Skip to content

Commit 9b56e66

Browse files
committed
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.
1 parent 37ff1ea commit 9b56e66

File tree

6 files changed

+188
-25
lines changed

6 files changed

+188
-25
lines changed

doc/api/single-executable-applications.md

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ into the `node` binary. During start up, the program checks if anything has been
3131
injected. If the blob is found, it executes the script in the blob. Otherwise
3232
Node.js operates as it normally does.
3333

34-
The single executable application feature currently only supports running a
35-
single embedded script using the [CommonJS][] module system.
34+
The single executable application feature supports running a
35+
single embedded script using the [CommonJS][] or the [ECMAScript Modules][] module system.
3636

3737
Users can create a single executable application from their bundled script
3838
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:
110110
```json
111111
{
112112
"main": "/path/to/bundled/script.js",
113+
"mainFormat": "commonjs", // Default: "commonjs", options: "commonjs", "module"
113114
"executable": "/path/to/node/binary", // Optional, if not specified, uses the current Node.js binary
114115
"output": "/path/to/write/the/generated/executable",
115116
"disableExperimentalSEAWarning": true, // Default: false
@@ -290,14 +291,12 @@ This would be equivalent to running:
290291
node --no-warnings --trace-exit /path/to/bundled/script.js user-arg1 user-arg2
291292
```
292293

293-
## In the injected main script
294-
295-
### Single-executable application API
294+
## Single-executable application API
296295

297296
The `node:sea` builtin allows interaction with the single-executable application
298297
from the JavaScript main script embedded into the executable.
299298

300-
#### `sea.isSea()`
299+
### `sea.isSea()`
301300

302301
<!-- YAML
303302
added:
@@ -383,25 +382,48 @@ This method can be used to retrieve an array of all the keys of assets
383382
embedded into the single-executable application.
384383
An error is thrown when not running inside a single-executable application.
385384

386-
### `require(id)` in the injected main script is not file based
385+
## In the injected main script
387386

388-
`require()` in the injected main script is not the same as the [`require()`][]
389-
available to modules that are not injected. It also does not have any of the
390-
properties that non-injected [`require()`][] has except [`require.main`][]. It
391-
can only be used to load built-in modules. Attempting to load a module that can
392-
only be found in the file system will throw an error.
387+
### Module format of the injected main script
388+
389+
To specify how Node.js should interpret the injected main script, use the
390+
`mainFormat` field in the single-executable application configuration.
391+
The accepted values are:
392+
393+
* `"commonjs"`: The injected main script is treated as a CommonJS module.
394+
* `"module"`: The injected main script is treated as an ECMAScript module.
395+
396+
If the `mainFormat` field is not specified, it defaults to `"commonjs"`.
397+
398+
Currently, `"mainFormat": "module"` cannot be used together with `"useSnapshot"`
399+
or `"useCodeCache"`.
400+
401+
### Module loading in the injected main script
402+
403+
In the injected main script, module loading does not read from the file system.
404+
By default, both `require()` and `import` statements would only be able to load
405+
the built-in modules. Attempting to load a module that can only be found in the
406+
file system will throw an error.
393407

394-
Instead of relying on a file based `require()`, users can bundle their
395-
application into a standalone JavaScript file to inject into the executable.
396-
This also ensures a more deterministic dependency graph.
408+
Users can bundle their application into a standalone JavaScript file to inject
409+
into the executable. This also ensures a more deterministic dependency graph.
397410

398-
However, if a file based `require()` is still needed, that can also be achieved:
411+
To load modules from the file system in the injected main script, users can
412+
create a `require` function that can load from the file system using
413+
`module.createRequire()`. For example, in a CommonJS entry point:
399414

400415
```js
401416
const { createRequire } = require('node:module');
402417
require = createRequire(__filename);
403418
```
404419

420+
### `require()` in the injected main script
421+
422+
`require()` in the injected main script is not the same as the [`require()`][]
423+
available to modules that are not injected.
424+
Currently, it does not have any of the properties that non-injected
425+
[`require()`][] has except [`require.main`][].
426+
405427
### `__filename` and `module.filename` in the injected main script
406428

