sea: support ESM entry point in SEA

This uses the new StartExecutionCallbackWithModule embedder
API to support ESM entrypoint in SEA via a new configuration
field `"mainFormat"`. The behavior currently aligns with the
embedder API and is mostly in sync with the CommonJS entry
point behavior, except that support for code caching and
snapshot is left for follow-ups.

PR-URL: https://github.com/nodejs/node/pull/61813
Reviewed-By: Michaël Zasso <targos@protonmail.com>
Reviewed-By: Anna Henningsen <anna@addaleax.net>
This commit is contained in:
Joyee Cheung authored and GitHub committed 2026-02-18 21:03:33 +00:00
1 parent 94b1f66b73
commit af0d6d4248
6 files changed
+188 -25

No files matched your search

+59 -16
View File
@@ -31,8 +31,8 @@ into the `node` binary. During start up, the program checks if anything has been
injected. If the blob is found, it executes the script in the blob. Otherwise
Node.js operates as it normally does.
The single executable application feature currently only supports running a
single embedded script using the [CommonJS][] module system.
The single executable application feature supports running a
single embedded script using the [CommonJS][] or the [ECMAScript Modules][] module system.
Users can create a single executable application from their bundled script
with the `node` binary itself and any tool which can inject resources into the
@@ -110,6 +110,7 @@ The configuration currently reads the following top-level fields:
```json
{
"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
@@ -290,14 +291,12 @@ This would be equivalent to running:
node --no-warnings --trace-exit /path/to/bundled/script.js user-arg1 user-arg2
```
## In the injected main script
### Single-executable application API
## Single-executable application API
The `node:sea` builtin allows interaction with the single-executable application
from the JavaScript main script embedded into the executable.
#### `sea.isSea()`
### `sea.isSea()`
<!-- YAML
added:
@@ -383,25 +382,48 @@ This method can be used to retrieve an array of all the keys of assets
embedded into the single-executable application.
An error is thrown when not running inside a single-executable application.
### `require(id)` in the injected main script is not file based
## In the injected main script
`require()` in the injected main script is not the same as the [`require()`][]
available to modules that are not injected. It also does not have any of the
properties that non-injected [`require()`][] has except [`require.main`][]. It
can only be used to load built-in modules. Attempting to load a module that can
only be found in the file system will throw an error.
### Module format of the injected main script
Instead of relying on a file based `require()`, users can bundle their
application into a standalone JavaScript file to inject into the executable.
This also ensures a more deterministic dependency graph.
To specify how Node.js should interpret the injected main script, use the
`mainFormat` field in the single-executable application configuration.
The accepted values are:
However, if a file based `require()` is still needed, that can also be achieved:
* `"commonjs"`: The injected main script is treated as a CommonJS module.
* `"module"`: The injected main script is treated as an ECMAScript module.
If the `mainFormat` field is not specified, it defaults to `"commonjs"`.
Currently, `"mainFormat": "module"` cannot be used together with `"useSnapshot"`
or `"useCodeCache"`.
### Module loading in the injected main script
In the injected main script, module loading does not read from the file system.
By default, both `require()` and `import` statements would only be able to load
the built-in modules. Attempting to load a module that can only be found in the
file system will throw an error.
Users can bundle their application into a standalone JavaScript file to inject
into the executable. This also ensures a more deterministic dependency graph.
To load modules from the file system in the injected main script, users can
create a `require` function that can load from the file system using
`module.createRequire()`. For example, in a CommonJS entry point:
```js
const { createRequire } = require('node:module');
require = createRequire(__filename);
```
### `require()` in the injected main script
`require()` in the injected main script is not the same as the [`require()`][]
available to modules that are not injected.
Currently, it does not have any of the properties that non-injected
[`require()`][] has except [`require.main`][].
### `__filename` and `module.filename` in the injected main script
The values of `__filename` and `module.filename` in the injected main script
@@ -412,6 +434,26 @@ are equal to [`process.execPath`][].
The value of `__dirname` in the injected main script is equal to the directory
name of [`process.execPath`][].
### `import.meta` in the injected main script
When using `"mainFormat": "module"`, `import.meta` is available in the
injected main script with the following properties:
* `import.meta.url`: A `file:` URL corresponding to [`process.execPath`][].
* `import.meta.filename`: Equal to [`process.execPath`][].
* `import.meta.dirname`: The directory name of [`process.execPath`][].
* `import.meta.main`: `true`.
`import.meta.resolve` is currently not supported.
### `import()` in the injected main script
<!-- TODO(joyeecheung): support and document module.registerHooks -->
When using `"mainFormat": "module"`, `import()` can be used to dynamically
load built-in modules. Attempting to use `import()` to load modules from
the file system will throw an error.
### Using native addons in the injected main script
Native addons can be bundled as assets into the single-executable application
@@ -599,6 +641,7 @@ start a discussion at <https://github.com/nodejs/single-executable/discussions>
to help us document them.
[CommonJS]: modules.md#modules-commonjs-modules
[ECMAScript Modules]: esm.md#modules-ecmascript-modules
[ELF]: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
[Generating single executable preparation blobs]: #1-generating-single-executable-preparation-blobs
[Mach-O]: https://en.wikipedia.org/wiki/Mach-O
+60 -7
View File
@@ -84,6 +84,11 @@ size_t SeaSerializer::Write(const SeaResource& sea) {
static_cast<uint8_t>(sea.exec_argv_extension));
written_total +=
WriteArithmetic<uint8_t>(static_cast<uint8_t>(sea.exec_argv_extension));
Debug("Write SEA main code format %u\n",
static_cast<uint8_t>(sea.main_code_format));
written_total +=
WriteArithmetic<uint8_t>(static_cast<uint8_t>(sea.main_code_format));
DCHECK_EQ(written_total, SeaResource::kHeaderSize);
Debug("Write SEA code path %p, size=%zu\n",
@@ -161,6 +166,11 @@ SeaResource SeaDeserializer::Read() {
SeaExecArgvExtension exec_argv_extension =
static_cast<SeaExecArgvExtension>(extension_value);
Debug("Read SEA resource exec argv extension %u\n", extension_value);
uint8_t format_value = ReadArithmetic<uint8_t>();
CHECK_LE(format_value, static_cast<uint8_t>(ModuleFormat::kModule));
ModuleFormat main_code_format = static_cast<ModuleFormat>(format_value);
Debug("Read SEA main code format %u\n", format_value);
CHECK_EQ(read_total, SeaResource::kHeaderSize);
std::string_view code_path =
@@ -219,6 +229,7 @@ SeaResource SeaDeserializer::Read() {
exec_argv_extension,
code_path,
code,
main_code_format,
code_cache,
assets,
exec_argv};
@@ -501,6 +512,25 @@ std::optional<SeaConfig> ParseSingleExecutableConfig(
config_path);
return std::nullopt;
}
} else if (key == "mainFormat") {
std::string_view format_str;
if (field.value().get_string().get(format_str)) {
FPrintF(stderr,
"\"mainFormat\" field of %s is not a string\n",
config_path);
return std::nullopt;
}
if (format_str == "commonjs") {
result.main_format = ModuleFormat::kCommonJS;
} else if (format_str == "module") {
result.main_format = ModuleFormat::kModule;
} else {
FPrintF(stderr,
"\"mainFormat\" field of %s must be one of "
"\"commonjs\" or \"module\"\n",
config_path);
return std::nullopt;
}
}
}
@@ -512,6 +542,23 @@ std::optional<SeaConfig> ParseSingleExecutableConfig(
"\"useCodeCache\" is redundant when \"useSnapshot\" is true\n");
}
// TODO(joyeecheung): support ESM with useSnapshot and useCodeCache.
if (result.main_format == ModuleFormat::kModule &&
static_cast<bool>(result.flags & SeaFlags::kUseSnapshot)) {
FPrintF(stderr,
"\"mainFormat\": \"module\" is not supported when "
"\"useSnapshot\" is true\n");
return std::nullopt;
}
if (result.main_format == ModuleFormat::kModule &&
static_cast<bool>(result.flags & SeaFlags::kUseCodeCache)) {
FPrintF(stderr,
"\"mainFormat\": \"module\" is not supported when "
"\"useCodeCache\" is true\n");
return std::nullopt;
}
if (result.main_path.empty()) {
FPrintF(stderr,
"\"main\" field of %s is not a non-empty string\n",
@@ -709,6 +756,7 @@ ExitCode GenerateSingleExecutableBlob(
builds_snapshot_from_main
? std::string_view{snapshot_blob.data(), snapshot_blob.size()}
: std::string_view{main_script.data(), main_script.size()},
config.main_format,
optional_sv_code_cache,
assets_view,
exec_argv_view};
@@ -792,20 +840,25 @@ void GetAssetKeys(const FunctionCallbackInfo<Value>& args) {
}
MaybeLocal<Value> LoadSingleExecutableApplication(
const StartExecutionCallbackInfo& info) {
const StartExecutionCallbackInfoWithModule& info) {
// Here we are currently relying on the fact that in NodeMainInstance::Run(),
// env->context() is entered.
Local<Context> context = Isolate::GetCurrent()->GetCurrentContext();
Environment* env = Environment::GetCurrent(context);
Environment* env = info.env();
Local<Context> context = env->context();
SeaResource sea = FindSingleExecutableResource();
CHECK(!sea.use_snapshot());
// TODO(joyeecheung): this should be an external string. Refactor UnionBytes
// and make it easy to create one based on static content on the fly.
Local<Value> main_script =
ToV8Value(env->context(), sea.main_code_or_snapshot).ToLocalChecked();
return info.run_cjs->Call(
env->context(), Null(env->isolate()), 1, &main_script);
ToV8Value(context, sea.main_code_or_snapshot).ToLocalChecked();
Local<Value> kind =
v8::Integer::New(env->isolate(), static_cast<int>(sea.main_code_format));
Local<Value> resource_name =
ToV8Value(context, env->exec_path()).ToLocalChecked();
Local<Value> args[] = {main_script, kind, resource_name};
return info.run_module()->Call(
env->context(), Null(env->isolate()), arraysize(args), args);
}
bool MaybeLoadSingleExecutableApplication(Environment* env) {
@@ -821,7 +874,7 @@ bool MaybeLoadSingleExecutableApplication(Environment* env) {
// this check is just here to guard against the unlikely case where
// the SEA preparation blob has been manually modified by someone.
CHECK(!env->snapshot_deserialize_main().IsEmpty());
LoadEnvironment(env, StartExecutionCallback{});
LoadEnvironment(env, StartExecutionCallbackWithModule{});
return true;
}
+6 -2
View File
@@ -11,6 +11,7 @@
#include <unordered_map>
#include <vector>
#include "node.h"
#include "node_exit_code.h"
namespace node {
@@ -43,6 +44,7 @@ struct SeaConfig {
std::string executable_path;
SeaFlags flags = SeaFlags::kDefault;
SeaExecArgvExtension exec_argv_extension = SeaExecArgvExtension::kEnv;
ModuleFormat main_format = ModuleFormat::kCommonJS;
std::unordered_map<std::string, std::string> assets;
std::vector<std::string> exec_argv;
};
@@ -52,6 +54,7 @@ struct SeaResource {
SeaExecArgvExtension exec_argv_extension = SeaExecArgvExtension::kEnv;
std::string_view code_path;
std::string_view main_code_or_snapshot;
ModuleFormat main_code_format = ModuleFormat::kCommonJS;
std::optional<std::string_view> code_cache;
std::unordered_map<std::string_view, std::string_view> assets;
std::vector<std::string_view> exec_argv;
@@ -59,8 +62,9 @@ struct SeaResource {
bool use_snapshot() const;
bool use_code_cache() const;
static constexpr size_t kHeaderSize =
sizeof(kMagic) + sizeof(SeaFlags) + sizeof(SeaExecArgvExtension);
static constexpr size_t kHeaderSize = sizeof(kMagic) + sizeof(SeaFlags) +
sizeof(SeaExecArgvExtension) +
sizeof(ModuleFormat);
};
bool IsSingleExecutable();
+6
View File
@@ -0,0 +1,6 @@
{
"main": "sea.mjs",
"output": "sea",
"mainFormat": "module",
"disableExperimentalSEAWarning": true
}
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { dirname } from 'node:path';
// Test createRequire with process.execPath.
const assert2 = createRequire(process.execPath)('node:assert');
assert.strictEqual(assert2.strict, assert.strict);
// Test import.meta properties. This should be in sync with the CommonJS entry
// point's corresponding values.
assert.strictEqual(import.meta.url, pathToFileURL(process.execPath).href);
assert.strictEqual(import.meta.filename, process.execPath);
assert.strictEqual(import.meta.dirname, dirname(process.execPath));
assert.strictEqual(import.meta.main, true);
// TODO(joyeecheung): support import.meta.resolve when we also support
// require.resolve in CommonJS entry points, the behavior of the two
// should be in sync.
// Test import() with a built-in module.
const { strict } = await import('node:assert');
assert.strictEqual(strict, assert.strict);
console.log('ESM SEA executed successfully');
@@ -0,0 +1,33 @@
'use strict';
require('../common');
const {
buildSEA,
skipIfBuildSEAIsNotSupported,
} = require('../common/sea');
skipIfBuildSEAIsNotSupported();
// This tests the creation of a single executable application with an ESM
// entry point using the "mainFormat": "module" configuration.
const tmpdir = require('../common/tmpdir');
const fixtures = require('../common/fixtures');
const { spawnSyncAndExitWithoutError } = require('../common/child_process');
tmpdir.refresh();
const outputFile = buildSEA(fixtures.path('sea', 'esm'));
spawnSyncAndExitWithoutError(
outputFile,
{
env: {
NODE_DEBUG_NATIVE: 'SEA',
...process.env,
},
},
{
stdout: /ESM SEA executed successfully/,
});