单可执行文件应用#

稳定性:1.1 - 活跃开发

该功能允许将 Node.js 应用方便地分发到未安装 Node.js 的系统上。

Node.js 通过允许将由 Node.js 准备的数据块(可以包含捆绑脚本)注入到 node 二进制文件中,从而支持创建单可执行文件应用。启动时,程序会检查是否已注入任何内容。如果找到数据块,它将执行其中的脚本。否则,Node.js 将按常规方式运行。

单可执行文件应用功能支持使用 CommonJSECMAScript 模块系统运行单个嵌入式脚本。

用户可以使用 node 二进制文件本身以及任何可以将资源注入二进制文件的工具,从其捆绑脚本中创建单可执行文件应用。

  1. 创建一个 JavaScript 文件

    echo 'console.log(`Hello, ${process.argv[2]}!`);' > hello.js
    
  2. 创建一个配置文件,用于构建可以注入到单可执行文件应用中的数据块(详情请参阅生成单可执行文件准备数据块

    • 在非 Windows 系统上
    echo '{ "main": "hello.js", "output": "sea" }' > sea-config.json
    
    • 在 Windows 上
    echo '{ "main": "hello.js", "output": "sea.exe" }' > sea-config.json
    

    .exe 扩展名是必需的。

  3. 生成目标可执行文件

    node --build-sea sea-config.json
    
  4. 对二进制文件进行签名(仅限 macOS 和 Windows)

    • 在 macOS 上
    codesign --sign - hello
    
    • 在 Windows 上(可选)

    需要证书才能完成此操作。不过,未签名的二进制文件仍然可以运行。

    signtool sign /fd SHA256 hello.exe
    
  5. 运行二进制文件

    • 在非 Windows 系统上
    $ ./hello world
    Hello, world!
    
    • 在 Windows 上
    $ .\hello.exe world
    Hello, world!
    

使用 --build-sea 生成单可执行文件应用#

要直接生成单可执行文件应用,可以使用 --build-sea 标志。它接受一个 JSON 格式的配置文件路径。如果传递给它的路径不是绝对路径,Node.js 将使用相对于当前工作目录的路径。

该配置目前读取以下顶级字段

{
  "main": "/path/to/bundled/script.js",
  "mainFormat": "commonjs", // Default: "commonjs", options: "commonjs", "module"
  "executable": "/path/to/node/binary", // Optional, if not specified, uses the current Node.js binary
  "output": "/path/to/write/the/generated/executable",
  "disableExperimentalSEAWarning": true, // Default: false
  "useSnapshot": false,  // Default: false
  "useCodeCache": true, // Default: false
  "execArgv": ["--no-warnings", "--max-old-space-size=4096"], // Optional
  "execArgvExtension": "env", // Default: "env", options: "none", "env", "cli"
  "assets": {  // Optional
    "a.dat": "/path/to/a.dat",
    "b.txt": "/path/to/b.txt"
  }
}

如果路径不是绝对路径,Node.js 将使用相对于当前工作目录的路径。用于生成数据块的 Node.js 二进制文件版本必须与将要注入数据块的二进制文件版本相同。

注意:生成跨平台 SEA(例如,在 darwin-arm64 上生成 linux-x64 的 SEA)时,必须将 useCodeCacheuseSnapshot 设置为 false,以避免生成不兼容的可执行文件。由于代码缓存和快照只能在编译它们的同一平台上加载,生成的执行文件在尝试加载在不同平台上构建的代码缓存或快照时可能会在启动时崩溃。

资产#

用户可以通过在配置中添加 assets 字段(键值对字典)来包含资产。在构建时,Node.js 会读取指定路径下的资产并将它们捆绑到准备数据块中。在生成的可执行文件中,用户可以使用 sea.getAsset()sea.getAssetAsBlob() API 检索资产。

{
  "main": "/path/to/bundled/script.js",
  "output": "/path/to/write/the/generated/executable",
  "assets": {
    "a.jpg": "/path/to/a.jpg",
    "b.txt": "/path/to/b.txt"
  }
}

单可执行文件应用可以通过以下方式访问资产

const { getAsset, getAssetAsBlob, getRawAsset, getAssetKeys } = require('node:sea');
// Get all asset keys.
const keys = getAssetKeys();
console.log(keys); // ['a.jpg', 'b.txt']
// Returns a copy of the data in an ArrayBuffer.
const image = getAsset('a.jpg');
// Returns a string decoded from the asset as UTF8.
const text = getAsset('b.txt', 'utf8');
// Returns a Blob containing the asset.
const blob = getAssetAsBlob('a.jpg');
// Returns an ArrayBuffer containing the raw asset without copying.
const raw = getRawAsset('a.jpg');

有关更多信息,请参阅 sea.getAsset()sea.getAssetAsBlob()sea.getRawAsset()sea.getAssetKeys() API 的文档。

启动快照支持#

useSnapshot 字段可用于启用启动快照支持。在这种情况下,最终可执行文件启动时不会执行 main 脚本。相反,它会在构建机器上生成单可执行文件应用准备数据块时运行。生成的准备数据块将包含一个捕获由 main 脚本初始化的状态的快照。注入了准备数据块的最终可执行文件将在运行时反序列化该快照。

useSnapshot 为 true 时,主脚本必须调用 v8.startupSnapshot.setDeserializeMainFunction() API 来配置用户启动最终可执行文件时需要运行的代码。

在单可执行文件应用中使用快照的典型模式是

  1. 在构建时,在构建机器上运行主脚本,将堆初始化为准备好接收用户输入的状态。脚本还应使用 v8.startupSnapshot.setDeserializeMainFunction() 配置一个主函数。该函数将被编译并序列化到快照中,但在构建时不会被调用。
  2. 在运行时,主函数将在用户机器的反序列化堆上运行,以处理用户输入并生成输出。

启动快照脚本的一般限制同样适用于用于构建单可执行文件应用快照的主脚本,并且主脚本可以使用 v8.startupSnapshot API 来适应这些限制。请参阅 有关 Node.js 中启动快照支持的文档

V8 代码缓存支持#

当在配置中将 useCodeCache 设置为 true 时,在生成单可执行文件准备数据块期间,Node.js 将编译 main 脚本以生成 V8 代码缓存。生成的代码缓存将成为准备数据块的一部分并被注入到最终可执行文件中。当启动单可执行文件应用时,Node.js 不会从零开始编译 main 脚本,而是使用代码缓存来加速编译,然后执行脚本,从而提高启动性能。

注意:useCodeCachetrue 时,import() 不起作用。

执行参数#

execArgv 字段可用于指定在单可执行文件应用启动时自动应用的 Node.js 特定参数。这允许应用开发者配置 Node.js 运行时选项,而无需最终用户了解这些标志。

例如,以下配置

{
  "main": "/path/to/bundled/script.js",
  "output": "/path/to/write/the/generated/executable",
  "execArgv": ["--no-warnings", "--max-old-space-size=2048"]
}

将指示 SEA 使用 --no-warnings--max-old-space-size=2048 标志启动。在嵌入可执行文件的脚本中,可以使用 process.execArgv 属性访问这些标志

// If the executable is launched with `sea user-arg1 user-arg2`
console.log(process.execArgv);
// Prints: ['--no-warnings', '--max-old-space-size=2048']
console.log(process.argv);
// Prints ['/path/to/sea', 'path/to/sea', 'user-arg1', 'user-arg2']

用户提供的参数位于 process.argv 数组中,从索引 2 开始,类似于如果应用程序以下列方式启动的情况

node --no-warnings --max-old-space-size=2048 /path/to/bundled/script.js user-arg1 user-arg2

执行参数扩展#

execArgvExtension 字段控制除了 execArgv 字段中指定的参数外,如何提供额外的执行参数。它接受三个字符串值之一

  • "none":不允许扩展。仅使用 execArgv 中指定的参数,并且 NODE_OPTIONS 环境变量将被忽略。
  • "env"(默认) NODE_OPTIONS 环境变量可以扩展执行参数。这是为了保持向后兼容性的默认行为。
  • "cli":可执行文件可以使用 --node-options="--flag1 --flag2" 启动,这些标志将被解析为 Node.js 的执行参数,而不是传递给用户脚本。这允许使用 NODE_OPTIONS 环境变量不支持的参数。

例如,使用 "execArgvExtension": "cli"

{
  "main": "/path/to/bundled/script.js",
  "output": "/path/to/write/the/generated/executable",
  "execArgv": ["--no-warnings"],
  "execArgvExtension": "cli"
}

可执行文件可以按如下方式启动

./my-sea --node-options="--trace-exit" user-arg1 user-arg2

这等同于运行

node --no-warnings --trace-exit /path/to/bundled/script.js user-arg1 user-arg2

单可执行文件应用 API#

node:sea 内置模块允许从嵌入到可执行文件中的 JavaScript 主脚本与单可执行文件应用进行交互。

sea.isSea()#

  • 返回:<boolean> 该脚本是否正在单可执行文件应用内运行。

sea.getAsset(key[, encoding])#

此方法可用于检索在构建时配置为捆绑到单可执行文件应用中的资产。当找不到匹配的资产时,会抛出错误。

  • key <string> 单可执行文件应用配置中 assets 字段指定的字典中资产的键。
  • encoding <string> 如果指定,资产将解码为字符串。接受 TextDecoder 支持的任何编码。如果未指定,将返回一个包含资产副本的 ArrayBuffer
  • 返回:<string> | <ArrayBuffer>

sea.getAssetAsBlob(key[, options])#

类似于 sea.getAsset(),但以 <Blob> 形式返回结果。当找不到匹配的资产时,会抛出错误。

  • key <string> 单可执行文件应用配置中 assets 字段指定的字典中资产的键。
  • options <Object>
  • type <string> blob 的可选 MIME 类型。
  • 返回:<Blob>
  • sea.getRawAsset(key)#

    此方法可用于检索在构建时配置为捆绑到单可执行文件应用中的资产。当找不到匹配的资产时,会抛出错误。

    sea.getAsset()sea.getAssetAsBlob() 不同,此方法不返回副本。相反,它返回捆绑在可执行文件内部的原始资产。

    目前,用户应避免写入返回的数组缓冲区。如果注入部分未标记为可写或未正确对齐,写入返回的数组缓冲区很可能会导致崩溃。

    • key <string> 单可执行文件应用配置中 assets 字段指定的字典中资产的键。
    • 返回: <ArrayBuffer>

    sea.getAssetKeys()#

    • 返回 <string[]> 一个包含嵌入在可执行文件中的所有资产键的数组。如果没有嵌入资产,则返回一个空数组。

    此方法可用于检索嵌入到单可执行文件应用中的所有资产键数组。在不在单可执行文件应用内运行时,会抛出错误。

    在注入的主脚本中#

    注入的主脚本的模块格式#

    要指定 Node.js 应如何解释注入的主脚本,请在单可执行文件应用配置中使用 mainFormat 字段。接受的值为

    • "commonjs":注入的主脚本被视为 CommonJS 模块。
    • "module":注入的主脚本被视为 ECMAScript 模块。

    如果未指定 mainFormat 字段,则默认为 "commonjs"

    目前,"mainFormat": "module" 不能与 "useSnapshot" 一起使用。

    注入的主脚本中的模块加载#

    在注入的主脚本中,模块加载不会读取文件系统。默认情况下,require()import 语句都只能加载内置模块。尝试加载只能在文件系统中找到的模块将抛出错误。

    用户可以将他们的应用捆绑到一个独立的 JavaScript 文件中以注入到可执行文件中。这也确保了更确定的依赖关系图。

    要从注入的主脚本中加载文件系统中的模块,用户可以使用 module.createRequire() 创建一个可以从文件系统加载的 require 函数。例如,在 CommonJS 入口点

    const { createRequire } = require('node:module');
    require = createRequire(__filename);
    

    注入的主脚本中的 require()#

    注入的主脚本中的 require() 与未注入的模块可用的 require() 不同。目前,除了 require.main 之外,它没有任何非注入 require() 所具有的属性。

    注入的主脚本中的 __filenamemodule.filename#

    注入的主脚本中 __filenamemodule.filename 的值等于 process.execPath

    注入的主脚本中的 __dirname#

    注入的主脚本中 __dirname 的值等于 process.execPath 的目录名。

    注入的主脚本中的 import.meta#

    当使用 "mainFormat": "module" 时,import.meta 在注入的主脚本中可用,并具有以下属性

    目前不支持 import.meta.resolve

    注入的主脚本中的 import()#

    当使用 "mainFormat": "module" 时,import() 可用于动态加载内置模块。尝试使用 import() 从文件系统加载模块将抛出错误。

    在注入的主脚本中使用原生插件#

    原生插件可以通过在用于生成单可执行文件准备数据块的配置文件的 assets 字段中指定它们,作为资产捆绑到单可执行文件应用中。然后,插件可以通过将资产写入临时文件并使用 process.dlopen() 加载它,在注入的主脚本中加载。

    {
      "main": "/path/to/bundled/script.js",
      "output": "/path/to/write/the/generated/executable",
      "assets": {
        "myaddon.node": "/path/to/myaddon/build/Release/myaddon.node"
      }
    }
    
    // script.js
    const fs = require('node:fs');
    const os = require('node:os');
    const path = require('node:path');
    const { getRawAsset } = require('node:sea');
    const addonPath = path.join(os.tmpdir(), 'myaddon.node');
    fs.writeFileSync(addonPath, new Uint8Array(getRawAsset('myaddon.node')));
    const myaddon = { exports: {} };
    process.dlopen(myaddon, addonPath);
    console.log(myaddon.exports);
    fs.rmSync(addonPath);
    

    已知警告:如果单可执行文件应用是由在 Linux arm64 Docker 容器中运行的 postject 产生的,所产生的 ELF 二进制文件没有正确的哈希表来加载插件,并且会在 process.dlopen() 上崩溃。在其他平台,或至少在非容器 Linux arm64 环境上构建单可执行文件应用以绕过此问题。

    注意事项#

    单可执行文件应用创建过程#

    此处记录的过程可能会发生变化。

    1. 生成单可执行文件准备数据块#

    为了构建单可执行文件应用,Node.js 首先会生成一个包含运行捆绑脚本所需所有必要信息的数据块。使用 --build-sea 时,此步骤与注入过程一起在内部完成。

    将准备数据块转储到磁盘#

    在引入 --build-sea 之前,引入了一种较旧的工作流程,用于将准备数据块写入磁盘以便由外部工具进行注入。这仍然可用于验证目的。

    要将准备数据块转储到磁盘进行验证,请使用 --experimental-sea-config。这会写入一个文件,可以使用 postject 等工具将其注入到 Node.js 二进制文件中。

    该配置与 --build-sea 的配置类似,不同之处在于 output 字段指定了写入生成的数据块文件的路径,而不是最终可执行文件的路径。

    {
      "main": "/path/to/bundled/script.js",
      // Instead of the final executable, this is the path to write the blob.
      "output": "/path/to/write/the/generated/blob.blob"
    }
    
    2. 将准备数据块注入到 node 二进制文件中#

    为了完成单可执行文件应用的创建,生成的数据块需要按照下文记录的那样注入到 node 二进制文件的副本中。

    使用 --build-sea 时,此步骤与数据块生成一起在内部完成。

    • 如果 node 二进制文件是 PE 文件,则应将数据块作为名为 NODE_SEA_BLOB 的资源注入。
    • 如果 node 二进制文件是 Mach-O 文件,则应将数据块作为 NODE_SEA 段中名为 NODE_SEA_BLOB 的节注入。
    • 如果 node 二进制文件是 ELF 文件,则应将数据块作为名为 NODE_SEA_BLOB 的注记 (note) 注入。

    然后,SEA 构建过程会搜索二进制文件中的 NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2:0 保险丝 (fuse) 字符串,并将最后一个字符翻转为 1,以表明资源已被注入。

    手动注入准备数据块#

    在引入 --build-sea 之前,引入了一种较旧的工作流程,允许外部工具将生成的数据块注入到 node 二进制文件的副本中。

    例如,使用 postject

    1. 创建 node 可执行文件的副本,并根据需要命名它

      • 在非 Windows 系统上
      cp $(command -v node) hello
      
      • 在 Windows 上
      node -e "require('fs').copyFileSync(process.execPath, 'hello.exe')"
      

      .exe 扩展名是必需的。

    2. 移除二进制文件的签名(仅限 macOS 和 Windows)

      • 在 macOS 上
      codesign --remove-signature hello
      
      • 在 Windows 上(可选)

      signtool 可以从已安装的 Windows SDK 中使用。如果跳过此步骤,请忽略 postject 发出的任何与签名相关的警告。

      signtool remove /s hello.exe
      
    3. 通过使用以下选项运行 postject,将数据块注入到复制的二进制文件中

      • hello / hello.exe - 在第 4 步中创建的 node 可执行文件副本的名称。
      • NODE_SEA_BLOB - 二进制文件中存储数据块内容的资源/注记/节的名称。
      • sea-prep.blob - 在第 1 步中创建的数据块的名称。
      • --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 - Node.js 项目用于检测文件是否已被注入的 保险丝
      • --macho-segment-name NODE_SEA(仅在 macOS 上需要) - 二进制文件中存储数据块内容的段名称。

      总结一下,这是每个平台所需的命令

      • 在 Linux 上

        npx postject hello NODE_SEA_BLOB sea-prep.blob \
            --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
        
      • 在 Windows 上 - PowerShell

        npx postject hello.exe 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
        

    平台支持#

    单可执行文件支持仅在以下平台上的 CI 中定期进行测试

    这是因为缺乏更好的工具来生成可以在其他平台上测试此功能的单可执行文件。

    欢迎就其他资源注入工具/工作流程提出建议。请在 https://github.com/nodejs/single-executable/discussions 发起讨论,帮助我们记录它们。