sea: implement execArgvExtension

This implements the execArgvExtension configuration field for SEA,
which takes one of three string values to specify whether and how
execution arguments can be extended for the SEA at run time:

* `"none"`: No extension is allowed. Only the arguments specified
  in `execArgv` will be used,
  and the `NODE_OPTIONS` environment variable will be ignored.
* `"env"`: _(Default)_ The `NODE_OPTIONS` environment variable can
  extend the execution arguments.
  This is the default behavior to maintain backward compatibility.
* `"cli"`: The executable can be launched with
  `--node-options="--flag1 --flag2"`, and those flags
  will be parsed as execution arguments for Node.js instead of being
  passed to the user script. This allows using arguments that are
  not supported by the `NODE_OPTIONS` environment variable.

PR-URL: https://github.com/nodejs/node/pull/59560
Fixes: https://github.com/nodejs/node/issues/55573
Fixes: https://github.com/nodejs/single-executable/issues/100
Refs: https://github.com/nodejs/node/issues/51688
Reviewed-By: Xuguang Mei <meixuguang@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Darshan Sen <raisinten@gmail.com>
This commit is contained in:
Joyee Cheung
2025-08-25 13:35:56 +02:00
committed by GitHub
parent 65858596ab
commit 6722642e3d
10 changed files with 372 additions and 7 deletions

View File

