Cloudflare 重构 Workers 模块注册表以增强 Node.js 兼容
我们已经重写了模块注册表工人,Workers 运行时的核心开源组件,以实现更快、更符合标准,并更紧密地与 Node.js 模块注册表保持一致。
过去几年,我们不断增加支持更多以及更多Node.js运行时 API。Workers 运行时现在支持你可能想在无服务器环境中使用的所有稳定 Node.js API,这些 API 现在是默认启用让你部署更大规模的Node.js应用到Cloudflare(现在所有套餐最高可达64 MiB——我们取消了压缩捆绑包大小的限制)。
但仅靠API兼容性还不够:Node.js应用还依赖于运行时的解析、加载和缓存模.ESM、CommonJS 和 WebAssembly 都是你可以导入 Worker 代码中的模块类型。
在运行时中处理所有这些的系统称为模块注册表。
你今天就可以通过启用new_module_registry兼容性标志在你的工人中。
当你启用new_module_registry兼容性标志:
import.meta.url,import.meta.main, 和import.meta.resolve()全都工作。- 模块指定符被解析并解析为真实的URL,包括查询字符串和片段。
node:内置的模块无论你怎么到达,都会解析到同一个模块实例。- 导入属性(
with { type: 'json' })被正确验证。 require()在ES模块上,Node.js'require(esm)规则。- 错误无论触发了哪条加载路径,都使用一致的类和消息。
- 模块在导入时(静态或动态)编译时会很懒散。
- WebAssembly 模块支持源阶段导入。
为了深入了解这个新模块注册表如何与V8的模块API交互,我们补充了Workerd 参考文档这些内容会详细解析。但对于大多数基于Workers构建的人来说,你希望了解这些变化如何提升兼容性并帮助你构建。
为此,我们将在下面的章节中深入探讨这些变化。
Workers 运行时如何加载你给它的代码
当你向Cloudflare部署工作者时,牛仔或快点将你所有 Worker 代码从多个文件和依赖中“打包”到一个或多个模块中,运行时这些模块会上传到 Cloudflarewrangler deploy.
默认情况下,Wrangler 几乎将所有这些代码打包到一个模块脚本中。它运行起来esbuild在底层,处理后,还会进行相对导入和require()将大多数NPM依赖调用到该文件中。
该文件import以及require()语句在进程中被常规函数取代。当该捆绑到达 Workers 运行时(workerd通常,Workers运行时几乎没有剩下的模块图可供处理。大多数不同的模块都被打包在一个文件里。我们见证过这些脚本增长到数十万行。
为什么在上传服务器端代码到 Cloudflare 之前,必须将多个模块打包到一个文件中?多年来,在 Workers 运行时技术上一直可以上传多个模块,甚至不同类型的模块。
然而,运行时未能以与其他运行时一致的方式解析模块。例如,如果你的代码或依赖import.meta.resolve()要解析通往另一个模块的路径,该代码会失败,因为import.meta.resolve()没有被支持。
当你使用Cloudflare Vite 插件,快8捆绑你的代码滚动,而不是用 Wrangler 捆绑你的代码埃斯维尔德.Rolldown 解决导入和 npm 依赖,必要时将 CommonJS 转换为 ESM,并输出一个入口模块及通过代码拆分创建的额外块,例如动态导入.因此,Workers运行时会获得一个较小的构建生成模块图,而不是应用程序的原始源图。
Workers 运行时的新模块注册表实现为像 Rolldown 这样的捆绑器打开了更少的转换机会,更多依赖运行时来处理模块解析。
当你在工作程序中导入Node.js API时,默认情况下你导入的是内置的模块workerd.它不会作为多重填充捆绑在你的代码中。Wasm、文本和二进制模块也作为独立文件提供给Workers运行时。
它们是通过指定符引用的,而不是内联的。如果你部署时用--no-bundle或者你的工具直接上传一个工人作为多个模块,完整的模块图在运行时会完全按照你写的顺序显示。
在所有这些情况下,某个东西都必须接受一个规范符,计算它实际指向的代码,编译它,然后交给 V8 一个可以链接和运行的模块对象。
在workerd那是模块注册表的工作。
为什么要采用新的实现方式?
最初的注册表将指定符解析为文件系统风格的路径,而非URL。这听起来像是个小区别,但它排除了很多问题:没有干净利落的方法实现import.meta.url,相对导入不遵循与new URL(),以及以下协议node:以及cloudflare:被用作特殊案例字符串前缀,而不是,嗯,协议。
它还会在前期编译整个Worker捆绑包,无论某个模块是否被导入,并且根据V8隔离程序保留所有内容的独立私有副本。Cloudflare运行多个V8隔离副本,将同一Worker的多个副本分摊到CPU核心,所以实际上这意味着要多次编译完全相同的源代码,同时在内存中保留多个源代码副本。
这些其实都不算是个漏洞,但让实现进化变得困难,而不破坏变更。新的注册表从URL作为指定格式开始,把懒惰和缓存共享当作从一开始就要设计的元素。
现有的注册表实现不会消失。目前,已部署的Workers将继续像以往一样工作。
import.meta
该import.metaAPI 提供模块的信息,如模块的 URL,以及它是否为主要入口模块:
那会打印类似这样的内容file:///bundle/index.js, main: true.
import.meta.main仅对配置为你工作者入口点的模块成立;其他模块则false.
import.meta.resolve()在不导入模块的情况下,解决当前模块的指定符:
它是一个纯字符串变换,和Node.js和浏览器中一样:它不会检查解析后的URL是否对应真实模块,而是抛出一个TypeError对于一个根本无法解析为URL的指定符,而不是返回null.如果你仔细看输出,值得知道的一个细节是:它以同样的方式规范百分比编码new URL()会,这意味着它会像./a/../b.js但它不会解码已经被编码为百分的字符。import.meta.resolve('%66oo.js')结算为file:///bundle/%66oo.js,不是file:///bundle/foo.js.
具体化词是URLs。
相对导入现在的解析方式相同new URL(specifier, base)会,因为这正是底层原理。完整URL不仅是相对路径,还能作为指定词使用:
更有趣的结果是查询字符串和片段的情况。根据浏览器使用的相同模块身份规则,具有不同查询字符串或片段的指定符仍被视为真正独立的模块实例,即使它指向相同的底层源:
./counter.js?a以及./counter.js?b加载同一个源,但它们分别评估,每个都有自己的import.meta.url,每个实例都会获得其任何顶级状态的副本。
再次导入相同的指定词和相同的查询字符串,仍然会返回同一个实例,因此这并不是强制每次导入重新评估的方法。
导入属性经过正确验证
原始模块注册表实现会无声地忽略导入属性,违反规范。当实现使用任何不理解的导入属性时,预期会抛出异常。
json目前是唯一启用的导入属性类型,因为它是唯一相关的属性类型TC39提案已进入第四阶段。text以及bytes被认可,因为它们追踪导入文本以及导入字节提案,但它们会被以具体错误拒绝,而不是被默默忽略或视为无支持的语法:
除了type现在也是一个硬错误,而不是被忽略:
如果你指定的类型和模块的实际类型不一致:
require(esm)遵循Node.js规则
如果你require()无论是直接在CommonJS模块内部还是通过ES模块,最终发现是ES模块require('node:module').createRequire(),注册表从Node.jsrequire(esm)行为:
- 如果模块有一个名为
'module.exports',Node.js ES模块实际控制 require() 的机制,返回该值。 - 否则,
require()返回模块的命名空间对象。 - 唯一的例外是
workerd自己的node:内置的。它们作为ES模块实现,将类似CommonJS风格的API包裹在默认导出中,因此要求直接返回该默认导出。require('node:buffer').Buffer表现如你所预期;你不会获得带有.default你需要解开自己。
这里有个限制:如果你要求的模块或其模块图中的任何内容有顶层await, require()用投掷代替挡网或交还半成品:
这和Node.js自己的相符ERR_REQUIRE_ASYNC_MODULE限制:require()必须同步返回,且对于尚未完成评估的模块,没有合理的价值可归还。
使用import()对于任何异步的事,都可以。无论导入顺序如何,这个检查都成立:模块不会变成require()——能因为已经有事了import()之前已经全面评估过。
如果你需要的是比Node.js更早的捆绑商输出require(esm)支撑和集合truthy __cjsUnwrapDefault导出作为标记,优先于上述两条规则,并返回默认导出。
这纯粹是为了让现有的预建捆绑包继续工作。
错误是一致的,并且使用正确的类
无论解析是否通过静态信号失败import,一个动态import(),或者require(),你会得到同一类错误,但消息形状相同:
"Module not found"是平原Error,因为这是找不到某物,而不是你传递的值出了问题。一个根本无法解析为URL的指定符是TypeError,与Node.js自己的匹配ERR_INVALID_MODULE_SPECIFIER.V8无法解除的循环依赖也是一个普通错误,绝不是TypeError.如果你是在动态基础上构建东西,这一点尤其重要import()就像你自己的加载器或重试包装器一样,因为无论触发哪个加载路径,你都可以可靠地分支错误类或消息。
WebAssembly 源代码阶段导入
你现在可以直接导入已编译但未实例化的WebAssembly模块形式,使用源相导入:
或者动态地:
无论哪种情况,你都会得到一个WebAssembly.Module直接返回,而不是像正常一样导入模块然后从default导出。由于源阶段导入是该语言的新功能,目前这只适用于WebAssembly;尝试在任何其他模块类型上使用都会触发SyntaxError,匹配Node.js及其他运行时的行为。
接下来是什么
试试看吧!给你的工作者添加new_module_registry兼容性标志:
它还没有默认的启用日期,所以无论你的工人用什么兼容日期,都不会自动开启,无论是旧的还是新的。你需要明确添加该标志。
我们非常希望得到你的反馈。workerd是开源的。如果你遇到的行为看起来像是回归,而不是这里描述的那些更改,请将其提交给Workerd 仓库.