ESA Pages 体验之旅(3):ESA CLI 的构建配置
函数入口需要将 TS 和 SDK 整理成一份 ESM 文件。接入 Node SDK 后,ESA CLI 的默认打包在模块解析阶段失败,需要检查它使用的 esbuild 配置。
这次没有直接沿用 ESA CLI 的默认打包。固定在 1.0.13 时,它按 browser 规则解析依赖,仅外置一组固定的 node: 模块;应用的 Node SDK 使用裸模块名和 CommonJS,部分引用无法解析。自行生成 worker 后,默认 deploy 又可能处理一次入口。因此由应用生成完整 ESM 产物,CLI 用 --no-bundle 读取并上传这个文件。
本次检查的是 CLI 在本地 deploy 时对函数入口进行的自动打包,源码固定在提交 604e28bb3d2e4024230ae5f54dd0eb752db11547。控制台 Git 工作流中的 buildCommand 是应用构建命令,另行配置。
CLI 1.0.13 的默认配置
prodBuild.ts使用 esbuild,关键配置可概括为:
{ bundle: true, format: 'esm', platform: 'browser', external: NODE_EXTERNALS }
NODE_EXTERNALS 是固定名单,并不等于所有 Node 内置模块。最小对照得到以下结果:
| 输入 | CLI 1.0.13 的结果 |
|---|---|
node:crypto、node:util |
名单内,保留为外部导入 |
裸 crypto、util,没有同名包或别名 |
解析失败 |
node:fs |
不在这份名单内,仍然失败 |
裸 util,提供同名 npm 测试包 |
打包该包,不会自动指向宿主模块 |
加上 node: 前缀后,模块仍需命中外置名单,或有其他解析处理。被外置的模块还需要在 ESA 中验证实际支持情况。
应用另外引入了 Node polyfill,以解决 SDK 依赖缺失;JSPM 属于这套插件的传递依赖,未由 CLI 默认注入。它带来的环境假设记录在运行时探测中。
platform、format 与 target
esbuild 的 platform 是一组解析与输出默认值。browser 会考虑包的 browser 入口或替换声明,node 默认外置 Node 内置模块,两者还影响包导出条件和默认入口字段。配置了额外插件时,插件也可以自行选择实现。
| 配置 | 主要职责 | 不能由此保证 |
|---|---|---|
platform |
模块解析规则及相关默认值 | 目标宿主拥有全部 Node 或浏览器 API |
format |
输出 ESM、CJS 等模块格式 | 外置的 require 自动变成可用 import |
target |
输出语法的兼容范围 | 自动补全运行时缺少的 API |
完整差异还包括默认 format、mainFields、exports 条件、browser 映射、特定 NODE_ENV 替换、binary loader 与内联 HTML 转义,见 esbuild platform 文档。显式配置可以覆盖其中一些默认值。
ESA 同时提供 Web API 和部分 Node 模块,browser 解析配合明确的 external 可以适用于这种宿主。本例失败在实际 SDK 的模块名和入口要求超出了默认配置,需要由应用补足解析规则。
如果目标是完整 Node.js,platform: 'node' 通常合适。目标换成 ESA 后,仍要逐项处理留下的宿主导入,不能只改这个选项就结束。
ESM 产物中的 require
esbuild 能把普通 CommonJS 包内联进 ESM,通过包装函数维护 exports。Node 原生模块不同:它们由宿主实现,外置后只留下加载引用。
例如 SDK 的 require('crypto') 被外置,最终 ESM 里可能出现 __require('crypto')。这个 helper 保留了加载调用,crypto 的实现仍需宿主提供。Node 的 ESM 作用域本身也没有 CommonJS require,因此加载方式也需要处理。
Node 可以显式用 createRequire(import.meta.url) 建立加载器,但 ESA 是否提供 node:module 和对应行为需要另测。这次也验证过针对少数已知模块的静态导入桥接;它不能处理任意动态路径。例如下面的字面量 require 也可能触发 Dynamic require of "node:crypto" is not supported;错误里的 Dynamic 不代表源码一定使用了变量路径。
// CJS 依赖
const crypto = require('node:crypto');
module.exports = crypto.createHash('sha256').update('abc').digest('hex');
// 内联进 ESM 后,外部加载仍可能保留为 __require('node:crypto')。
静态 import 会提前初始化依赖,直接替换条件或函数中的 require 可能改变副作用顺序、捕获异常与惰性加载。因此模块格式、依赖实现和加载器需要分别处理。Node 互操作、createRequire 配置和固定模块桥接见ESM 与 CommonJS 加载实验弹窗。
用 –no-bundle 上传
已内联的依赖不会凭空恢复成原包入口,但文件中尚存的外部引用,仍可能在第二次构建中被重新解析。裸内置模块可能再次失败,外部 npm 包可能再次被打包,预期保留的环境分支也可能被折叠。
因此最终产物一旦由应用明确生成,就应明确后续是否还会改写。CLI 已提供 --no-bundle;在核对版本中,它直接读取 entry 文件,不再执行这轮 esbuild 打包。
pnpm build
esa-cli deploy --environment production --no-preview --no-bundle --skip-update-check
这条命令用于已经准备好两份产物的发布环境,执行 deploy 会更新远端项目。--no-bundle 不负责递归收集剩余文件,入口若还引用本地相对模块,必须先让它们进入 worker;受支持的宿主导入可以保留。
项目使用的构建配置
截至 2026-10-29,应用的 scripts/build-edge.mjs 仍使用 platform: 'neutral'、mainFields: ['module', 'main'],并显式配置 Node 兼容插件。README 的发布流程已经要求跳过 CLI 二次打包。使用 ESA 原生 crypto、buffer 的适配仍是隔离原型,生产构建尚未切换。
跳过 CLI 二次打包,只控制最终文件怎样上传。worker 内部使用原生模块还是兼容实现,由应用构建决定,仍需逐项验证。
完整 CLI 对照与运行方式见固定版本的构建对照弹窗。上线前仍需检查未解析引用、文件体积、ESM 入口,再在真实 ESA 上验证宿主能力。
ESA CLI 1.0.13 的最小构建对照
源码固定 CLI 提交与依赖版本,用独立输入比较模块前缀、同名包、二次打包和 no-bundle。测试只构建本地文件,不部署云资源。
ESM 与 CommonJS 的加载边界
构建器输出 ESM,不代表依赖中的每个 require 都已经转换为可在目标宿主加载的 import。普通 CommonJS 可以内联,原生模块通常外置,动态路径还需要运行时资源与加载器。
这组实验用 esbuild 的 Node 产物区分几种加载方式;ESA 作为受限宿主的例子,单独核对它提供的模块,不把 Node 的互操作能力直接套过去。
输出格式与宿主能力分别决定什么
import { build } from 'esbuild';
await build({
entryPoints: ['src/index.js'],
outfile: 'release/index.mjs',
platform: 'node',
format: 'esm',
bundle: true,
});
这份配置表达「按 Node 规则解析依赖,输出 ESM」。它没有承诺所有依赖都已内联,也没有自动给 ESM 作用域安装 CommonJS 的 require。将同样的文件上传到 ESA,还要确认保留的外部模块和加载方式是否由宿主提供。
CJS 与 ESM 也不是天然的单向包含关系。Node 可以让 ESM 导入 CJS;较新的 Node 也能在条件满足时 require 同步 ESM,但顶层 await、版本和具体导出形态仍会影响结果。这些是 Node 加载器的能力,不能直接当作 ESA 的能力。Node 的模块互操作文档
内联 CommonJS 与保留外部加载的区别
platform: 'node' 不等于必须输出 CommonJS。Node 支持两种模块系统,但文件扩展名、包的 type 和依赖加载方式需要互相匹配。
ESM 与 CJS 的兼容需要区分内联的模块和留给宿主的模块。esbuild 支持 CommonJS:普通 JavaScript 模块可以被编进产物,通过内部包装函数保留 exports、缓存等行为,最终输出仍然可以是 ESM。实验中的纯 CJS 包内联进 ESM 后能正常执行。
但 Node 内置 crypto 的实现由 Node 宿主提供,不是一份可直接复制进 bundle 的普通 JS 包。platform: 'node' 默认将它 external,也就是保留加载引用,等程序执行时再取得实现。
例如某个 CJS 依赖写了:
const crypto = require('node:crypto');
module.exports = crypto.createHash('sha256').update('abc').digest('hex');
esbuild 将这个依赖内联进 ESM 时,关键加载仍可能表现为下面的形式。这里是省略包装细节的等价节选:
var crypto = __require('node:crypto');
SDK 的其余 JavaScript 已进入 bundle,crypto 本身却仍然 external。__require 会尝试使用宿主的 require;完整 Node 的 ESM 作用域也没有默认 require,所以会报:
Dynamic require of "node:crypto" is not supported
错误里的 Dynamic require 指运行时加载,不证明源码一定写了 require(variable)。字面量形式的 require('node:crypto') 也可以触发它。内部普通 CJS 包装函数可能同样含有 require 字样,也不能仅靠搜索单词就认定产物有问题。
为什么不自动把它改成 import?静态 import 的依赖会在模块正文执行前初始化,而 require 可以在条件、函数或 try/catch 中执行。直接提升到静态 import,可能改变副作用顺序、失败捕获和惰性加载。esbuild 作者在 issue #1148 的讨论解释过这个语义取舍;0.28.2 的固定实验也确认,外部 require 不会因为 format: 'esm' 自动变成原生 import。
这个边界可以按目标分别处理:
| 需求 | 可以选择的处理 | 交付与语义边界 |
|---|---|---|
| 完整 Node,允许 CJS 输出 | 输出 .cjs |
Node 直接提供 require |
| 完整 Node,保持 ESM,允许保留包 | ESM import 引入外置 CJS 依赖 | 随包交付生产依赖,由 Node 为 CJS 文件提供加载器 |
| 完整 Node,ESM 产物仍需同步 require | 用 Node 的 createRequire 提供局部 require | 明确依赖 Node 的 module API |
| 目标只适合 ESM import,模块名单固定 | 采用 ESM 依赖入口,或通过构建插件桥接已验证模块 | 核对导出形态、初始化顺序和宿主接口 |
| 大量依赖需要自动转换外部 CJS 加载 | 评估 Rollup 官方 CommonJS 插件 | 按配置验证转换语义与动态加载范围 |
createRequire 是 Node 官方 module API,本身是正式的加载器桥接。esbuild 的 banner 是在输出文件中插入这段声明的配置入口,并不等于伪造一个 Node 实现。例如完整 Node 的 ESM 产物可以显式配置:
banner: {
js: 'import { createRequire as __nodeCreateRequire } from "node:module"; const require = __nodeCreateRequire(import.meta.url);',
},
这段声明提供的是局部 require,没有修改全局对象。它适合明确运行于完整 Node 的产物;ESM 输出到边缘运行时时,应确认该宿主是否提供所需 module API,不能照搬这个方案。
成熟工具也需要选择加载语义。例如 Rollup 官方 CommonJS 插件的 requireNodeBuiltins 默认关闭;显式开启后,它同样使用 createRequire 保留内置模块的惰性加载,并提醒这会依赖 node:module。封装成插件可以减少配置细节,却不会消除宿主能力要求。业务项目可以集中维护一份构建适配,避免在业务源码中重复这些声明。
固定原生模块的桥接也可以由插件完成:把已知 CJS 引用解析到一个虚拟模块,该模块用原生 import 取得宿主实现,再通过 module.exports 保留 CJS 调用方期望的对象。实验里的 crypto 桥接产物只保留 import 类型的外部引用,不需要宿主 require;完整实现放在配套源码中。它没有自行实现 crypto,也不尝试改写任意动态 require,不能直接推广为已适配整个 SDK 或其他宿主。
普通 Node ESM 加载 CJS、打包器内联 CJS,以及 ESM 产物保留外部 require,是三个不同的场景。先看实际产物和 external 的加载类型,才能判断缺少的是依赖实现、模块绑定,还是宿主能力。
把模块问题放回交付物中检查
发布前应该检查真正的入口文件,而不是只确认构建命令退出为零:
| 产物中的内容 | 需要继续确认 |
|---|---|
| 内部 CJS 包装函数 | 对应依赖是否已内联,调用是否只经过内部包装 |
| 外部 ESM import | 目标宿主是否提供模块,或交付包是否保留依赖文件 |
| 外部 require / __require | 目标作用域是否有对应加载器,能否使用受控桥接 |
| 运行时目录扫描 | 发布后目录和资源是否仍存在 |
| 已转换的业务入口 | 框架路由、初始化和错误路径是否保持正确 |
文件即路由也属于这条边界。框架可以在构建时扫描文件并生成路由表,通用 bundler 则不会自动理解任意框架约定。运行时仍扫描目录的代码,需要保留目录或修改资源清单。Node.js 构建专题保留了文件路由与资源交付的示意,不能把这些行为归结为 esm / cjs 选项。
普通 CJS 内联、外部 require、Node createRequire 和固定原生模块桥接,都在构建实验资料统一维护,避免多篇文章分别修改一份测试实现。19 项控制场景既检查成功,也检查预期的构建与运行失败;它们没有调用真实云服务。