跳到正文

ESA Pages 体验之旅(3):ESA CLI 的构建配置

ESA Pages 体验之旅 · 1 · 2 · 第 3 篇(当前) · 4 · 5 · 6 · 7 · 8 · 9

函数入口需要将 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 的最小构建对照

打开原文件 FILES.json

源码固定 CLI 提交与依赖版本,用独立输入比较模块前缀、同名包、二次打包和 no-bundle。测试只构建本地文件,不部署云资源。

node: 前缀与默认构建解析实验正在加载代码工作区...
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 字样,也不能仅靠搜索单词就认定产物有问题。

esbuild 可以内联普通 CommonJS 模块;外置内置模块的加载引用仍需要宿主加载器

为什么不自动把它改成 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 项控制场景既检查成功,也检查预期的构建与运行失败;它们没有调用真实云服务。