mirror of
https://github.com/nodejs/node.git
synced 2026-10-14 10:26:26 -04:00
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:
1 parent
94b1f66b73
commit
af0d6d4248
6 files changed
+188
-25
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"main": "sea.mjs",
|
||||
"output": "sea",
|
||||
"mainFormat": "module",
|
||||
"disableExperimentalSEAWarning": true
|
||||
}
|
||||
Vendored
+24
@@ -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/,
|
||||
});
|
||||
Reference in new issue
Block a user