407429
The values of `__filename` and `module.filename` in the injected main script
@@ -412,6 +434,26 @@ are equal to [`process.execPath`][].
412434
The value of `__dirname` in the injected main script is equal to the directory
413435
name of [`process.execPath`][].
414436

437+
### `import.meta` in the injected main script
438+
439+
When using `"mainFormat": "module"`, `import.meta` is available in the
440+
injected main script with the following properties:
441+
442+
* `import.meta.url`: A `file:` URL corresponding to [`process.execPath`][].
443+
* `import.meta.filename`: Equal to [`process.execPath`][].
444+
* `import.meta.dirname`: The directory name of [`process.execPath`][].
445+
* `import.meta.main`: `true`.
446+
447+
`import.meta.resolve` is currently not supported.
448+
449+
### `import()` in the injected main script
450+
451+
<!-- TODO(joyeecheung): support and document module.registerHooks -->
452+
453+
When using `"mainFormat": "module"`, `import()` can be used to dynamically
454+
load built-in modules. Attempting to use `import()` to load modules from
455+
the file system will throw an error.
456+
415457
### Using native addons in the injected main script
416458

417459
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>
599641
to help us document them.
600642

601643
[CommonJS]: modules.md#modules-commonjs-modules
644+
[ECMAScript Modules]: esm.md#modules-ecmascript-modules
602645
[ELF]: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
603646
[Generating single executable preparation blobs]: #1-generating-single-executable-preparation-blobs
604647
[Mach-O]: https://en.wikipedia.org/wiki/Mach-O

src/node_sea.cc

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ size_t SeaSerializer::Write(const SeaResource& sea) {
8484
static_cast<uint8_t>(sea.exec_argv_extension));
8585
written_total +=
8686
WriteArithmetic<uint8_t>(static_cast<uint8_t>(sea.exec_argv_extension));
87+
88+
Debug("Write SEA main code format %u\n",
89+
static_cast<uint8_t>(sea.main_code_format));
90+
written_total +=
91+
WriteArithmetic<uint8_t>(static_cast<uint8_t>(sea.main_code_format));
8792
DCHECK_EQ(written_total, SeaResource::kHeaderSize);
8893

8994
Debug("Write SEA code path %p, size=%zu\n",
@@ -161,6 +166,11 @@ SeaResource SeaDeserializer::Read() {
161166
SeaExecArgvExtension exec_argv_extension =
162167
static_cast<SeaExecArgvExtension>(extension_value);
163168
Debug("Read SEA resource exec argv extension %u\n", extension_value);
169+
170+
uint8_t format_value = ReadArithmetic<uint8_t>();
171+
CHECK_LE(format_value, static_cast<uint8_t>(ModuleFormat::kModule));
172+
ModuleFormat main_code_format = static_cast<ModuleFormat>(format_value);
173+
Debug("Read SEA main code format %u\n", format_value);
164174
CHECK_EQ(read_total, SeaResource::kHeaderSize);
165175

166176
std::string_view code_path =
@@ -219,6 +229,7 @@ SeaResource SeaDeserializer::Read() {
219229
exec_argv_extension,
220230
code_path,
221231
code,
232+
main_code_format,
222233
code_cache,
223234
assets,
224235
exec_argv};
@@ -501,6 +512,25 @@ std::optional<SeaConfig> ParseSingleExecutableConfig(
501512
config_path);
502513
return std::nullopt;
503514
}
515+
} else if (key == "mainFormat") {
516+
std::string_view format_str;
517+
if (field.value().get_string().get(format_str)) {
518+
FPrintF(stderr,
519+
"\"mainFormat\" field of %s is not a string\n",
520+
config_path);
521+
return std::nullopt;
522+
}
523+
if (format_str == "commonjs") {
524+
result.main_format = ModuleFormat::kCommonJS;
525+
} else if (format_str == "module") {
526+
result.main_format = ModuleFormat::kModule;
527+
} else {
528+
FPrintF(stderr,
529+
"\"mainFormat\" field of %s must be one of "
530+
"\"commonjs\" or \"module\"\n",
531+
config_path);
532+
return std::nullopt;
533+
}
504534
}
505535
}
506536

