From 9b56e661073a6636844b2e1473ee6df15f2cd1ca Mon Sep 17 00:00:00 2001 From: Joyee Cheung Date: Tue, 27 Jan 2026 01:17:17 +0100 Subject: [PATCH] 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. --- doc/api/single-executable-applications.md | 75 +++++++++++++++---- src/node_sea.cc | 67 +++++++++++++++-- src/node_sea.h | 8 +- test/fixtures/sea/esm/sea-config.json | 6 ++ test/fixtures/sea/esm/sea.mjs | 24 ++++++ .../test-single-executable-application-esm.js | 33 ++++++++ 6 files changed, 188 insertions(+), 25 deletions(-) create mode 100644 test/fixtures/sea/esm/sea-config.json create mode 100644 test/fixtures/sea/esm/sea.mjs create mode 100644 test/sea/test-single-executable-application-esm.js diff --git a/doc/api/single-executable-applications.md b/doc/api/single-executable-applications.md index 12049f07a620bf..d90397dfd668eb 100644 --- a/doc/api/single-executable-applications.md +++ b/doc/api/single-executable-applications.md @@ -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()` + +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 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 diff --git a/src/node_sea.cc b/src/node_sea.cc index bffdc72d1d1791..85f5eb118c3845 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -84,6 +84,11 @@ size_t SeaSerializer::Write(const SeaResource& sea) { static_cast(sea.exec_argv_extension)); written_total += WriteArithmetic(static_cast(sea.exec_argv_extension)); + + Debug("Write SEA main code format %u\n", + static_cast(sea.main_code_format)); + written_total += + WriteArithmetic(static_cast(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(extension_value); Debug("Read SEA resource exec argv extension %u\n", extension_value); + + uint8_t format_value = ReadArithmetic(); + CHECK_LE(format_value, static_cast(ModuleFormat::kModule)); + ModuleFormat main_code_format = static_cast(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 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 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(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(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& args) { } MaybeLocal 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 = Isolate::GetCurrent()->GetCurrentContext(); - Environment* env = Environment::GetCurrent(context); + Environment* env = info.env(); + Local 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 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 kind = + v8::Integer::New(env->isolate(), static_cast(sea.main_code_format)); + Local resource_name = + ToV8Value(context, env->exec_path()).ToLocalChecked(); + Local 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; } diff --git a/src/node_sea.h b/src/node_sea.h index 34596972b60219..dd0b89db841eed 100644 --- a/src/node_sea.h +++ b/src/node_sea.h @@ -11,6 +11,7 @@ #include #include +#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 assets; std::vector 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 code_cache; std::unordered_map assets; std::vector 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(); diff --git a/test/fixtures/sea/esm/sea-config.json b/test/fixtures/sea/esm/sea-config.json new file mode 100644 index 00000000000000..e5ee27ff7f4c85 --- /dev/null +++ b/test/fixtures/sea/esm/sea-config.json @@ -0,0 +1,6 @@ +{ + "main": "sea.mjs", + "output": "sea", + "mainFormat": "module", + "disableExperimentalSEAWarning": true +} diff --git a/test/fixtures/sea/esm/sea.mjs b/test/fixtures/sea/esm/sea.mjs new file mode 100644 index 00000000000000..c8c9fe0ca1d571 --- /dev/null +++ b/test/fixtures/sea/esm/sea.mjs @@ -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'); diff --git a/test/sea/test-single-executable-application-esm.js b/test/sea/test-single-executable-application-esm.js new file mode 100644 index 00000000000000..9f7366cb0e2405 --- /dev/null +++ b/test/sea/test-single-executable-application-esm.js @@ -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/, + });