构建工具打包 Node 原生模块的踩坑记录
起因
有个项目是使用 Babel 作为 TypeScript AST 解析器,但 Babel 依赖多、类型支持也不好,后来换成了 oxc-parser。虽说代码能跑,但构建打包后运行就报错找不到 native binding。
这个问题不限于 oxc-parser,任何带 .node 原生二进制的模块(swc等)在做 bundle 时都会遇到。
问题本质
这类原生模块安装后,会根据当前平台下载对应的 binding 包,比如 Windows x64 对应 @xxx/binding-win32-x64-msvc,里面有个 .node 二进制文件:
node_modules/@oxc-parser/binding-win32-x64-msvc/
├── package.json
├── parser.win32-x64-msvc.node ← 原生二进制
└── README.md
bundler(esbuild、rollup、tsdown 等)没法把 .node 文件内联到 JS bundle 里。构建后 JS 代码打包到了 dist/,但 .node 文件还留在 node_modules 中,运行时自然找不到。
解法方案
解决方法很简单,把当前平台对应的 .node 文件拷到 dist 目录就行了。以 tsdown 为例:
tsdown.config.ts
export default defineConfig({
copy: [
{
from: ['./node_modules/@oxc-parser/binding-win32-x64-msvc/parser.win32-x64-msvc.node'],
to: './dist'
}
]
})
这里只针对了当前设备的平台,如果需要支持多平台分发,得把各平台的 .node 文件都拷进去,或者在 CI 里按目标平台分别构建。
为什么放到 dist 就能被找到
看一下这类模块内部的加载逻辑(以 oxc-parser 的 bindings.js 为例),简化后大概是这样:
bindings.js
const require = createRequire(import.meta.url)
function requireNative() {
if (process.platform === 'win32' && process.arch === 'x64') {
// 第一步:尝试从同目录加载 .node 文件
try {
return require('./parser.win32-x64-msvc.node')
} catch (e) {
loadErrors.push(e)
}
// 第二步:fallback 到对应的 binding 包
try {
return require('@oxc-parser/binding-win32-x64-msvc')
} catch (e) {
loadErrors.push(e)
}
}
// 其他平台同理...
}
关键在 createRequire(import.meta.url),它的 require 基于当前文件所在目录解析相对路径。bundle 后这段代码被内联到了 dist/index.cjs,所以 ./parser.win32-x64-msvc.node 会从 dist/ 目录去找。把 .node 文件拷到 dist,刚好能被第一步命中。
构建工具打包 Node 原生模块的踩坑记录
http://localhost/articles/bundling-native-modules-in-nodejs