E02 的 `commonMain` 状态机先在 JVM 与 Node 的 JS 目标上运行;E03 增加 linuxX64 ELF。现在把同一个 `TaskState` 编到 Kotlin/Wasm,是否就能让 JavaScript 客户端不经适配地调用同一组导出?公共业务源码可以一样,生成模块的加载方式、异常对象与异步契约却不自动相同。

本轮固定 Kotlin Multiplatform 2.2.21、Gradle 8.14、JDK 21,在 Node 22.23.2 上直接载入实际生成的 JS 与 Wasm 文件。Wasm 概述在核对时仍注明 Beta;没有浏览器、DOM、WASI 或服务器流量的结果。详细版本、失败构建、四目标 XML 和 Node 客户端断言在 writing-plans/kotlin/verification/E04/。

状态规则共享,导出声明分开放

原 commonMain 的 advanceTask 仍没有 kotlin.js 或 Wasm 导入。构建另外声明 wasmJs { nodejs() },各自的测试目标复用 commonTest:Queued + Start、正常完成、排队取消和非法转移必须在实际运行时通过。wasmJsNodeTest 的 XML 报 tests="1" failures="0",打印 TARGET=Wasm/Node;同次复跑还执行了 JVM、JS/Node 和 linuxX64 目标测试。编译产生 Wasm 并不代表客户端能加载它,后者由单独的 Node 脚本检查。

JS 和 Wasm 分别在 jsMain、wasmJsMain 定义一个标注 @JsExport 的 String → String 函数,它们都调用公共状态机:

1
2
3
4
5
@JsExport
fun taskReplyJs(text: String): String {
require(text.isNotBlank()) { "Task text must not be blank" }
return advanceTask(TaskState.Running, TaskSignal.Success(text)).toString()
}

Wasm 对应函数名是 taskReplyWasm,代码结构相同。把字符串处理与状态机之间的适配留在平台源集,避免因想演示共享而把 JS 导出注解强塞进 JVM/Native 所用的 commonMain。这个出口仅返回一个可读实验字符串;真正对外 API 不宜用 toString() 当稳定 JSON 协议,任务 ID、错误码与状态版本需要另行设计。官方 JS 导出文档和 Wasm/JS 导出文档分别描述可见声明与目标限制,不能用一种目标的模块目录推断另一种目标的产物。

Node 客户端用 require(...) 载入 Kotlin/JS 生成的 CommonJS .js,实测调用位置为 module.example.tasks.taskReplyJs。Wasm 产物通过动态 import(...) 加载 ESM .mjs,该文件再实例化同目录 .wasm,实测函数直接位于具名导出 taskReplyWasm。两边加载时会运行样本 main 并留下 JS_ENTRY、WASM_ENTRY 行;脚本随后检查各自实际可访问的导出函数,而非只搜索文件里的符号名。因此“同一公共函数”不意味着调用者可以交换 require 与 import 或忽略 Wasm 模块的实例化阶段。

编码、错误和 Promise 要分开问

测试输入 hé🧪 经过两种导出都得到 Succeeded(result=hé🧪),说明这个输入在该构建/Node 组合中完成往返。外层 Node 的 TextEncoder 把原字符串编码成 7 个 UTF-8 字节,而其 JS String.length 是 4 个 UTF-16 code unit。值虽然一致,不能把 JS 长度当成网络消息或数据库字段的 UTF-8 字节上限;这与 04 对 JVM 字符串的区分是同一类边界,但数据在这里是跨 JS/Wasm 加载的,不是 Java String 的测量结果。

空白输入对两个入口都触发 require,Node 捕获到同一错误消息 Task text must not be blank。实测 JS 异常构造器是 IllegalArgumentException,Wasm 是 Error;脚本只断言拒绝与消息,不把 Kotlin 异常类的 JS 外形承诺为两种产物的共同 ABI。不同宿主、不同生成器版本的堆栈与异常原型也未经验证。API 客户端若需要稳定错误形状,应在出口定义明确的错误 DTO,而非逐字符匹配异常的 toString()。

JS 目标另导出 taskReplyAsyncJs,返回 Promise<String>。它在 Promise executor 中调用同步 taskReplyJs:有效任务 resolve 为上述状态文本,空白任务 reject 为可检查的异常,Node 客户端分别 await 和 assert.rejects,并确认返回值有 then 方法。这里验证的是同步结果适配成 Promise 时的成功/拒绝传播;函数没有外部异步 I/O、超时、取消或协程 Job,因此绝不据此声称 Promise 取消会停止 Kotlin 协程。Wasm 出口尚未导出 Promise,也没有跨 Wasm 的 suspend→Promise 测试,这项明确 NOT_RUN。

锁文件失败属于交付证据

在 E02 的库目标上新增 JS/Wasm executable 会改变 npm 布局。第一次增量构建由 :kotlinStorePackageLock 拒绝:原锁文件已变,不能静默用生成目录的临时依赖冒充可复跑源码。按 Kotlin Gradle Plugin 的诊断执行 kotlinUpgradePackageLock,将更新后的锁文件随项目保存,然后完整执行两目标编译与测试。初次失败、锁更新日志和重跑的 BUILD SUCCESSFUL 分别存放,npm 对传递包的弃用提示没有被抹掉;它提示后续升级风险,不推翻这一轮已经实际运行的断言。

归档解包后可在缓存依赖和 Node 22 条件下复跑:

1
2
3
cd kotlin-language
PATH=/path/to/node-22/bin:$PATH npm_config_offline=true bash shared-model/verify-js-wasm.sh
./gradlew --offline --no-daemon --console=plain clean test loopbackTest packageTest

同名素材归档里的脚本曾先跑 E03 的 Native/C 回归,再执行 JVM、JS、Wasm 的公共测试与实际 Node 客户端;这说明归档脚本把跨目标回归串在一起,不代表 JS/Wasm 的 Node 导出必须依赖 Native/C。当前根工程脚本将 JVM、JS/Node、Wasm/Node 的公共测试和 Node 导出正反例作为本篇主路径;Native/C 回归单独属于 E03。站点里的正文、下载包和页面导航另由全量源码 Hexo 生成验收。结论限定为 Node 22 中的 JS 与 Wasm/JS 路径;浏览器 DOM、网络断连、WASI、Wasm Promise 和不同浏览器引擎全部 NOT_RUN。Kotlin 官方 Wasm 概述与 配置文档说明目标与运行环境边界,但不能替代这些尚未运行的平台测试。

练习与答案

练习一:JS 和 Wasm 都返回 Succeeded(result=hé🧪),能否让客户端共用同一段加载代码?答案:不能。JS 走 CommonJS require,Wasm 走 ESM 动态导入并实例化 .wasm;加载契约不同。

练习二:空白输入在两端消息相同,能否把异常类也当成共同 ABI?答案:不能。实测构造器一个是 IllegalArgumentException,一个是 Error;稳定 API 应输出明确错误 DTO。

练习三:taskReplyAsyncJs 返回 Promise,是否证明 Wasm 的 suspend 导出也可取消?答案:不能。当前 Promise 是 JS 目标同步函数的适配,Wasm Promise 与 suspend 取消都是 NOT_RUN。