Sandbank ESM / CommonJS 集成方案
问题
sandbank v0.5.4 + @sandbank.dev/core v0.3.6 + adapter 子包都是 ESM-only("type": "module",exports 字段定义)。
apps/api 当前 tsconfig:
module: "CommonJS"moduleResolution: "Node"(不是 Node16/NodeNext/Bundler)
→ 直接 import { createProvider } from '@sandbank.dev/core' 编译失败(找不到模块类型)+ 运行时 require() 会抛 ERR_REQUIRE_ESM。
这是真实工程障碍,POC 启动前必须先选一个方案。
方案对比
方案 A · 整个 apps/api 切到 NodeNext ESM
把 module: CommonJS → Node16 或 NodeNext,moduleResolution → 同步。
优点:彻底解决,未来用其他 ESM 包零摩擦 缺点:
- 所有相对 import 必须显式带
.js扩展(NestJS 现有约 150 处 import 都要改) - Jest 配置改成 ESM(
extensionsToTreatAsEsm、globalSetup等) - NestJS 启动入口
bootstrap.ts需要确认与 ESM 兼容 - 现有 unit / e2e 测试可能全部重新跑通
工作量:1-2 天 风险:可能踩到 NestJS 5.x ESM 兼容坑(@nestjs/swagger 等周边包历史上对 ESM 支持迟)
方案 B · Dynamic import(推荐)
保留 CommonJS 主体,只在 task-runner-provider.factory.ts 内用 await import():
// factory.ts —— 改成 async
export async function createTaskRunnerProvider(env: Env): Promise<TaskRunnerProvider> {
const { createProvider } = await import('@sandbank.dev/core');
if (env.TASK_RUNNER_MODE === 'private') {
const { BoxLiteAdapter } = await import('@sandbank.dev/boxlite');
return createProvider(new BoxLiteAdapter({ baseUrl: env.BOXLITE_HOST! })) as TaskRunnerProvider;
}
const { FlyioAdapter } = await import('@sandbank.dev/flyio');
return createProvider(new FlyioAdapter({
apiToken: env.FLY_API_TOKEN!,
appName: env.FLY_APP_NAME,
})) as TaskRunnerProvider;
}配套:
- TaskRunnerDispatcher constructor 改用 lazy init(首次 dispatch 时 await provider)
- 类型用本地最小子集(已实现:见
apps/api/src/task-runner/task-runner-provider.types.ts)——避免 TSnodenextresolution 强制 - 或用
tsconfig加"moduleResolution": "Node16"仅为类型解析(编译仍 CJS)
优点:
- 影响面最小,只动 factory 一个文件
- TS / Jest / NestJS 启动无需改造
- Node.js 自 v12 起 CommonJS 模块可以
await import()ESM
缺点:
- factory 变 async,调用方要 await(dispatcher 已经是 async,问题不大)
- 类型用本地最小子集,要手动跟 sandbank API 对齐(升级 sandbank 时注意)
工作量:2-4 小时 推荐理由:风险最小、与 POC “13 天双轨”工期最匹配
方案 C · 直接调 Fly Machines REST(放弃 Sandbank 抽象)
把 Sandbank 抽象去掉,主 API 直接 fetch('https://api.machines.dev/v1/apps/...')。
优点:
- 完全绕开 ESM 问题
- POC 公有云路径最快上线
缺点:
- 破坏双轨架构核心 —— 私有化路径要自己再实现一份 BoxLite REST 调用
- 报告里所有关于”Sandbank adapter 切换”的论述失效
- 未来加 backend 工作量直接变 1-2 天/个
判定:仅作为公有云 demo 紧急上线时的退路;不推荐作为本期 POC 决策。
推荐路径
采纳方案 B:
- 把
task-runner-provider.factory.ts的createTaskRunnerProvider改为 async + dynamic import - dispatcher 的 constructor 改为 lazy init(首次
dispatch()时await this.factory(env)) - 单元测试相应改 async
- typecheck + jest 全过即完成
预计 0.5 天工作量,比方案 A 的 1-2 天小一个数量级,且不引入 ESM 切换风险。
已装的依赖
// apps/api/package.json
{
"dependencies": {
"sandbank": "^0.5.4",
"@sandbank.dev/core": "^0.3.6",
"@sandbank.dev/flyio": "^0.2.0",
"@sandbank.dev/boxlite": "^0.2.0"
}
}这些包已 pnpm add 完毕。方案 B 实施后无需重装。
何时切方案 A
如果未来发现:
- 业务需要更多 ESM-only 包(sandbank 之外)
- Dynamic import 的 async 传染让 dispatcher 变得难读
- @nestjs 生态在 2026 后期对 ESM 支持更完善
→ 那时再考虑统一切 ESM。本期 POC 不做。