@@ -180,6 +180,7 @@ The configuration currently reads the following top-level fields:
"useSnapshot": false, // Default: false
"useCodeCache": true, // Default: false
"execArgv": ["--no-warnings", "--max-old-space-size=4096"], // Optional
"execArgvExtension": "env", // Default: "env", options: "none", "env", "cli"
"assets": { // Optional
"a.dat": "/path/to/a.dat",
"b.txt": "/path/to/b.txt"
@@ -314,6 +315,42 @@ similar to what would happen if the application is started with:
node --no-warnings --max-old-space-size=2048 /path/to/bundled/script.js user-arg1 user-arg2
```
### Execution argument extension
The `execArgvExtension` field controls how additional execution arguments can be
provided beyond those specified in the `execArgv` field. It accepts one of three string values:
* `"none"`: No extension is allowed. Only the arguments specified in `execArgv` will be used,
and the `NODE_OPTIONS` environment variable will be ignored.
* `"env"`: _(Default)_ The `NODE_OPTIONS` environment variable can extend the execution arguments.
This is the default behavior to maintain backward compatibility.
* `"cli"`: The executable can be launched with `--node-options="--flag1 --flag2"`, and those flags
will be parsed as execution arguments for Node.js instead of being passed to the user script.
This allows using arguments that are not supported by the `NODE_OPTIONS` environment variable.
For example, with `"execArgvExtension": "cli"`:
```json
{
"main": "/path/to/bundled/script.js",
"output": "/path/to/write/the/generated/blob.blob",
"execArgv": ["--no-warnings"],
"execArgvExtension": "cli"
}
```
The executable can be launched as:
```console
./my-sea --node-options="--trace-exit" user-arg1 user-arg2
```
This would be equivalent to running:
```console
node --no-warnings --trace-exit /path/to/bundled/script.js user-arg1 user-arg2
```
## In the injected main script
### Single-executable application API

View File

@@ -940,7 +940,17 @@ static ExitCode InitializeNodeWithArgsInternal(
}
#if !defined(NODE_WITHOUT_NODE_OPTIONS)
if (!(flags & ProcessInitializationFlags::kDisableNodeOptionsEnv)) {
bool should_parse_node_options =
!(flags & ProcessInitializationFlags::kDisableNodeOptionsEnv);
#ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION
if (sea::IsSingleExecutable()) {
sea::SeaResource sea_resource = sea::FindSingleExecutableResource();
if (sea_resource.exec_argv_extension != sea::SeaExecArgvExtension::kEnv) {
should_parse_node_options = false;
}
}
#endif
if (should_parse_node_options) {
// NODE_OPTIONS environment variable is preferred over the file one.
if (credentials::SafeGetenv("NODE_OPTIONS", &node_options) ||
!node_options.empty()) {

View File

@@ -7,6 +7,7 @@
#include "node_errors.h"
#include "node_external_reference.h"
#include "node_internals.h"
#include "node_options.h"
#include "node_snapshot_builder.h"
#include "node_union_bytes.h"
#include "node_v8_platform-inl.h"
@@ -86,6 +87,11 @@ size_t SeaSerializer::Write(const SeaResource& sea) {
uint32_t flags = static_cast<uint32_t>(sea.flags);
Debug("Write SEA flags %x\n", flags);
written_total += WriteArithmetic<uint32_t>(flags);
Debug("Write SEA resource exec argv extension %u\n",
static_cast<uint8_t>(sea.exec_argv_extension));
written_total +=
WriteArithmetic<uint8_t>(static_cast<uint8_t>(sea.exec_argv_extension));
DCHECK_EQ(written_total, SeaResource::kHeaderSize);
Debug("Write SEA code path %p, size=%zu\n",
@@ -158,6 +164,11 @@ SeaResource SeaDeserializer::Read() {
CHECK_EQ(magic, kMagic);
SeaFlags flags(static_cast<SeaFlags>(ReadArithmetic<uint32_t>()));
Debug("Read SEA flags %x\n", static_cast<uint32_t>(flags));
uint8_t extension_value = ReadArithmetic<uint8_t>();
SeaExecArgvExtension exec_argv_extension =
static_cast<SeaExecArgvExtension>(extension_value);
Debug("Read SEA resource exec argv extension %u\n", extension_value);
CHECK_EQ(read_total, SeaResource::kHeaderSize);
std::string_view code_path =
@@ -212,7 +223,13 @@ SeaResource SeaDeserializer::Read() {
exec_argv.emplace_back(arg);
}
}
return {flags, code_path, code, code_cache, assets, exec_argv};
return {flags,
exec_argv_extension,
code_path,
code,
code_cache,
assets,
exec_argv};
}
std::string_view FindSingleExecutableBlob() {
@@ -297,26 +314,55 @@ std::tuple<int, char**> FixupArgsForSEA(int argc, char** argv) {
if (IsSingleExecutable()) {
static std::vector<char*> new_argv;
static std::vector<std::string> exec_argv_storage;
static std::vector<std::string> cli_extension_args;
SeaResource sea_resource = FindSingleExecutableResource();
new_argv.clear();
exec_argv_storage.clear();
cli_extension_args.clear();
// Reserve space for argv[0], exec argv, original argv, and nullptr
new_argv.reserve(argc + sea_resource.exec_argv.size() + 2);
// Handle CLI extension mode for --node-options
if (sea_resource.exec_argv_extension == SeaExecArgvExtension::kCli) {
// Extract --node-options and filter argv
for (int i = 1; i < argc; ++i) {
if (strncmp(argv[i], "--node-options=", 15) == 0) {
std::string node_options = argv[i] + 15;
std::vector<std::string> errors;
cli_extension_args = ParseNodeOptionsEnvVar(node_options, &errors);
// Remove this argument by shifting the rest
for (int j = i; j < argc - 1; ++j) {
argv[j] = argv[j + 1];
}
argc--;
i--; // Adjust index since we removed an element
}
}
}
// Reserve space for argv[0], exec argv, cli extension args, original argv,
// and nullptr
new_argv.reserve(argc + sea_resource.exec_argv.size() +
cli_extension_args.size() + 2);
new_argv.emplace_back(argv[0]);
// Insert exec argv from SEA config
if (!sea_resource.exec_argv.empty()) {
exec_argv_storage.reserve(sea_resource.exec_argv.size());
exec_argv_storage.reserve(sea_resource.exec_argv.size() +
cli_extension_args.size());
for (const auto& arg : sea_resource.exec_argv) {
exec_argv_storage.emplace_back(arg);
new_argv.emplace_back(exec_argv_storage.back().data());
}
}
// Add actual run time arguments.
// Insert CLI extension args
for (const auto& arg : cli_extension_args) {
exec_argv_storage.emplace_back(arg);
new_argv.emplace_back(exec_argv_storage.back().data());
}
// Add actual run time arguments
new_argv.insert(new_argv.end(), argv, argv + argc);
new_argv.emplace_back(nullptr);
argc = new_argv.size() - 1;
@@ -332,6 +378,7 @@ struct SeaConfig {
std::string main_path;
std::string output_path;
SeaFlags flags = SeaFlags::kDefault;
SeaExecArgvExtension exec_argv_extension = SeaExecArgvExtension::kEnv;
std::unordered_map<std::string, std::string> assets;
std::vector<std::string> exec_argv;
};
@@ -475,6 +522,27 @@ std::optional<SeaConfig> ParseSingleExecutableConfig(
result.flags |= SeaFlags::kIncludeExecArgv;
result.exec_argv = std::move(exec_argv);
}
} else if (key == "execArgvExtension") {
std::string_view extension_str;
if (field.value().get_string().get(extension_str)) {
FPrintF(stderr,
"\"execArgvExtension\" field of %s is not a string\n",
config_path);
return std::nullopt;
}
if (extension_str == "none") {
result.exec_argv_extension = SeaExecArgvExtension::kNone;
} else if (extension_str == "env") {
result.exec_argv_extension = SeaExecArgvExtension::kEnv;
} else if (extension_str == "cli") {
result.exec_argv_extension = SeaExecArgvExtension::kCli;
} else {
FPrintF(stderr,
"\"execArgvExtension\" field of %s must be one of "
"\"none\", \"env\", or \"cli\"\n",
config_path);
return std::nullopt;
}
}
}
@@ -674,6 +742,7 @@ ExitCode GenerateSingleExecutableBlob(
}
SeaResource sea{
config.flags,
config.exec_argv_extension,
config.main_path,
builds_snapshot_from_main
? std::string_view{snapshot_blob.data(), snapshot_blob.size()}

View File

@@ -31,8 +31,15 @@ enum class SeaFlags : uint32_t {
kIncludeExecArgv = 1 << 4,
};
enum class SeaExecArgvExtension : uint8_t {
kNone = 0,
kEnv = 1,
kCli = 2,
};
struct SeaResource {
SeaFlags flags = SeaFlags::kDefault;
SeaExecArgvExtension exec_argv_extension = SeaExecArgvExtension::kEnv;
std::string_view code_path;
std::string_view main_code_or_snapshot;
std::optional<std::string_view> code_cache;
@@ -42,7 +49,8 @@ struct SeaResource {
bool use_snapshot() const;
bool use_code_cache() const;
static constexpr size_t kHeaderSize = sizeof(kMagic) + sizeof(SeaFlags);
static constexpr size_t kHeaderSize =
sizeof(kMagic) + sizeof(SeaFlags) + sizeof(SeaExecArgvExtension);
};
bool IsSingleExecutable();

View File

@@ -0,0 +1,14 @@
const assert = require('assert');
console.log('process.argv:', JSON.stringify(process.argv));
console.log('process.execArgv:', JSON.stringify(process.execArgv));
// Should have execArgv from SEA config + CLI --node-options
assert.deepStrictEqual(process.execArgv, ['--no-warnings', '--max-old-space-size=1024']);
assert.deepStrictEqual(process.argv.slice(2), [
'user-arg1',
'user-arg2'
]);
console.log('execArgvExtension cli test passed');

View File

@@ -0,0 +1,19 @@
const assert = require('assert');
process.emitWarning('This warning should not be shown in the output', 'TestWarning');
console.log('process.argv:', JSON.stringify(process.argv));
console.log('process.execArgv:', JSON.stringify(process.execArgv));
// Should have execArgv from SEA config.
// Note that flags from NODE_OPTIONS are not included in process.execArgv no matter it's
// an SEA or not, but we can test whether it works by checking that the warning emitted
// above was silenced.
assert.deepStrictEqual(process.execArgv, ['--no-warnings']);
assert.deepStrictEqual(process.argv.slice(2), [
'user-arg1',
'user-arg2'
]);
console.log('execArgvExtension env test passed');

View File

@@ -0,0 +1,14 @@
const assert = require('assert');
console.log('process.argv:', JSON.stringify(process.argv));
console.log('process.execArgv:', JSON.stringify(process.execArgv));
// Should only have execArgv from SEA config, no NODE_OPTIONS
assert.deepStrictEqual(process.execArgv, ['--no-warnings']);
assert.deepStrictEqual(process.argv.slice(2), [
'user-arg1',
'user-arg2'
]);
console.log('execArgvExtension none test passed');

View File

@@ -0,0 +1,63 @@
'use strict';
require('../common');
const {
generateSEA,
skipIfSingleExecutableIsNotSupported,
} = require('../common/sea');
skipIfSingleExecutableIsNotSupported();
// This tests the execArgvExtension "cli" mode in single executable applications.
const fixtures = require('../common/fixtures');
const tmpdir = require('../common/tmpdir');
const { copyFileSync, writeFileSync, existsSync } = require('fs');
const { spawnSyncAndAssert, spawnSyncAndExitWithoutError } = require('../common/child_process');
const { join } = require('path');
const assert = require('assert');
const configFile = tmpdir.resolve('sea-config.json');
const seaPrepBlob = tmpdir.resolve('sea-prep.blob');
const outputFile = tmpdir.resolve(process.platform === 'win32' ? 'sea.exe' : 'sea');
tmpdir.refresh();
// Copy test fixture to working directory
copyFileSync(fixtures.path('sea-exec-argv-extension-cli.js'), tmpdir.resolve('sea.js'));
writeFileSync(configFile, `
{
"main": "sea.js",
"output": "sea-prep.blob",
"disableExperimentalSEAWarning": true,
"execArgv": ["--no-warnings"],
"execArgvExtension": "cli"
}
`);
spawnSyncAndExitWithoutError(
process.execPath,
['--experimental-sea-config', 'sea-config.json'],
{ cwd: tmpdir.path });
assert(existsSync(seaPrepBlob));
generateSEA(outputFile, process.execPath, seaPrepBlob);
// Test that --node-options works with execArgvExtension: "cli"
spawnSyncAndAssert(
outputFile,
['--node-options=--max-old-space-size=1024', 'user-arg1', 'user-arg2'],
{
env: {
...process.env,
NODE_OPTIONS: '--max-old-space-size=2048', // Should be ignored
COMMON_DIRECTORY: join(__dirname, '..', 'common'),
NODE_DEBUG_NATIVE: 'SEA',
}
},
{
stdout: /execArgvExtension cli test passed/
});

View File

@@ -0,0 +1,68 @@
'use strict';
require('../common');
const {
generateSEA,
skipIfSingleExecutableIsNotSupported,
} = require('../common/sea');
skipIfSingleExecutableIsNotSupported();
// This tests the execArgvExtension "env" mode (default) in single executable applications.
const fixtures = require('../common/fixtures');
const tmpdir = require('../common/tmpdir');
const { copyFileSync, writeFileSync, existsSync } = require('fs');
const { spawnSyncAndAssert, spawnSyncAndExitWithoutError } = require('../common/child_process');
const { join } = require('path');
const assert = require('assert');
const configFile = tmpdir.resolve('sea-config.json');
const seaPrepBlob = tmpdir.resolve('sea-prep.blob');
const outputFile = tmpdir.resolve(process.platform === 'win32' ? 'sea.exe' : 'sea');
tmpdir.refresh();
// Copy test fixture to working directory
copyFileSync(fixtures.path('sea-exec-argv-extension-env.js'), tmpdir.resolve('sea.js'));
writeFileSync(configFile, `
{
"main": "sea.js",
"output": "sea-prep.blob",
"disableExperimentalSEAWarning": true,
"execArgv": ["--no-warnings"],
"execArgvExtension": "env"
}
`);
spawnSyncAndExitWithoutError(
process.execPath,
['--experimental-sea-config', 'sea-config.json'],
{ cwd: tmpdir.path });
assert(existsSync(seaPrepBlob));
generateSEA(outputFile, process.execPath, seaPrepBlob);
// Test that NODE_OPTIONS works with execArgvExtension: "env" (default behavior)
spawnSyncAndAssert(
outputFile,
['user-arg1', 'user-arg2'],
{
env: {
...process.env,
NODE_OPTIONS: '--max-old-space-size=512',
COMMON_DIRECTORY: join(__dirname, '..', 'common'),
NODE_DEBUG_NATIVE: 'SEA',
}
},
{
stdout: /execArgvExtension env test passed/,
stderr(output) {
assert.doesNotMatch(output, /This warning should not be shown in the output/);
return true;
},
trim: true
});

View File

@@ -0,0 +1,63 @@
'use strict';
require('../common');
const {
generateSEA,
skipIfSingleExecutableIsNotSupported,
} = require('../common/sea');
skipIfSingleExecutableIsNotSupported();
// This tests the execArgvExtension "none" mode in single executable applications.
const fixtures = require('../common/fixtures');
const tmpdir = require('../common/tmpdir');
const { copyFileSync, writeFileSync, existsSync } = require('fs');
const { spawnSyncAndAssert, spawnSyncAndExitWithoutError } = require('../common/child_process');
const { join } = require('path');
const assert = require('assert');
const configFile = tmpdir.resolve('sea-config.json');
const seaPrepBlob = tmpdir.resolve('sea-prep.blob');
const outputFile = tmpdir.resolve(process.platform === 'win32' ? 'sea.exe' : 'sea');
tmpdir.refresh();
// Copy test fixture to working directory
copyFileSync(fixtures.path('sea-exec-argv-extension-none.js'), tmpdir.resolve('sea.js'));
writeFileSync(configFile, `
{
"main": "sea.js",
"output": "sea-prep.blob",
"disableExperimentalSEAWarning": true,
"execArgv": ["--no-warnings"],
"execArgvExtension": "none"
}
`);
spawnSyncAndExitWithoutError(
process.execPath,
['--experimental-sea-config', 'sea-config.json'],
{ cwd: tmpdir.path });
assert(existsSync(seaPrepBlob));
generateSEA(outputFile, process.execPath, seaPrepBlob);
// Test that NODE_OPTIONS is ignored with execArgvExtension: "none"
spawnSyncAndAssert(
outputFile,
['user-arg1', 'user-arg2'],
{
env: {
...process.env,
NODE_OPTIONS: '--max-old-space-size=2048',
COMMON_DIRECTORY: join(__dirname, '..', 'common'),
NODE_DEBUG_NATIVE: 'SEA',
}
},
{
stdout: /execArgvExtension none test passed/
});