Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 59 additions & 16 deletions doc/api/single-executable-applications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()`
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive-by: fixed this since it seems out of touch with other APIs below.

### `sea.isSea()`

<!-- YAML
added:
Expand Down Expand Up @@ -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

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:

* `"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.

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.
Users can bundle their application into a standalone JavaScript file to inject
into the executable. This also ensures a more deterministic dependency graph.

However, if a file based `require()` is still needed, that can also be achieved:
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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
67 changes: 60 additions & 7 deletions src/node_sea.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -219,6 +229,7 @@ SeaResource SeaDeserializer::Read() {
exec_argv_extension,
code_path,
code,
main_code_format,
code_cache,
assets,
exec_argv};
Expand Down Expand Up @@ -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;
}
}
}

Expand All @@ -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",
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down
8 changes: 6 additions & 2 deletions src/node_sea.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include <unordered_map>
#include <vector>

#include "node.h"
#include "node_exit_code.h"

namespace node {
Expand Down Expand Up @@ -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;
};
Expand All @@ -52,15 +54,17 @@ 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;

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();
Expand Down
6 changes: 6 additions & 0 deletions test/fixtures/sea/esm/sea-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"main": "sea.mjs",
"output": "sea",
"mainFormat": "module",
"disableExperimentalSEAWarning": true
}
24 changes: 24 additions & 0 deletions test/fixtures/sea/esm/sea.mjs
Original file line number Diff line number Diff line change
@@ -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');
33 changes: 33 additions & 0 deletions test/sea/test-single-executable-application-esm.js
Original file line number Diff line number Diff line change
@@ -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/,
});
Loading