diff --git a/Makefile b/Makefile index 29f81b89426a58..9c5b36d088babd 100644 --- a/Makefile +++ b/Makefile @@ -807,6 +807,7 @@ doc: $(NODE_EXE) doc-only ## Build Node.js, and then build the documentation wit out/doc: mkdir -p $@ + cp doc/node-config-schema.json $@ # If it's a source tarball, doc/api already contains the generated docs. # Just copy everything under doc/api over. diff --git a/doc/api/cli.md b/doc/api/cli.md index d8ac2b7cfdf95b..d5076220e40022 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -909,6 +909,84 @@ flows within the application. As such, it is presently recommended to be sure your application behaviour is unaffected by this change before using it in production. +### `--experimental-config-file=config` + + + +> Stability: 1.0 - Early development + +If present, Node.js will look for a +configuration file at the specified path. +Node.js will read the configuration file and apply the settings. +The configuration file should be a JSON file +with the following structure: + +> \[!NOTE] +> Replace `vX.Y.Z` in the `$schema` with the version of Node.js you are using. + +```json +{ + "$schema": "https://nodejs.org/dist/vX.Y.Z/docs/node-config-schema.json", + "nodeOptions": { + "import": [ + "amaro/strip" + ], + "watch-path": "src", + "watch-preserve-output": true + } +} +``` + +In the `nodeOptions` field, only flags that are allowed in [`NODE_OPTIONS`][] are supported. +No-op flags are not supported. +Not all V8 flags are currently supported. + +It is possible to use the [official JSON schema](../node-config-schema.json) +to validate the configuration file, which may vary depending on the Node.js version. +Each key in the configuration file corresponds to a flag that can be passed +as a command-line argument. The value of the key is the value that would be +passed to the flag. + +For example, the configuration file above is equivalent to +the following command-line arguments: + +```bash +node --import amaro/strip --watch-path=src --watch-preserve-output +``` + +The priority in configuration is as follows: + +1. NODE\_OPTIONS and command-line options +2. Configuration file +3. Dotenv NODE\_OPTIONS + +Values in the configuration file will not override the values in the environment +variables and command-line options, but will override the values in the `NODE_OPTIONS` +env file parsed by the `--env-file` flag. + +If duplicate keys are present in the configuration file, only +the first key will be used. + +The configuration parser will throw an error if the configuration file contains +unknown keys or keys that cannot used in `NODE_OPTIONS`. + +Node.js will not sanitize or perform validation on the user-provided configuration, +so **NEVER** use untrusted configuration files. + +### `--experimental-default-config-file` + + + +> Stability: 1.0 - Early development + +If the `--experimental-default-config-file` flag is present, Node.js will look for a +`node.config.json` file in the current working directory and load it as a +as configuration file. + ### `--experimental-default-type=type` + +An attempt was made to get options before the bootstrapping was completed. + ### `ERR_OUT_OF_RANGE` diff --git a/doc/node-config-schema.json b/doc/node-config-schema.json new file mode 100644 index 00000000000000..bc2ab4c8468324 --- /dev/null +++ b/doc/node-config-schema.json @@ -0,0 +1,593 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "nodeOptions": { + "additionalProperties": false, + "properties": { + "addons": { + "type": "boolean" + }, + "allow-addons": { + "type": "boolean" + }, + "allow-child-process": { + "type": "boolean" + }, + "allow-fs-read": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "allow-fs-write": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "allow-wasi": { + "type": "boolean" + }, + "allow-worker": { + "type": "boolean" + }, + "conditions": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "cpu-prof": { + "type": "boolean" + }, + "cpu-prof-dir": { + "type": "string" + }, + "cpu-prof-interval": { + "type": "number" + }, + "cpu-prof-name": { + "type": "string" + }, + "debug-arraybuffer-allocations": { + "type": "boolean" + }, + "deprecation": { + "type": "boolean" + }, + "diagnostic-dir": { + "type": "string" + }, + "disable-proto": { + "type": "string" + }, + "disable-sigusr1": { + "type": "boolean" + }, + "disable-warning": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "disable-wasm-trap-handler": { + "type": "boolean" + }, + "dns-result-order": { + "type": "string" + }, + "enable-fips": { + "type": "boolean" + }, + "enable-source-maps": { + "type": "boolean" + }, + "entry-url": { + "type": "boolean" + }, + "experimental-async-context-frame": { + "type": "boolean" + }, + "experimental-default-type": { + "type": "string" + }, + "experimental-detect-module": { + "type": "boolean" + }, + "experimental-eventsource": { + "type": "boolean" + }, + "experimental-fetch": { + "type": "boolean" + }, + "experimental-global-customevent": { + "type": "boolean" + }, + "experimental-global-navigator": { + "type": "boolean" + }, + "experimental-global-webcrypto": { + "type": "boolean" + }, + "experimental-import-meta-resolve": { + "type": "boolean" + }, + "experimental-loader": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "experimental-print-required-tla": { + "type": "boolean" + }, + "experimental-repl-await": { + "type": "boolean" + }, + "experimental-require-module": { + "type": "boolean" + }, + "experimental-shadow-realm": { + "type": "boolean" + }, + "experimental-sqlite": { + "type": "boolean" + }, + "experimental-strip-types": { + "type": "boolean" + }, + "experimental-transform-types": { + "type": "boolean" + }, + "experimental-vm-modules": { + "type": "boolean" + }, + "experimental-wasm-modules": { + "type": "boolean" + }, + "experimental-websocket": { + "type": "boolean" + }, + "experimental-webstorage": { + "type": "boolean" + }, + "extra-info-on-fatal-exception": { + "type": "boolean" + }, + "force-async-hooks-checks": { + "type": "boolean" + }, + "force-context-aware": { + "type": "boolean" + }, + "force-fips": { + "type": "boolean" + }, + "force-node-api-uncaught-exceptions-policy": { + "type": "boolean" + }, + "frozen-intrinsics": { + "type": "boolean" + }, + "global-search-paths": { + "type": "boolean" + }, + "heap-prof": { + "type": "boolean" + }, + "heap-prof-dir": { + "type": "string" + }, + "heap-prof-interval": { + "type": "number" + }, + "heap-prof-name": { + "type": "string" + }, + "heapsnapshot-near-heap-limit": { + "type": "number" + }, + "heapsnapshot-signal": { + "type": "string" + }, + "icu-data-dir": { + "type": "string" + }, + "import": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "input-type": { + "type": "string" + }, + "insecure-http-parser": { + "type": "boolean" + }, + "inspect": { + "type": "boolean" + }, + "inspect-brk": { + "type": "boolean" + }, + "inspect-port": { + "type": "number" + }, + "inspect-publish-uid": { + "type": "string" + }, + "inspect-wait": { + "type": "boolean" + }, + "localstorage-file": { + "type": "string" + }, + "max-http-header-size": { + "type": "number" + }, + "network-family-autoselection": { + "type": "boolean" + }, + "network-family-autoselection-attempt-timeout": { + "type": "number" + }, + "node-snapshot": { + "type": "boolean" + }, + "openssl-config": { + "type": "string" + }, + "openssl-legacy-provider": { + "type": "boolean" + }, + "openssl-shared-config": { + "type": "boolean" + }, + "pending-deprecation": { + "type": "boolean" + }, + "permission": { + "type": "boolean" + }, + "preserve-symlinks": { + "type": "boolean" + }, + "preserve-symlinks-main": { + "type": "boolean" + }, + "redirect-warnings": { + "type": "string" + }, + "report-compact": { + "type": "boolean" + }, + "report-dir": { + "type": "string" + }, + "report-exclude-env": { + "type": "boolean" + }, + "report-exclude-network": { + "type": "boolean" + }, + "report-filename": { + "type": "string" + }, + "report-on-fatalerror": { + "type": "boolean" + }, + "report-on-signal": { + "type": "boolean" + }, + "report-signal": { + "type": "string" + }, + "report-uncaught-exception": { + "type": "boolean" + }, + "require": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "secure-heap": { + "type": "number" + }, + "secure-heap-min": { + "type": "number" + }, + "snapshot-blob": { + "type": "string" + }, + "stack-trace-limit": { + "type": "number" + }, + "test-coverage-branches": { + "type": "number" + }, + "test-coverage-exclude": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "test-coverage-functions": { + "type": "number" + }, + "test-coverage-include": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "test-coverage-lines": { + "type": "number" + }, + "test-name-pattern": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "test-only": { + "type": "boolean" + }, + "test-reporter": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "test-reporter-destination": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "test-shard": { + "type": "string" + }, + "test-skip-pattern": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "throw-deprecation": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "tls-cipher-list": { + "type": "string" + }, + "tls-keylog": { + "type": "string" + }, + "tls-max-v1.2": { + "type": "boolean" + }, + "tls-max-v1.3": { + "type": "boolean" + }, + "tls-min-v1.0": { + "type": "boolean" + }, + "tls-min-v1.1": { + "type": "boolean" + }, + "tls-min-v1.2": { + "type": "boolean" + }, + "tls-min-v1.3": { + "type": "boolean" + }, + "trace-atomics-wait": { + "type": "boolean" + }, + "trace-deprecation": { + "type": "boolean" + }, + "trace-env": { + "type": "boolean" + }, + "trace-env-js-stack": { + "type": "boolean" + }, + "trace-env-native-stack": { + "type": "boolean" + }, + "trace-event-categories": { + "type": "string" + }, + "trace-event-file-pattern": { + "type": "string" + }, + "trace-exit": { + "type": "boolean" + }, + "trace-promises": { + "type": "boolean" + }, + "trace-require-module": { + "type": "string" + }, + "trace-sigint": { + "type": "boolean" + }, + "trace-sync-io": { + "type": "boolean" + }, + "trace-tls": { + "type": "boolean" + }, + "trace-uncaught": { + "type": "boolean" + }, + "trace-warnings": { + "type": "boolean" + }, + "track-heap-objects": { + "type": "boolean" + }, + "unhandled-rejections": { + "type": "string" + }, + "use-bundled-ca": { + "type": "boolean" + }, + "use-largepages": { + "type": "string" + }, + "use-openssl-ca": { + "type": "boolean" + }, + "use-system-ca": { + "type": "boolean" + }, + "v8-pool-size": { + "type": "number" + }, + "verify-base-objects": { + "type": "boolean" + }, + "warnings": { + "type": "boolean" + }, + "watch": { + "type": "boolean" + }, + "watch-path": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "watch-preserve-output": { + "type": "boolean" + }, + "zero-fill-buffers": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" +} diff --git a/doc/node.1 b/doc/node.1 index 9f534746ef9d9c..663d123ac728f0 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -168,6 +168,12 @@ Interpret as either ES modules or CommonJS modules input via --eval or STDIN, wh .js or extensionless files with no sibling or parent package.json; .js or extensionless files whose nearest parent package.json lacks a "type" field, unless under node_modules. . +.It Fl -experimental-config-file +Specifies the configuration file to load. +. +.It Fl -experimental-default-config-file +Enable support for automatically loading node.config.json. +. .It Fl -experimental-import-meta-resolve Enable experimental ES modules support for import.meta.resolve(). . diff --git a/lib/internal/options.js b/lib/internal/options.js index 1192b46c9ede82..efc3ff3c30c9a4 100644 --- a/lib/internal/options.js +++ b/lib/internal/options.js @@ -1,9 +1,18 @@ 'use strict'; +const { + ArrayPrototypeMap, + ArrayPrototypeSort, + ObjectFromEntries, + ObjectKeys, + StringPrototypeReplace, +} = primordials; + const { getCLIOptionsValues, getCLIOptionsInfo, getEmbedderOptions: getEmbedderOptionsFromBinding, + getEnvOptionsInputType, } = internalBinding('options'); let warnOnAllowUnauthorized = true; @@ -28,6 +37,53 @@ function getEmbedderOptions() { return embedderOptions ??= getEmbedderOptionsFromBinding(); } +function generateConfigJsonSchema() { + const map = getEnvOptionsInputType(); + + const schema = { + __proto__: null, + $schema: 'https://json-schema.org/draft/2020-12/schema', + additionalProperties: false, + properties: { + nodeOptions: { + __proto__: null, + additionalProperties: false, + properties: { __proto__: null }, + type: 'object', + }, + __proto__: null, + }, + type: 'object', + }; + + const nodeOptions = schema.properties.nodeOptions.properties; + + for (const { 0: key, 1: type } of map) { + const keyWithoutPrefix = StringPrototypeReplace(key, '--', ''); + if (type === 'array') { + nodeOptions[keyWithoutPrefix] = { + __proto__: null, + oneOf: [ + { __proto__: null, type: 'string' }, + { __proto__: null, items: { __proto__: null, type: 'string', minItems: 1 }, type: 'array' }, + ], + }; + } else { + nodeOptions[keyWithoutPrefix] = { __proto__: null, type }; + } + } + + // Sort the proerties by key alphabetically. + const sortedKeys = ArrayPrototypeSort(ObjectKeys(nodeOptions)); + const sortedProperties = ObjectFromEntries( + ArrayPrototypeMap(sortedKeys, (key) => [key, nodeOptions[key]]), + ); + + schema.properties.nodeOptions.properties = sortedProperties; + + return schema; +} + function refreshOptions() { optionsDict = undefined; } @@ -55,5 +111,6 @@ module.exports = { getOptionValue, getAllowUnauthorized, getEmbedderOptions, + generateConfigJsonSchema, refreshOptions, }; diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js index d1c05d1717cdc8..4e7be0594ca1e1 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -124,6 +124,8 @@ function prepareExecution(options) { initializeSourceMapsHandlers(); initializeDeprecations(); + initializeConfigFileSupport(); + require('internal/dns/utils').initializeDns(); setupSymbolDisposePolyfill(); @@ -390,6 +392,13 @@ function setupSQLite() { BuiltinModule.allowRequireByUsers('sqlite'); } +function initializeConfigFileSupport() { + if (getOptionValue('--experimental-default-config-file') || + getOptionValue('--experimental-config-file')) { + emitExperimentalWarning('--experimental-config-file'); + } +} + function setupWebStorage() { if (getEmbedderOptions().noBrowserGlobals || !getOptionValue('--experimental-webstorage')) { diff --git a/node.gyp b/node.gyp index ec1f90b73f7d11..8c89e0cb32e4dd 100644 --- a/node.gyp +++ b/node.gyp @@ -105,6 +105,7 @@ 'src/node_buffer.cc', 'src/node_builtins.cc', 'src/node_config.cc', + 'src/node_config_file.cc', 'src/node_constants.cc', 'src/node_contextify.cc', 'src/node_credentials.cc', @@ -228,6 +229,7 @@ 'src/node_blob.h', 'src/node_buffer.h', 'src/node_builtins.h', + 'src/node_config_file.h', 'src/node_constants.h', 'src/node_context_data.h', 'src/node_contextify.h', diff --git a/src/node.cc b/src/node.cc index a0f1deadfc58f1..51c1e459a5fce4 100644 --- a/src/node.cc +++ b/src/node.cc @@ -20,6 +20,7 @@ // USE OR OTHER DEALINGS IN THE SOFTWARE. #include "node.h" +#include "node_config_file.h" #include "node_dotenv.h" #include "node_task_runner.h" @@ -150,6 +151,9 @@ namespace per_process { // Instance is used to store environment variables including NODE_OPTIONS. node::Dotenv dotenv_file = Dotenv(); +// node_config_file.h +node::ConfigReader config_reader = ConfigReader(); + // node_revert.h // Bit flag used to track security reverts. unsigned int reverted_cve = 0; @@ -936,6 +940,36 @@ static ExitCode InitializeNodeWithArgsInternal( per_process::dotenv_file.AssignNodeOptionsIfAvailable(&node_options); } + std::string node_options_from_config; + if (auto path = per_process::config_reader.GetDataFromArgs(*argv)) { + switch (per_process::config_reader.ParseConfig(*path)) { + case ParseResult::Valid: + break; + case ParseResult::InvalidContent: + errors->push_back(std::string(*path) + ": invalid content"); + break; + case ParseResult::FileError: + errors->push_back(std::string(*path) + ": not found"); + break; + default: + UNREACHABLE(); + } + node_options_from_config = per_process::config_reader.AssignNodeOptions(); + // (@marco-ippolito) Avoid reparsing the env options again + std::vector env_argv_from_config = + ParseNodeOptionsEnvVar(node_options_from_config, errors); + + // Check the number of flags in NODE_OPTIONS from the config file + // matches the parsed ones. This avoid users from sneaking in + // additional flags. + if (env_argv_from_config.size() != + per_process::config_reader.GetFlagsSize()) { + errors->emplace_back("The number of NODE_OPTIONS doesn't match " + "the number of flags in the config file"); + } + node_options += node_options_from_config; + } + #if !defined(NODE_WITHOUT_NODE_OPTIONS) if (!(flags & ProcessInitializationFlags::kDisableNodeOptionsEnv)) { // NODE_OPTIONS environment variable is preferred over the file one. diff --git a/src/node_config_file.cc b/src/node_config_file.cc new file mode 100644 index 00000000000000..d801d935a41706 --- /dev/null +++ b/src/node_config_file.cc @@ -0,0 +1,224 @@ +#include "node_config_file.h" +#include "debug_utils-inl.h" +#include "simdjson.h" + +#include + +namespace node { + +std::optional ConfigReader::GetDataFromArgs( + const std::vector& args) { + constexpr std::string_view flag_path = "--experimental-config-file"; + constexpr std::string_view default_file = + "--experimental-default-config-file"; + + bool has_default_config_file = false; + + for (auto it = args.begin(); it != args.end(); ++it) { + if (*it == flag_path) { + // Case: "--experimental-config-file foo" + if (auto next = std::next(it); next != args.end()) { + return *next; + } + } else if (it->starts_with(flag_path)) { + // Case: "--experimental-config-file=foo" + if (it->size() > flag_path.size() && (*it)[flag_path.size()] == '=') { + return it->substr(flag_path.size() + 1); + } + } else if (*it == default_file || it->starts_with(default_file)) { + has_default_config_file = true; + } + } + + if (has_default_config_file) { + return "node.config.json"; + } + + return std::nullopt; +} + +ParseResult ConfigReader::ParseNodeOptions( + simdjson::ondemand::object* node_options_object) { + auto env_options_map = options_parser::MapEnvOptionsFlagInputType(); + simdjson::ondemand::value ondemand_value; + std::string_view key; + + for (auto field : *node_options_object) { + if (field.unescaped_key().get(key) || field.value().get(ondemand_value)) { + return ParseResult::InvalidContent; + } + + // The key needs to match the CLI option + std::string prefix = "--"; + auto it = env_options_map.find(prefix.append(key)); + if (it != env_options_map.end()) { + switch (it->second) { + case options_parser::OptionType::kBoolean: { + bool result; + if (ondemand_value.get_bool().get(result)) { + FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); + return ParseResult::InvalidContent; + } + node_options_.push_back(it->first + "=" + + (result ? "true" : "false")); + break; + } + // String array can allow both string and array types + case options_parser::OptionType::kStringList: { + simdjson::ondemand::json_type field_type; + if (ondemand_value.type().get(field_type)) { + return ParseResult::InvalidContent; + } + switch (field_type) { + case simdjson::ondemand::json_type::array: { + std::vector result; + simdjson::ondemand::array raw_imports; + if (ondemand_value.get_array().get(raw_imports)) { + FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); + return ParseResult::InvalidContent; + } + for (auto raw_import : raw_imports) { + std::string_view import; + if (raw_import.get_string(import)) { + FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); + return ParseResult::InvalidContent; + } + node_options_.push_back(it->first + "=" + std::string(import)); + } + break; + } + case simdjson::ondemand::json_type::string: { + std::string result; + if (ondemand_value.get_string(result)) { + FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); + return ParseResult::InvalidContent; + } + node_options_.push_back(it->first + "=" + result); + break; + } + default: + FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); + return ParseResult::InvalidContent; + } + break; + } + case options_parser::OptionType::kString: { + std::string result; + if (ondemand_value.get_string(result)) { + FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); + return ParseResult::InvalidContent; + } + node_options_.push_back(it->first + "=" + result); + break; + } + case options_parser::OptionType::kInteger: { + int64_t result; + if (ondemand_value.get_int64().get(result)) { + FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); + return ParseResult::InvalidContent; + } + node_options_.push_back(it->first + "=" + std::to_string(result)); + break; + } + case options_parser::OptionType::kHostPort: + case options_parser::OptionType::kUInteger: { + uint64_t result; + if (ondemand_value.get_uint64().get(result)) { + FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); + return ParseResult::InvalidContent; + } + node_options_.push_back(it->first + "=" + std::to_string(result)); + break; + } + case options_parser::OptionType::kNoOp: { + FPrintF(stderr, + "No-op flag %s is currently not supported\n", + it->first.c_str()); + return ParseResult::InvalidContent; + break; + } + case options_parser::OptionType::kV8Option: { + FPrintF(stderr, + "V8 flag %s is currently not supported\n", + it->first.c_str()); + return ParseResult::InvalidContent; + } + default: + UNREACHABLE(); + } + } else { + FPrintF(stderr, "Unknown or not allowed option %s\n", key.data()); + return ParseResult::InvalidContent; + } + } + return ParseResult::Valid; +} + +ParseResult ConfigReader::ParseConfig(const std::string_view& config_path) { + std::string file_content; + // Read the configuration file + int r = ReadFileSync(&file_content, config_path.data()); + if (r != 0) { + const char* err = uv_strerror(r); + FPrintF( + stderr, "Cannot read configuration from %s: %s\n", config_path, err); + return ParseResult::FileError; + } + + // Parse the configuration file + simdjson::ondemand::parser json_parser; + simdjson::ondemand::document document; + if (json_parser.iterate(file_content).get(document)) { + FPrintF(stderr, "Can't parse %s\n", config_path.data()); + return ParseResult::InvalidContent; + } + + simdjson::ondemand::object main_object; + // If document is not an object, throw an error. + if (auto root_error = document.get_object().get(main_object)) { + if (root_error == simdjson::error_code::INCORRECT_TYPE) { + FPrintF(stderr, + "Root value unexpected not an object for %s\n\n", + config_path.data()); + } else { + FPrintF(stderr, "Can't parse %s\n", config_path.data()); + } + return ParseResult::InvalidContent; + } + + simdjson::ondemand::object node_options_object; + // If "nodeOptions" is an object, parse it + if (auto node_options_error = + main_object["nodeOptions"].get_object().get(node_options_object)) { + if (node_options_error != simdjson::error_code::NO_SUCH_FIELD) { + FPrintF(stderr, + "\"nodeOptions\" value unexpected for %s\n\n", + config_path.data()); + return ParseResult::InvalidContent; + } + } else { + return ParseNodeOptions(&node_options_object); + } + + return ParseResult::Valid; +} + +std::string ConfigReader::AssignNodeOptions() { + if (node_options_.empty()) { + return ""; + } else { + DCHECK_GT(node_options_.size(), 0); + std::string acc; + acc.reserve(node_options_.size() * 2); + for (size_t i = 0; i < node_options_.size(); ++i) { + // The space is necessary at the beginning of the string + acc += " " + node_options_[i]; + } + return acc; + } +} + +size_t ConfigReader::GetFlagsSize() { + return node_options_.size(); +} +} // namespace node diff --git a/src/node_config_file.h b/src/node_config_file.h new file mode 100644 index 00000000000000..04b4721a411088 --- /dev/null +++ b/src/node_config_file.h @@ -0,0 +1,45 @@ +#ifndef SRC_NODE_CONFIG_FILE_H_ +#define SRC_NODE_CONFIG_FILE_H_ + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#include +#include +#include +#include "simdjson.h" +#include "util-inl.h" + +namespace node { + +// When trying to parse the configuration file, we can have three possible +// results: +// - Valid: The file was successfully parsed and the content is valid. +// - FileError: There was an error reading the file. +// - InvalidContent: The file was read, but the content is invalid. +enum ParseResult { Valid, FileError, InvalidContent }; + +// ConfigReader is the class that parses the configuration JSON file. +// It reads the file provided by --experimental-config-file and +// extracts the flags. +class ConfigReader { + public: + ParseResult ParseConfig(const std::string_view& config_path); + + std::optional GetDataFromArgs( + const std::vector& args); + + std::string AssignNodeOptions(); + + size_t GetFlagsSize(); + + private: + ParseResult ParseNodeOptions(simdjson::ondemand::object* node_options_object); + + std::vector node_options_; +}; + +} // namespace node + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#endif // SRC_NODE_CONFIG_FILE_H_ diff --git a/src/node_errors.h b/src/node_errors.h index 2a41826bbea24e..f813c5b38766d8 100644 --- a/src/node_errors.h +++ b/src/node_errors.h @@ -105,6 +105,7 @@ void OOMErrorHandler(const char* location, const v8::OOMDetails& details); V(ERR_MISSING_PLATFORM_FOR_WORKER, Error) \ V(ERR_MODULE_NOT_FOUND, Error) \ V(ERR_NON_CONTEXT_AWARE_DISABLED, Error) \ + V(ERR_OPTIONS_BEFORE_BOOTSTRAPPING, Error) \ V(ERR_OUT_OF_RANGE, RangeError) \ V(ERR_REQUIRE_ASYNC_MODULE, Error) \ V(ERR_SCRIPT_EXECUTION_INTERRUPTED, Error) \ diff --git a/src/node_options.cc b/src/node_options.cc index 1444acd59d42f6..3fc8194475ec0e 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -3,6 +3,7 @@ #include "env-inl.h" #include "node_binding.h" +#include "node_errors.h" #include "node_external_reference.h" #include "node_internals.h" #include "node_sea.h" @@ -29,6 +30,7 @@ using v8::Name; using v8::Null; using v8::Number; using v8::Object; +using v8::String; using v8::Undefined; using v8::Value; namespace node { @@ -686,6 +688,12 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { "set environment variables from supplied file", &EnvironmentOptions::optional_env_file); Implies("--env-file-if-exists", "[has_env_file_string]"); + AddOption("--experimental-config-file", + "set config file from supplied file", + &EnvironmentOptions::experimental_config_file_path); + AddOption("--experimental-default-config-file", + "set config file from default config file", + &EnvironmentOptions::experimental_default_config_file); AddOption("--test", "launch test runner on startup", &EnvironmentOptions::test_runner); @@ -1317,6 +1325,19 @@ std::string GetBashCompletion() { return out.str(); } +std::unordered_map +MapEnvOptionsFlagInputType() { + std::unordered_map type_map; + const auto& parser = _ppop_instance; + for (const auto& item : parser.options_) { + if (!item.first.empty() && !item.first.starts_with('[') && + item.second.env_setting == kAllowedInEnvvar) { + type_map[item.first] = item.second.type; + } + } + return type_map; +} + struct IterateCLIOptionsScope { explicit IterateCLIOptionsScope(Environment* env) { // Temporarily act as if the current Environment's/IsolateData's options @@ -1345,8 +1366,8 @@ void GetCLIOptionsValues(const FunctionCallbackInfo& args) { if (!env->has_run_bootstrapping_code()) { // No code because this is an assertion. - return env->ThrowError( - "Should not query options before bootstrapping is done"); + THROW_ERR_OPTIONS_BEFORE_BOOTSTRAPPING( + isolate, "Should not query options before bootstrapping is done"); } env->set_has_serialized_options(true); @@ -1463,8 +1484,8 @@ void GetCLIOptionsInfo(const FunctionCallbackInfo& args) { if (!env->has_run_bootstrapping_code()) { // No code because this is an assertion. - return env->ThrowError( - "Should not query options before bootstrapping is done"); + THROW_ERR_OPTIONS_BEFORE_BOOTSTRAPPING( + isolate, "Should not query options before bootstrapping is done"); } Mutex::ScopedLock lock(per_process::cli_options_mutex); @@ -1530,7 +1551,8 @@ void GetEmbedderOptions(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); if (!env->has_run_bootstrapping_code()) { // No code because this is an assertion. - return env->ThrowError( + THROW_ERR_OPTIONS_BEFORE_BOOTSTRAPPING( + env->isolate(), "Should not query options before bootstrapping is done"); } Isolate* isolate = args.GetIsolate(); @@ -1554,6 +1576,81 @@ void GetEmbedderOptions(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(ret); } +// This function returns a map containing all the options available +// as NODE_OPTIONS and their input type +// Example --experimental-transform types: kBoolean +// This is used to determine the type of the input for each option +// to generate the config file json schema +void GetEnvOptionsInputType(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + Local context = isolate->GetCurrentContext(); + Environment* env = Environment::GetCurrent(context); + + if (!env->has_run_bootstrapping_code()) { + // No code because this is an assertion. + THROW_ERR_OPTIONS_BEFORE_BOOTSTRAPPING( + isolate, "Should not query options before bootstrapping is done"); + } + + Mutex::ScopedLock lock(per_process::cli_options_mutex); + + Local flags_map = Map::New(isolate); + + for (const auto& item : _ppop_instance.options_) { + if (!item.first.empty() && !item.first.starts_with('[') && + item.second.env_setting == kAllowedInEnvvar) { + std::string type; + switch (static_cast(item.second.type)) { + case 0: // No-op + case 1: // V8 flags + break; // V8 and NoOp flags are not supported + + case 2: + type = "boolean"; + break; + case 3: // integer + case 4: // unsigned integer + case 6: // host port + type = "number"; + break; + case 5: // string + type = "string"; + break; + case 7: // string array + type = "array"; + break; + default: + UNREACHABLE(); + } + + if (type.empty()) { + continue; + } + + Local value; + if (!String::NewFromUtf8( + isolate, type.data(), v8::NewStringType::kNormal, type.size()) + .ToLocal(&value)) { + continue; + } + + Local field; + if (!String::NewFromUtf8(isolate, + item.first.data(), + v8::NewStringType::kNormal, + item.first.size()) + .ToLocal(&field)) { + continue; + } + + if (flags_map->Set(context, field, value).IsEmpty()) { + return; + } + } + } + args.GetReturnValue().Set(flags_map); +} + void Initialize(Local target, Local unused, Local context, @@ -1566,7 +1663,8 @@ void Initialize(Local target, context, target, "getCLIOptionsInfo", GetCLIOptionsInfo); SetMethodNoSideEffect( context, target, "getEmbedderOptions", GetEmbedderOptions); - + SetMethodNoSideEffect( + context, target, "getEnvOptionsInputType", GetEnvOptionsInputType); Local env_settings = Object::New(isolate); NODE_DEFINE_CONSTANT(env_settings, kAllowedInEnvvar); NODE_DEFINE_CONSTANT(env_settings, kDisallowedInEnvvar); @@ -1592,6 +1690,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(GetCLIOptionsValues); registry->Register(GetCLIOptionsInfo); registry->Register(GetEmbedderOptions); + registry->Register(GetEnvOptionsInputType); } } // namespace options_parser diff --git a/src/node_options.h b/src/node_options.h index e68a41b60832c4..7d14f06370d936 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -257,6 +257,8 @@ class EnvironmentOptions : public Options { bool report_exclude_env = false; bool report_exclude_network = false; + std::string experimental_config_file_path; + bool experimental_default_config_file = false; inline DebugOptions* get_debug_options() { return &debug_options_; } inline const DebugOptions& debug_options() const { return debug_options_; } @@ -389,6 +391,7 @@ enum OptionType { kHostPort, kStringList, }; +std::unordered_map MapEnvOptionsFlagInputType(); template class OptionsParser { @@ -569,6 +572,10 @@ class OptionsParser { friend void GetCLIOptionsInfo( const v8::FunctionCallbackInfo& args); friend std::string GetBashCompletion(); + friend std::unordered_map + MapEnvOptionsFlagInputType(); + friend void GetEnvOptionsInputType( + const v8::FunctionCallbackInfo& args); }; using StringVector = std::vector; diff --git a/test/fixtures/dotenv/node-options-no-tranform.env b/test/fixtures/dotenv/node-options-no-tranform.env new file mode 100644 index 00000000000000..88ecfa83522e9f --- /dev/null +++ b/test/fixtures/dotenv/node-options-no-tranform.env @@ -0,0 +1 @@ +NODE_OPTIONS="--no-experimental-strip-types" diff --git a/test/fixtures/rc/broken-node-options.json b/test/fixtures/rc/broken-node-options.json new file mode 100644 index 00000000000000..beea3f7143f879 --- /dev/null +++ b/test/fixtures/rc/broken-node-options.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "inspect-port + } +} diff --git a/test/fixtures/rc/broken.json b/test/fixtures/rc/broken.json new file mode 100644 index 00000000000000..98232c64fce936 --- /dev/null +++ b/test/fixtures/rc/broken.json @@ -0,0 +1 @@ +{ diff --git a/test/fixtures/rc/default/node.config.json b/test/fixtures/rc/default/node.config.json new file mode 100644 index 00000000000000..54bcbfef04a947 --- /dev/null +++ b/test/fixtures/rc/default/node.config.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "max-http-header-size": 10 + } +} diff --git a/test/fixtures/rc/default/override.json b/test/fixtures/rc/default/override.json new file mode 100644 index 00000000000000..0f6f763cad86c6 --- /dev/null +++ b/test/fixtures/rc/default/override.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "max-http-header-size": 20 + } +} diff --git a/test/fixtures/rc/empty-object.json b/test/fixtures/rc/empty-object.json new file mode 100644 index 00000000000000..0db3279e44b0dc --- /dev/null +++ b/test/fixtures/rc/empty-object.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/test/fixtures/rc/empty.json b/test/fixtures/rc/empty.json new file mode 100644 index 00000000000000..8b137891791fe9 --- /dev/null +++ b/test/fixtures/rc/empty.json @@ -0,0 +1 @@ + diff --git a/test/fixtures/rc/host-port.json b/test/fixtures/rc/host-port.json new file mode 100644 index 00000000000000..48fb16edae64d6 --- /dev/null +++ b/test/fixtures/rc/host-port.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "inspect-port": 65535 + } +} diff --git a/test/fixtures/rc/import-as-string.json b/test/fixtures/rc/import-as-string.json new file mode 100644 index 00000000000000..b1e1feb96a9aef --- /dev/null +++ b/test/fixtures/rc/import-as-string.json @@ -0,0 +1,5 @@ +{ + "nodeOptions":{ + "import": "./test/fixtures/printA.js" + } +} diff --git a/test/fixtures/rc/import.json b/test/fixtures/rc/import.json new file mode 100644 index 00000000000000..c0f74ed62b4eec --- /dev/null +++ b/test/fixtures/rc/import.json @@ -0,0 +1,9 @@ +{ + "nodeOptions": { + "import": [ + "./test/fixtures/printA.js", + "./test/fixtures/printB.js", + "./test/fixtures/printC.js" + ] + } +} diff --git a/test/fixtures/rc/invalid-import.json b/test/fixtures/rc/invalid-import.json new file mode 100644 index 00000000000000..8d6a1a0777e6b9 --- /dev/null +++ b/test/fixtures/rc/invalid-import.json @@ -0,0 +1,7 @@ +{ + "nodeOptions": { + "import": [ + 1 + ] + } +} diff --git a/test/fixtures/rc/negative-numeric.json b/test/fixtures/rc/negative-numeric.json new file mode 100644 index 00000000000000..f0b6d5736985a4 --- /dev/null +++ b/test/fixtures/rc/negative-numeric.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "max-http-header-size": -1 + } +} diff --git a/test/fixtures/rc/no-op.json b/test/fixtures/rc/no-op.json new file mode 100644 index 00000000000000..a8e0a191ca7cb5 --- /dev/null +++ b/test/fixtures/rc/no-op.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "http-parser": true + } +} diff --git a/test/fixtures/rc/non-object-node-options.json b/test/fixtures/rc/non-object-node-options.json new file mode 100644 index 00000000000000..5dc596e4673bf3 --- /dev/null +++ b/test/fixtures/rc/non-object-node-options.json @@ -0,0 +1,3 @@ +{ + "nodeOptions": "string" +} diff --git a/test/fixtures/rc/non-object-root.json b/test/fixtures/rc/non-object-root.json new file mode 100644 index 00000000000000..fe51488c7066f6 --- /dev/null +++ b/test/fixtures/rc/non-object-root.json @@ -0,0 +1 @@ +[] diff --git a/test/fixtures/rc/non-readable/node.config.json b/test/fixtures/rc/non-readable/node.config.json new file mode 100755 index 00000000000000..21e2b85fbda8fc --- /dev/null +++ b/test/fixtures/rc/non-readable/node.config.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "max-http-header-size": 10 + } +} diff --git a/test/fixtures/rc/not-node-options-flag.json b/test/fixtures/rc/not-node-options-flag.json new file mode 100644 index 00000000000000..c35ff6064ea39c --- /dev/null +++ b/test/fixtures/rc/not-node-options-flag.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "test": true + } +} diff --git a/test/fixtures/rc/numeric.json b/test/fixtures/rc/numeric.json new file mode 100644 index 00000000000000..c9d5d6241f85ed --- /dev/null +++ b/test/fixtures/rc/numeric.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "max-http-header-size": 4294967295 + } +} diff --git a/test/fixtures/rc/override-property.json b/test/fixtures/rc/override-property.json new file mode 100644 index 00000000000000..9e76f24fcd30bc --- /dev/null +++ b/test/fixtures/rc/override-property.json @@ -0,0 +1,6 @@ +{ + "nodeOptions": { + "experimental-transform-types": true, + "experimental-transform-types": false + } +} diff --git a/test/fixtures/rc/sneaky-flag.json b/test/fixtures/rc/sneaky-flag.json new file mode 100644 index 00000000000000..0b2342539eaff2 --- /dev/null +++ b/test/fixtures/rc/sneaky-flag.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "import": "./test/fixtures/printA.js --experimental-transform-types" + } +} diff --git a/test/fixtures/rc/string.json b/test/fixtures/rc/string.json new file mode 100644 index 00000000000000..54dd0964b31a82 --- /dev/null +++ b/test/fixtures/rc/string.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "test-reporter": "dot" + } +} diff --git a/test/fixtures/rc/test.js b/test/fixtures/rc/test.js new file mode 100644 index 00000000000000..7775b1498797d3 --- /dev/null +++ b/test/fixtures/rc/test.js @@ -0,0 +1,6 @@ +const { test } = require('node:test'); +const { ok } = require('node:assert'); + +test('should pass', () => { + ok(true); +}); diff --git a/test/fixtures/rc/transform-types.json b/test/fixtures/rc/transform-types.json new file mode 100644 index 00000000000000..ea5a9f9f16ff1f --- /dev/null +++ b/test/fixtures/rc/transform-types.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "experimental-transform-types": true + } +} diff --git a/test/fixtures/rc/unknown-flag.json b/test/fixtures/rc/unknown-flag.json new file mode 100644 index 00000000000000..31087baa00f4f0 --- /dev/null +++ b/test/fixtures/rc/unknown-flag.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "some-unknown-flag": true + } +} diff --git a/test/fixtures/rc/v8-flag.json b/test/fixtures/rc/v8-flag.json new file mode 100644 index 00000000000000..5f740953063002 --- /dev/null +++ b/test/fixtures/rc/v8-flag.json @@ -0,0 +1,5 @@ +{ + "nodeOptions": { + "abort-on-uncaught-exception": true + } +} diff --git a/test/parallel/test-config-file.js b/test/parallel/test-config-file.js new file mode 100644 index 00000000000000..b2a8625d327549 --- /dev/null +++ b/test/parallel/test-config-file.js @@ -0,0 +1,353 @@ +'use strict'; + +const { spawnPromisified } = require('../common'); +const fixtures = require('../common/fixtures'); +const { match, strictEqual } = require('node:assert'); +const { test } = require('node:test'); +const { chmodSync, constants } = require('node:fs'); +const common = require('../common'); + +test('should handle non existing json', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-config-file', + 'i-do-not-exist.json', + '-p', '"Hello, World!"', + ]); + match(result.stderr, /Cannot read configuration from i-do-not-exist\.json: no such file or directory/); + match(result.stderr, /i-do-not-exist\.json: not found/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); +}); + +test('should handle empty json', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-config-file', + fixtures.path('rc/empty.json'), + '-p', '"Hello, World!"', + ]); + match(result.stderr, /Can't parse/); + match(result.stderr, /empty\.json: invalid content/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); +}); + +test('should handle empty object json', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/empty-object.json'), + '-p', '"Hello, World!"', + ]); + strictEqual(result.stderr, ''); + match(result.stdout, /Hello, World!/); + strictEqual(result.code, 0); +}); + +test('should parse boolean flag', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-config-file', + fixtures.path('rc/transform-types.json'), + fixtures.path('typescript/ts/transformation/test-enum.ts'), + ]); + match(result.stderr, /--experimental-config-file is an experimental feature and might change at any time/); + match(result.stdout, /Hello, TypeScript!/); + strictEqual(result.code, 0); +}); + +test('should not override a flag declared twice', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/override-property.json'), + fixtures.path('typescript/ts/transformation/test-enum.ts'), + ]); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, 'Hello, TypeScript!\n'); + strictEqual(result.code, 0); +}); + +test('should override env-file', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/transform-types.json'), + '--env-file', fixtures.path('dotenv/node-options-no-tranform.env'), + fixtures.path('typescript/ts/transformation/test-enum.ts'), + ]); + strictEqual(result.stderr, ''); + match(result.stdout, /Hello, TypeScript!/); + strictEqual(result.code, 0); +}); + +test('should not override NODE_OPTIONS', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-strip-types', + '--experimental-config-file', + fixtures.path('rc/transform-types.json'), + fixtures.path('typescript/ts/transformation/test-enum.ts'), + ], { + env: { + ...process.env, + NODE_OPTIONS: '--no-experimental-transform-types', + }, + }); + match(result.stderr, /ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 1); +}); + +test('should not ovverride CLI flags', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--no-experimental-transform-types', + '--experimental-config-file', + fixtures.path('rc/transform-types.json'), + fixtures.path('typescript/ts/transformation/test-enum.ts'), + ]); + match(result.stderr, /ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 1); +}); + +test('should parse array flag correctly', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/import.json'), + '--eval', 'setTimeout(() => console.log("D"),99)', + ]); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, 'A\nB\nC\nD\n'); + strictEqual(result.code, 0); +}); + +test('should validate invalid array flag', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/invalid-import.json'), + '--eval', 'setTimeout(() => console.log("D"),99)', + ]); + match(result.stderr, /invalid-import\.json: invalid content/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); +}); + +test('should validate array flag as string', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/import-as-string.json'), + '--eval', 'setTimeout(() => console.log("B"),99)', + ]); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, 'A\nB\n'); + strictEqual(result.code, 0); +}); + +test('should throw at unknown flag', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/unknown-flag.json'), + '-p', '"Hello, World!"', + ]); + match(result.stderr, /Unknown or not allowed option some-unknown-flag/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); +}); + +test('should throw at flag not available in NODE_OPTIONS', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/not-node-options-flag.json'), + '-p', '"Hello, World!"', + ]); + match(result.stderr, /Unknown or not allowed option test/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); +}); + +test('unsigned flag should be parsed correctly', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/numeric.json'), + '-p', 'http.maxHeaderSize', + ]); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, '4294967295\n'); + strictEqual(result.code, 0); +}); + +test('numeric flag should not allow negative values', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/negative-numeric.json'), + '-p', 'http.maxHeaderSize', + ]); + match(result.stderr, /Invalid value for --max-http-header-size/); + match(result.stderr, /negative-numeric\.json: invalid content/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); +}); + +test('v8 flag should not be allowed in config file', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/v8-flag.json'), + '-p', '"Hello, World!"', + ]); + match(result.stderr, /V8 flag --abort-on-uncaught-exception is currently not supported/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); +}); + +test('string flag should be parsed correctly', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--test', + '--experimental-config-file', + fixtures.path('rc/string.json'), + fixtures.path('rc/test.js'), + ]); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, '.\n'); + strictEqual(result.code, 0); +}); + +test('host port flag should be parsed correctly', { skip: !process.features.inspector }, async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--expose-internals', + '--experimental-config-file', + fixtures.path('rc/host-port.json'), + '-p', 'require("internal/options").getOptionValue("--inspect-port").port', + ]); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, '65535\n'); + strictEqual(result.code, 0); +}); + +test('no op flag should throw', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/no-op.json'), + '-p', '"Hello, World!"', + ]); + match(result.stderr, /No-op flag --http-parser is currently not supported/); + match(result.stderr, /no-op\.json: invalid content/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); +}); + +test('should not allow users to sneak in a flag', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/sneaky-flag.json'), + '-p', '"Hello, World!"', + ]); + match(result.stderr, /The number of NODE_OPTIONS doesn't match the number of flags in the config file/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); +}); + +test('non object root', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/non-object-root.json'), + '-p', '"Hello, World!"', + ]); + match(result.stderr, /Root value unexpected not an object for/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); +}); + +test('non object node options', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/non-object-node-options.json'), + '-p', '"Hello, World!"', + ]); + match(result.stderr, /"nodeOptions" value unexpected for/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); +}); + +test('should throw correct error when a json is broken', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/broken.json'), + '-p', '"Hello, World!"', + ]); + match(result.stderr, /Can't parse/); + match(result.stderr, /broken\.json: invalid content/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); +}); + +test('broken value in node_options', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/broken-node-options.json'), + '-p', '"Hello, World!"', + ]); + match(result.stderr, /Can't parse/); + match(result.stderr, /broken-node-options\.json: invalid content/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); +}); + +test('should use node.config.json as default', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-default-config-file', + '-p', 'http.maxHeaderSize', + ], { + cwd: fixtures.path('rc/default'), + }); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, '10\n'); + strictEqual(result.code, 0); +}); + +test('should override node.config.json when specificied', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-default-config-file', + '--experimental-config-file', + fixtures.path('rc/default/override.json'), + '-p', 'http.maxHeaderSize', + ], { + cwd: fixtures.path('rc/default'), + }); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, '20\n'); + strictEqual(result.code, 0); +}); +// Skip on windows because it doesn't support chmod changing read permissions +test('should throw an error when the file is non readable', { skip: common.isWindows }, async () => { + chmodSync(fixtures.path('rc/non-readable/node.config.json'), constants.O_RDONLY); + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-default-config-file', + '-p', 'http.maxHeaderSize', + ], { + cwd: fixtures.path('rc/non-readable'), + }); + match(result.stderr, /Cannot read configuration from node\.config\.json: permission denied/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); + chmodSync(fixtures.path('rc/non-readable/node.config.json'), + constants.S_IRWXU | constants.S_IRWXG | constants.S_IRWXO); +}); diff --git a/test/parallel/test-config-json-schema.js b/test/parallel/test-config-json-schema.js new file mode 100644 index 00000000000000..5a4c1075d0fdb5 --- /dev/null +++ b/test/parallel/test-config-json-schema.js @@ -0,0 +1,40 @@ +// Flags: --no-warnings --expose-internals + +'use strict'; + +const common = require('../common'); + +common.skipIfInspectorDisabled(); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +const { hasOpenSSL3 } = require('../common/crypto'); + +if (!hasOpenSSL3) { + common.skip('this test requires OpenSSL 3.x'); +} + +if (!common.hasIntl) { + // A handful of the tests fail when ICU is not included. + common.skip('missing Intl'); +} + +const { + generateConfigJsonSchema, +} = require('internal/options'); +const schemaInDoc = require('../../doc/node-config-schema.json'); +const assert = require('assert'); + +const schema = generateConfigJsonSchema(); + +// This assertion ensures that whenever we add a new env option, we also add it +// to the JSON schema. The function getEnvOptionsInputType() returns all the available +// env options, so we can generate the JSON schema from it and compare it to the +// current JSON schema. +// To regenerate the JSON schema, run: +// out/Release/node --expose-internals tools/doc/generate-json-schema.mjs +// And then run make doc to update the out/doc/node-config-schema.json file. +assert.strictEqual(JSON.stringify(schema), JSON.stringify(schemaInDoc), 'JSON schema is outdated.' + + 'Run `out/Release/node --expose-internals tools/doc/generate-json-schema.mjs` to update it.'); diff --git a/tools/doc/generate-json-schema.mjs b/tools/doc/generate-json-schema.mjs new file mode 100644 index 00000000000000..29f15605026c9f --- /dev/null +++ b/tools/doc/generate-json-schema.mjs @@ -0,0 +1,7 @@ +// Flags: --expose-internals + +import internal from 'internal/options'; +import { writeFileSync } from 'fs'; + +const schema = internal.generateConfigJsonSchema(); +writeFileSync('doc/node-config-schema.json', `${JSON.stringify(schema, null, 2)}\n`);