diff --git a/doc/api/single-executable-applications.md b/doc/api/single-executable-applications.md index 5e12e985b6567c..41e4bd7383ef7f 100644 --- a/doc/api/single-executable-applications.md +++ b/doc/api/single-executable-applications.md @@ -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 diff --git a/src/node.cc b/src/node.cc index d6f9922a5b1562..fed1417f5f4dee 100644 --- a/src/node.cc +++ b/src/node.cc @@ -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()) { diff --git a/src/node_sea.cc b/src/node_sea.cc index bcc49c149e2374..49071304262f10 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -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(sea.flags); Debug("Write SEA flags %x\n", flags); written_total += WriteArithmetic(flags); + + Debug("Write SEA resource exec argv extension %u\n", + static_cast(sea.exec_argv_extension)); + written_total += + WriteArithmetic(static_cast(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(ReadArithmetic())); Debug("Read SEA flags %x\n", static_cast(flags)); + + uint8_t extension_value = ReadArithmetic(); + SeaExecArgvExtension exec_argv_extension = + static_cast(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 FixupArgsForSEA(int argc, char** argv) { if (IsSingleExecutable()) { static std::vector new_argv; static std::vector exec_argv_storage; + static std::vector cli_extension_args; SeaResource sea_resource = FindSingleExecutableResource(); new_argv.clear(); exec_argv_storage.clear(); + cli_extension_args.clear(); + + // 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 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, original argv, and nullptr - new_argv.reserve(argc + sea_resource.exec_argv.size() + 2); + // 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 assets; std::vector exec_argv; }; @@ -475,6 +522,27 @@ std::optional 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()} diff --git a/src/node_sea.h b/src/node_sea.h index 5ba41064304fdf..686e283fd6441b 100644 --- a/src/node_sea.h +++ b/src/node_sea.h @@ -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 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(); diff --git a/test/fixtures/sea-exec-argv-extension-cli.js b/test/fixtures/sea-exec-argv-extension-cli.js new file mode 100644 index 00000000000000..e9585483fcc21d --- /dev/null +++ b/test/fixtures/sea-exec-argv-extension-cli.js @@ -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'); diff --git a/test/fixtures/sea-exec-argv-extension-env.js b/test/fixtures/sea-exec-argv-extension-env.js new file mode 100644 index 00000000000000..1d706dfe7cfe11 --- /dev/null +++ b/test/fixtures/sea-exec-argv-extension-env.js @@ -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'); diff --git a/test/fixtures/sea-exec-argv-extension-none.js b/test/fixtures/sea-exec-argv-extension-none.js new file mode 100644 index 00000000000000..c089b065677091 --- /dev/null +++ b/test/fixtures/sea-exec-argv-extension-none.js @@ -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'); diff --git a/test/sequential/test-single-executable-application-exec-argv-extension-cli.js b/test/sequential/test-single-executable-application-exec-argv-extension-cli.js new file mode 100644 index 00000000000000..81ff05d53ce7a4 --- /dev/null +++ b/test/sequential/test-single-executable-application-exec-argv-extension-cli.js @@ -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/ + }); diff --git a/test/sequential/test-single-executable-application-exec-argv-extension-env.js b/test/sequential/test-single-executable-application-exec-argv-extension-env.js new file mode 100644 index 00000000000000..25d07bdc468f9a --- /dev/null +++ b/test/sequential/test-single-executable-application-exec-argv-extension-env.js @@ -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 + }); diff --git a/test/sequential/test-single-executable-application-exec-argv-extension-none.js b/test/sequential/test-single-executable-application-exec-argv-extension-none.js new file mode 100644 index 00000000000000..272016f006f3f7 --- /dev/null +++ b/test/sequential/test-single-executable-application-exec-argv-extension-none.js @@ -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/ + });