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 年未更新了,预编译版本也很陈旧。感兴趣的朋友可以试试。


