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

下面写个简单的示例,先准备一个项目:

  1. 新建目录 pkg-test
  2. 目录里执行 npm init -y 初始化 npm 项目
  3. 执行 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 年未更新了,预编译版本也很陈旧。感兴趣的朋友可以试试。

参考#

赞赏

微信