@@ -512,6 +542,23 @@ std::optional<SeaConfig> ParseSingleExecutableConfig(
512542
"\"useCodeCache\" is redundant when \"useSnapshot\" is true\n");
513543
}
514544

545+
// TODO(joyeecheung): support ESM with useSnapshot and useCodeCache.
546+
if (result.main_format == ModuleFormat::kModule &&
547+
static_cast<bool>(result.flags & SeaFlags::kUseSnapshot)) {
548+
FPrintF(stderr,
549+
"\"mainFormat\": \"module\" is not supported when "
550+
"\"useSnapshot\" is true\n");
551+
return std::nullopt;
552+
}
553+
554+
if (result.main_format == ModuleFormat::kModule &&
555+
static_cast<bool>(result.flags & SeaFlags::kUseCodeCache)) {
556+
FPrintF(stderr,
557+
"\"mainFormat\": \"module\" is not supported when "
558+
"\"useCodeCache\" is true\n");
559+
return std::nullopt;
560+
}
561+
515562
if (result.main_path.empty()) {
516563
FPrintF(stderr,
517564
"\"main\" field of %s is not a non-empty string\n",
@@ -709,6 +756,7 @@ ExitCode GenerateSingleExecutableBlob(
709756
builds_snapshot_from_main
710757
? std::string_view{snapshot_blob.data(), snapshot_blob.size()}
711758
: std::string_view{main_script.data(), main_script.size()},
759+
config.main_format,
712760
optional_sv_code_cache,
713761
assets_view,
714762
exec_argv_view};
@@ -792,20 +840,25 @@ void GetAssetKeys(const FunctionCallbackInfo<Value>& args) {
792840
}
793841

794842
MaybeLocal<Value> LoadSingleExecutableApplication(
795-
const StartExecutionCallbackInfo& info) {
843+
const StartExecutionCallbackInfoWithModule& info) {
796844
// Here we are currently relying on the fact that in NodeMainInstance::Run(),
797845
// env->context() is entered.
798-
Local<Context> context = Isolate::GetCurrent()->GetCurrentContext();
799-
Environment* env = Environment::GetCurrent(context);
846+
Environment* env = info.env();
847+
Local<Context> context = env->context();
800848
SeaResource sea = FindSingleExecutableResource();
801849

802850
CHECK(!sea.use_snapshot());
803851
// TODO(joyeecheung): this should be an external string. Refactor UnionBytes
804852
// and make it easy to create one based on static content on the fly.
805853
Local<Value> main_script =
806-
ToV8Value(env->context(), sea.main_code_or_snapshot).ToLocalChecked();
807-
return info.run_cjs->Call(
808-
env->context(), Null(env->isolate()), 1, &main_script);
854+
ToV8Value(context, sea.main_code_or_snapshot).ToLocalChecked();
855+
Local<Value> kind =
856+
v8::Integer::New(env->isolate(), static_cast<int>(sea.main_code_format));
857+
Local<Value> resource_name =
858+
ToV8Value(context, env->exec_path()).ToLocalChecked();
859+
Local<Value> args[] = {main_script, kind, resource_name};
860+
return info.run_module()->Call(
861+
env->context(), Null(env->isolate()), arraysize(args), args);
809862
}
810863

811864
bool MaybeLoadSingleExecutableApplication(Environment* env) {
@@ -821,7 +874,7 @@ bool MaybeLoadSingleExecutableApplication(Environment* env) {
821874
// this check is just here to guard against the unlikely case where
822875
// the SEA preparation blob has been manually modified by someone.
823876
CHECK(!env->snapshot_deserialize_main().IsEmpty());
824-
LoadEnvironment(env, StartExecutionCallback{});
877+
LoadEnvironment(env, StartExecutionCallbackWithModule{});
825878
return true;
826879
}
827880

src/node_sea.h

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include <unordered_map>
1212
#include <vector>
1313

14+
#include "node.h"
1415
#include "node_exit_code.h"
1516

1617
namespace node {
@@ -43,6 +44,7 @@ struct SeaConfig {
4344
std::string executable_path;
4445
SeaFlags flags = SeaFlags::kDefault;
4546
SeaExecArgvExtension exec_argv_extension = SeaExecArgvExtension::kEnv;
47+
ModuleFormat main_format = ModuleFormat::kCommonJS;
4648
std::unordered_map<std::string, std::string> assets;
4749
std::vector<std::string> exec_argv;
4850
};
@@ -52,15 +54,17 @@ struct SeaResource {
5254
SeaExecArgvExtension exec_argv_extension = SeaExecArgvExtension::kEnv;
5355
std::string_view code_path;
5456
std::string_view main_code_or_snapshot;
57+
ModuleFormat main_code_format = ModuleFormat::kCommonJS;
5558
std::optional<std::string_view> code_cache;
5659
std::unordered_map<std::string_view, std::string_view> assets;
5760
std::vector<std::string_view> exec_argv;
5861

5962
bool use_snapshot() const;
6063
bool use_code_cache() const;
6164

62-
static constexpr size_t kHeaderSize =
63-
sizeof(kMagic) + sizeof(SeaFlags) + sizeof(SeaExecArgvExtension);
65+
static constexpr size_t kHeaderSize = sizeof(kMagic) + sizeof(SeaFlags) +
66+
sizeof(SeaExecArgvExtension) +
67+
sizeof(ModuleFormat);
6468
};
6569

6670
bool IsSingleExecutable();
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"main": "sea.mjs",
3+
"output": "sea",
4+
"mainFormat": "module",
5+
"disableExperimentalSEAWarning": true
6+
}

test/fixtures/sea/esm/sea.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import assert from 'node:assert';
2+
import { createRequire } from 'node:module';
3+
import { pathToFileURL } from 'node:url';
4+
import { dirname } from 'node:path';
5+
6+
// Test createRequire with process.execPath.
7+
const assert2 = createRequire(process.execPath)('node:assert');
8+
assert.strictEqual(assert2.strict, assert.strict);
9+
10+
// Test import.meta properties. This should be in sync with the CommonJS entry
11+
// point's corresponding values.
12+
assert.strictEqual(import.meta.url, pathToFileURL(process.execPath).href);
13+
assert.strictEqual(import.meta.filename, process.execPath);
14+
assert.strictEqual(import.meta.dirname, dirname(process.execPath));
15+
assert.strictEqual(import.meta.main, true);
16+
// TODO(joyeecheung): support import.meta.resolve when we also support
17+
// require.resolve in CommonJS entry points, the behavior of the two
18+
// should be in sync.
19+
20+
// Test import() with a built-in module.
21+
const { strict } = await import('node:assert');
22+
assert.strictEqual(strict, assert.strict);
23+
24+
console.log('ESM SEA executed successfully');
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use strict';
2+
3+
require('../common');
4+
5+
const {
6+
buildSEA,
7+
skipIfBuildSEAIsNotSupported,
8+
} = require('../common/sea');
9+
10+
skipIfBuildSEAIsNotSupported();
11+
12+
// This tests the creation of a single executable application with an ESM
13+
// entry point using the "mainFormat": "module" configuration.
14+
15+
const tmpdir = require('../common/tmpdir');
16+
const fixtures = require('../common/fixtures');
17+
const { spawnSyncAndExitWithoutError } = require('../common/child_process');
18+
19+
tmpdir.refresh();
20+
21+
const outputFile = buildSEA(fixtures.path('sea', 'esm'));
22+
23+
spawnSyncAndExitWithoutError(
24+
outputFile,
25+
{
26+
env: {
27+
NODE_DEBUG_NATIVE: 'SEA',
28+
...process.env,
29+
},
30+
},
31+
{
32+
stdout: /ESM SEA executed successfully/,
33+
});

0 commit comments

Comments
 (0)