Node.js SEA
2024-07-03 · 2,249 chars · 12 min read
SEA(Single executable applications, 单个可执行应用程序)是从 Node.js v18 起支持的一项实验性功能,可以将 Node.js 程序打包为一个可执行文件,方便在没有 Node 环境的机器上运行,目前还是活跃开发的状态(最新版 v22.3.0)。
不过这个不影响我使用,我的需求很简单,就是为了解决 ubuntu 里定时任务不方便执行 Node.js 程序的问题。低于 v18 的版本,可以考虑使用 vercel/pkg 代替,这里两个都试用一下。
使用 pkg 生成 SEA 文件#
pkg 的最新版本是 5.8.1,发布于 2023-05-08,目前仓库已经 public archive,未来就是使用 Node.js 原生的 SEA 功能了。
pkg 使用比较简单,看这个就很明白了:
pkg [options] <input> Options: -h, --help 帮助 -v, --version 版本 -t, --targets 逗号分隔的目标列表(参见示例) -c, --config Package.json 或任何具有顶级配置的 json 文件 --options 将 v8 选项烘焙为可执行文件以在其上运行 -o, --output 多个文件的输出文件名或模板 --out-path 输出的可执行文件的路径 -d, --debug 在打包过程中显示更多信息 -b, --build 不下载预构建的基础二进制文件,而是构建它们 --public 加速,指定公开项目 --public-packages 强制将指定的包视为公开的 --no-bytecode 跳过字节码生成并将源文件包含为纯 js --no-native-build 跳过本机插件构建 --no-signature 在 macOS 上跳过最终可执行文件的签名 --no-dict 逗号分隔的包名称列表,用来忽略字典。使用 -no-dict* 禁用所有词典 -C, --compress [default=None] 压缩算法,Brotli 或 GZip 示例: – 为 Linux、Mac OS 和 Windows 制作可执行文件 $ pkg index.js – 从当前目录获取 package.json,并使用 bin 作为入口文件 $ pkg . – 为特定目标机器制作可执行文件 $ pkg -t node16-win-arm64 index.js – 为你选定的多个目标机器制作可执行文件 $ pkg -t node16-linux,node18-linux,node16-win index.js – 将 -expose-gc 和 -max-heap-size=34 烘焙到可执行文件 $ pkg --options "expose-gc,max-heap-size=34" index.js – 把 packageA 和 packageB 视为 public $ pkg --public-packages "packageA,packageB" index.js – 全部依赖包视为 public $ pkg --public-packages "*" index.js – 将 -expose-gc 烘焙到可执行文件 $ pkg --options expose-gc index.js – 使用 GZip 减小可执行文件体积 $ pkg --compress GZip index.js
下面写个简单的示例,先准备一个项目:
- 新建目录
pkg-test
- 目录里执行
npm init -y
初始化 npm 项目 - 执行
npm install pkg
或者pnpm add pkg
把 pkg 安装到本地(我不喜欢全局安装),后面通过npx pkg
执行命令
然后新建 index.js
文件,随便安装几个依赖,写点代码:
import path from 'node:path' import { fileURLToPath } from 'node:url' import { format } from 'date-fns' import fs from 'fs-extra' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) const filePath = path.join(dirname, 'test.log') // 获取当前时间 const time = format(new Date(), 'yyyy-MM-dd HH:mm:ss') // 输出到文件 fs.appendFile(filePath, time + '\n')
使用 node index.js
执行这段代码,就会在当前目录输出一个 test.log
文件,里面是当前的时间。
接下来把 index.js
打包为可执行文件,执行 npx pkg index.js
:
> [email protected] > Targets not specified. Assuming: node16-linux-x64, node16-macos-x64, node16-win-x64 > Fetching base Node.js binaries to PKG_CACHE_PATH fetched-v16.16.0-linux-x64 [====================] 100% fetched-v16.16.0-macos-x64 [====================] 100% fetched-v16.16.0-win-x64 [====================] 100% > Warning Babel parse has failed: import.meta may appear only with 'sourceType: "module"' (6:31) > Warning Failed to make bytecode node16-x64 for file /snapshot/pkg-test/index.js > Warning Failed to make bytecode node16-x64 for file /snapshot/pkg-test/index.js > Warning Failed to make bytecode node16-x64 for file C:\snapshot\pkg-test\index.js
失败!看上去是 ES Modules 的问题...
Node.js 的「模块化」是真的烦,加上 TypeScript、Webpack、Babel、ts-node 等,是烦上加烦...
改为 CommonJS,再次执行打包,这次成功了,不过有个 Warning:
> [email protected] > Targets not specified. Assuming: node16-linux-x64, node16-macos-x64, node16-win-x64 > Warning Cannot stat, ENOENT /Users/keenwon/Test/pkg-test/test.log The file was required from '/Users/keenwon/Test/pkg-test/index.js'
执行下 ./index-macos
试试
pkg/prelude/bootstrap.js:657 const error = new Error( ^ Error: File or directory '/**/pkg-test/test.log' was not included into executable at compilation stage. Please recompile adding it as asset or script. at error_ENOENT (pkg/prelude/bootstrap.js:657:19) at openFromSnapshot (pkg/prelude/bootstrap.js:755:29) at Object.open (pkg/prelude/bootstrap.js:809:5) at Object.writeFile (node:fs:2137:6) at appendFile (node:fs:2208:6) at go$appendFile (/snapshot/pkg-test/node_modules/.pnpm/[email protected]/node_modules/graceful-fs/graceful-fs.js:159:14) at Object.appendFile (/snapshot/pkg-test/node_modules/.pnpm/[email protected]/node_modules/graceful-fs/graceful-fs.js:156:12) at /snapshot/pkg-test/node_modules/.pnpm/[email protected]/node_modules/universalify/index.js:9:12 at new Promise (<anonymous>) at Object.appendFile (/snapshot/pkg-test/node_modules/.pnpm/[email protected]/node_modules/universalify/index.js:7:14) { errno: -2, code: 'ENOENT', path: '/snapshot/pkg-test/test.log', pkg: true }
看来还是要解决下文件路径的问题。可以在这里了解下 pkg 的「镜像文件系统」,我直接上修改后的代码:
const path = require('path') const dateFns = require('date-fns') const fs = require('fs-extra') // 运行时判断路径,使用 process.cwd() const filePath = path.join(process.cwd(), 'test.log') // 获取当前时间 const time = dateFns.format(new Date(), 'yyyy-MM-dd HH:mm:ss') // 输出到文件 fs.appendFile(filePath, time + '\n')
重新打包,error 和 warning 都没有:
> [email protected] > Targets not specified. Assuming: node16-linux-x64, node16-macos-x64, node16-win-x64
再次执行 index-macos
,成功在当前路径下输出 test.log
,搞定!
把 index-macos
移动到别的目录,再次执行依然正常,说明所有依赖都打包内置了,并且会在运行时找到当前路径,输出日志文件。这基本上就满足我的需求了!
接下来再看看原生的 SEA 功能...
Node.js 原生 SEA 功能#
注意
- 目前 SEA 只支持 CommonJS
- 原生不支持
require()
,需要通过createRequire
实现,看下面的代码
还是刚才的项目,使用 nvm 把 node 切换到 22.3.0,index.js
文件现在长这样:
const { createRequire } = require('node:module'); require = createRequire(__filename); const path = require('path') const dateFns = require('date-fns') const fs = require('fs-extra') // 运行时判断路径,使用 process.cwd() const filePath = path.join(process.cwd(), 'test.log') // 获取当前时间 const time = dateFns.format(new Date(), 'yyyy-MM-dd HH:mm:ss') // 输出到文件 fs.appendFile(filePath, time + '\n')
直接执行是 ok 的,准备打包...
Node.js 原生 SEA 的原理是:把事先准备好的,包含脚本的 blob,注入到 node 可执行文件中。当 node 启动时,会检测是都存在已注入的 blob,如果存在就执行它,这就是 SEA 可执行文件;如果什么都没有注入,那还和普通的 node 一样。
开干!
先把代码编译为 blob。在项目根目录创建 sea-config.json
文件:
{ "main": "index.js", "output": "sea-prep.blob" }
执行 node --experimental-sea-config sea-config.json
:
$ node --experimental-sea-config sea-config.json Wrote single executable preparation blob to sea-prep.blob
然后把 node 可执行文件 copy 一份(不知道叫什么名字好,和官网一样叫 hello 吧),执行 cp $(command -v node) hello
,现在的根目录是这样的:
total 235664 -rwxr-xr-x 1 keenwon staff 115M 7 2 17:57 hello -rw-r--r-- 1 keenwon staff 279B 7 2 17:31 index.js drwxr-xr-x 9 keenwon staff 288B 7 2 17:04 node_modules -rw-r--r-- 1 keenwon staff 370B 7 2 17:31 package.json -rw-r--r-- 1 keenwon staff 29K 7 2 17:04 pnpm-lock.yaml -rw-r--r-- 1 keenwon staff 53B 7 2 17:55 sea-config.json -rw------- 1 keenwon staff 311B 7 2 17:56 sea-prep.blob
Note
如果是 macOS 或者 windows,要先移除掉 node 的签名(复制出来的 hello)
# macOS codesign --remove-signature hello # Windows(可能需要安装下 windows sdk https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/) signtool remove /s hello.exe
后面完成注入后,还要再签一次
# macOS codesign --sign - hello # Windows signtool sign /fd SHA256 hello.exe
开始使用 postject 注入 blob
# linux npx postject hello NODE_SEA_BLOB sea-prep.blob \ --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 # Windows npx postject hello.exe NODE_SEA_BLOB sea-prep.blob ` --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 # macOS npx postject hello NODE_SEA_BLOB sea-prep.blob \ --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \ --macho-segment-name NODE_SEA
这是我在 mac 上的执行输出:
$ npx postject hello NODE_SEA_BLOB sea-prep.blob \ --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \ --macho-segment-name NODE_SEA Need to install the following packages: [email protected] Ok to proceed? (y) Start injection of NODE_SEA_BLOB in hello... 💉 Injection done!
把上面说的签名加上,执行 ./hello
,有警告,但是运行正常:
(node:4529) ExperimentalWarning: Single executable application is an experimental feature and might change at any time (Use `hello --trace-warnings ...` to show where the warning was created)
把 hello 复制到别的目录,再次执行,报错:
node:internal/modules/cjs/loader:1222 throw err; ^ Error: Cannot find module 'date-fns' Require stack: - /Users/keenwon/Test/22222/hello at Module._resolveFilename (node:internal/modules/cjs/loader:1219:15) at Module._load (node:internal/modules/cjs/loader:1045:27) at TracingChannel.traceSync (node:diagnostics_channel:315:14) at wrapModuleLoad (node:internal/modules/cjs/loader:215:24) at Module.require (node:internal/modules/cjs/loader:1304:12) at require (node:internal/modules/helpers:123:16) at index.js:5:17 at embedderRunCjs (node:internal/util/embedding:37:10) { code: 'MODULE_NOT_FOUND', requireStack: [ '/Users/keenwon/Test/22222/hello' ] } Node.js v22.4.0
说明目前原生的 SEA 并没有处理依赖,上面 createRequire()
之后,也只是能在当前目录正常读取了。
记录几个问题#
尝鲜过程中遇到几个问题,记录下
问题1#
报错 Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: xxx
node:internal/util/embedding:48 throw new ERR_UNKNOWN_BUILTIN_MODULE(id); ^ Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: date-fns at embedderRequire (node:internal/util/embedding:48:11) at index.js:2:17 at embedderRunCjs (node:internal/util/embedding:37:10) { code: 'ERR_UNKNOWN_BUILTIN_MODULE' } Node.js v22.4.0
这是因为 SEA 中 require
只有 require.main
一个属性,解决方法上文讲过了。
问题2#
(node:45683) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension. (Use `node --trace-warnings ...` to show where the warning was created) /Users/keenwon/Test/pkg-test/index.js:1 import path from 'node:path' ^^^^^^ SyntaxError: Cannot use import statement outside a module at wrapSafe (node:internal/modules/cjs/loader:1383:18) at Module._compile (node:internal/modules/cjs/loader:1412:20) at Module._extensions..js (node:internal/modules/cjs/loader:1551:10) at Module.load (node:internal/modules/cjs/loader:1282:32) at Module._load (node:internal/modules/cjs/loader:1098:12) at TracingChannel.traceSync (node:diagnostics_channel:315:14) at wrapModuleLoad (node:internal/modules/cjs/loader:215:24) at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:158:5) at node:internal/main/run_main_module:30:49 Node.js v22.4.0
原因是 SEA 目前只支持 CommonJS。
如果你的项目是一个很大的 ES Modules 项目,只能先编译为 CommonJS 了。
小结#
整个体验下来,在当前这个时间点,还是 pkg 体验更好。
即使未来原生 SEA 更加完善了,但操作步骤太多,也很难直接用,必要的封装是少不了的。
pkg 目前已经满足我的需求了,如果大家有更复杂的场景,可以找一找其他 fork 版本试试,比如 yao-pkg/pkg。
社区里还有一个仓库叫 nexe,看上去 star 很多,不过 3 年未更新了,预编译版本也很陈旧。感兴趣的朋友可以试试。