diff --git a/Makefile b/Makefile index 39f5ee5e12a9b7..69c026755bf230 100644 --- a/Makefile +++ b/Makefile @@ -809,6 +809,7 @@ doc: $(NODE_EXE) doc-only ## Build Node.js, and then build the documentation wit out/doc: mkdir -p $@ + cp doc/node_config_json_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 855d83a2d2ba37..37ce213570b25b 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -911,6 +911,69 @@ added: v23.6.0 Enable experimental import support for `.node` addons. +### `--experimental-config-file` + + + +> Stability: 1.0 - Early development + +Use this flag to specify a configuration file that will be loaded and parsed +before the application starts. +Node.js will read the configuration file and apply the settings. +The configuration file should be a JSON file +with the following structure: + +```json +{ + "$schema": "https://nodejs.org/dist/REPLACEME/docs/node_config_json_schema.json", + "experimental-transform-types": true, + "import": [ + "amaro/transform" + ], + "disable-warning": "ExperimentalWarning", + "watch-path": "src", + "watch-preserve-output": true +} +``` + +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_json_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 --experimental-transform-types --import amaro/transform --disable-warning=ExperimentalWarning --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-eventsource` + +An attempt was made to get options before the bootstrapping was completed. + ### `ERR_OUT_OF_RANGE` diff --git a/doc/node.1 b/doc/node.1 index d33bb82b7670e7..ad8873b423105c 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -166,6 +166,9 @@ Interpret the entry point as a URL. .It Fl -experimental-addon-modules Enable experimental addon module support. . +.It Fl -experimental-config-file +Enable support for experimental config file +. .It Fl -experimental-import-meta-resolve Enable experimental ES modules support for import.meta.resolve(). . diff --git a/doc/node_config_json_schema.json b/doc/node_config_json_schema.json new file mode 100644 index 00000000000000..f76fbef3c7c3fd --- /dev/null +++ b/doc/node_config_json_schema.json @@ -0,0 +1,578 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "addons": { + "type": "boolean" + }, + "allow-addons": { + "type": "boolean" + }, + "allow-child-process": { + "type": "boolean" + }, + "allow-fs-read": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "allow-fs-write": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "allow-wasi": { + "type": "boolean" + }, + "allow-worker": { + "type": "boolean" + }, + "async-context-frame": { + "type": "boolean" + }, + "conditions": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "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" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "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-addon-modules": { + "type": "boolean" + }, + "experimental-detect-module": { + "type": "boolean" + }, + "experimental-eventsource": { + "type": "boolean" + }, + "experimental-global-navigator": { + "type": "boolean" + }, + "experimental-import-meta-resolve": { + "type": "boolean" + }, + "experimental-loader": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "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" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "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" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "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" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "test-coverage-functions": { + "type": "number" + }, + "test-coverage-include": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "test-coverage-lines": { + "type": "number" + }, + "test-isolation": { + "type": "string" + }, + "test-name-pattern": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "test-only": { + "type": "boolean" + }, + "test-reporter": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "test-reporter-destination": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "test-shard": { + "type": "string" + }, + "test-skip-pattern": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "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-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" + }, + { + "type": "array", + "items": { + "type": "string", + "minItems": 1 + } + } + ] + }, + "watch-preserve-output": { + "type": "boolean" + }, + "zero-fill-buffers": { + "type": "boolean" + } + }, + "type": "object" +} diff --git a/lib/internal/options.js b/lib/internal/options.js index 1192b46c9ede82..f23b75c0a8f3a2 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,45 @@ 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: { + __proto__: null, + }, + type: 'object', + }; + + for (const { 0: key, 1: type } of map) { + const keyWithoutPrefix = StringPrototypeReplace(key, '--', ''); + if (type === 'array') { + schema.properties[keyWithoutPrefix] = { + __proto__: null, + oneOf: [ + { __proto__: null, type: 'string' }, + { __proto__: null, type: 'array', items: { __proto__: null, type: 'string', minItems: 1 } }, + ], + }; + } else { + schema.properties[keyWithoutPrefix] = { __proto__: null, type }; + } + } + + // Sort the proerties by key alphabetically. + const sortedKeys = ArrayPrototypeSort(ObjectKeys(schema.properties)); + const sortedProperties = ObjectFromEntries( + ArrayPrototypeMap(sortedKeys, (key) => [key, schema.properties[key]]), + ); + + schema.properties = sortedProperties; + + return schema; +} + function refreshOptions() { optionsDict = undefined; } @@ -55,5 +103,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 109890e5986ee4..c705fce930da24 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -116,6 +116,8 @@ function prepareExecution(options) { initializeSourceMapsHandlers(); initializeDeprecations(); + initializeConfigFileSupport(); + require('internal/dns/utils').initializeDns(); if (isMainThread) { @@ -312,6 +314,12 @@ function setupSQLite() { BuiltinModule.allowRequireByUsers('sqlite'); } +function initializeConfigFileSupport() { + if (getOptionValue('--experimental-config-file')) { + emitExperimentalWarning('--experimental-config-file'); + } +} + function setupQuic() { if (!getOptionValue('--experimental-quic')) { return; diff --git a/node.gyp b/node.gyp index 42a1160ad4af7b..726403ae0bb9f4 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', @@ -230,6 +231,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 61e7287a4f73a3..4cdbcda7343293 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; @@ -884,6 +888,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..700c3e458dad23 --- /dev/null +++ b/src/node_config_file.cc @@ -0,0 +1,195 @@ +#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 = "--experimental-config-file"; + + for (auto it = args.begin(); it != args.end(); ++it) { + if (*it == flag) { + // Case: "--experimental-config-file foo" + if (auto next = std::next(it); next != args.end()) { + return *next; + } + } else if (it->starts_with(flag)) { + // Case: "--experimental-config-file=foo" + if (it->size() > flag.size() && (*it)[flag.size()] == '=') { + return it->substr(flag.size() + 1); + } + } + } + + return std::nullopt; +} + +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; + } + + auto env_options_map = options_parser::MapEnvOptionsFlagInputType(); + simdjson::ondemand::value ondemand_value; + std::string_view key; + + for (auto field : main_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; + } + flags_.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; + } + flags_.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; + } + flags_.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; + } + flags_.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; + } + flags_.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; + } + flags_.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; +} + +std::string ConfigReader::AssignNodeOptions() { + if (flags_.empty()) { + return ""; + } else { + DCHECK_GT(flags_.size(), 0); + std::string acc; + acc.reserve(flags_.size() * 2); + for (size_t i = 0; i < flags_.size(); ++i) { + // The space is necessary at the beginning of the string + acc += " " + flags_[i]; + } + return acc; + } +} + +size_t ConfigReader::GetFlagsSize() { + return flags_.size(); +} +} // namespace node diff --git a/src/node_config_file.h b/src/node_config_file.h new file mode 100644 index 00000000000000..938f3647d8eb8e --- /dev/null +++ b/src/node_config_file.h @@ -0,0 +1,43 @@ +#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: + std::vector flags_; +}; + +} // 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 801b19fc91810b..8c714d7f6fb142 100644 --- a/src/node_errors.h +++ b/src/node_errors.h @@ -101,6 +101,7 @@ void OOMErrorHandler(const char* location, const v8::OOMDetails& details); V(ERR_MODULE_NOT_FOUND, Error) \ V(ERR_NON_CONTEXT_AWARE_DISABLED, Error) \ V(ERR_OPERATION_FAILED, TypeError) \ + 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 939628fdf10eaf..89af5ff4422996 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 { @@ -681,6 +683,9 @@ 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); AddOption("--test", "launch test runner on startup", &EnvironmentOptions::test_runner); @@ -1299,6 +1304,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 @@ -1327,8 +1345,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); @@ -1449,8 +1467,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); @@ -1518,7 +1536,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(); @@ -1542,6 +1561,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, @@ -1554,7 +1648,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); @@ -1580,6 +1675,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 6b8e812321c1ae..93fbebd20e656d 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -258,6 +258,7 @@ class EnvironmentOptions : public Options { bool report_exclude_env = false; bool report_exclude_network = false; + std::string experimental_config_file; inline DebugOptions* get_debug_options() { return &debug_options_; } inline const DebugOptions& debug_options() const { return debug_options_; } @@ -390,6 +391,7 @@ enum OptionType { kHostPort, kStringList, }; +std::unordered_map MapEnvOptionsFlagInputType(); template class OptionsParser { @@ -570,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/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..d9ee9a3662387d --- /dev/null +++ b/test/fixtures/rc/host-port.json @@ -0,0 +1,3 @@ +{ + "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..e719e1bc9d9a8a --- /dev/null +++ b/test/fixtures/rc/import-as-string.json @@ -0,0 +1,3 @@ +{ + "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..d169f8a9100395 --- /dev/null +++ b/test/fixtures/rc/import.json @@ -0,0 +1,7 @@ +{ + "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..bc6a4a2757e166 --- /dev/null +++ b/test/fixtures/rc/invalid-import.json @@ -0,0 +1,3 @@ +{ + "import": [1] +} diff --git a/test/fixtures/rc/negative-numeric.json b/test/fixtures/rc/negative-numeric.json new file mode 100644 index 00000000000000..a1303cb8251331 --- /dev/null +++ b/test/fixtures/rc/negative-numeric.json @@ -0,0 +1,3 @@ +{ + "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..8901009333cac3 --- /dev/null +++ b/test/fixtures/rc/no-op.json @@ -0,0 +1,3 @@ +{ + "http-parser": true +} 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..446d3207b135f0 --- /dev/null +++ b/test/fixtures/rc/not-node-options-flag.json @@ -0,0 +1,3 @@ +{ + "--test": true +} diff --git a/test/fixtures/rc/numeric.json b/test/fixtures/rc/numeric.json new file mode 100644 index 00000000000000..d22bf4eec303e8 --- /dev/null +++ b/test/fixtures/rc/numeric.json @@ -0,0 +1,3 @@ +{ + "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..17e3ac7738d76f --- /dev/null +++ b/test/fixtures/rc/override-property.json @@ -0,0 +1,4 @@ +{ + "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..20a5d966f01868 --- /dev/null +++ b/test/fixtures/rc/sneaky-flag.json @@ -0,0 +1,3 @@ +{ + "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..e1b4d4ebc001aa --- /dev/null +++ b/test/fixtures/rc/string.json @@ -0,0 +1,3 @@ +{ + "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..aae5a83d651f71 --- /dev/null +++ b/test/fixtures/rc/transform-types.json @@ -0,0 +1,3 @@ +{ + "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..1e284d7f9cbb0c --- /dev/null +++ b/test/fixtures/rc/unknown-flag.json @@ -0,0 +1,3 @@ +{ + "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..b38a41d9325a49 --- /dev/null +++ b/test/fixtures/rc/v8-flag.json @@ -0,0 +1,3 @@ +{ + "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..6cd24adbc7943a --- /dev/null +++ b/test/parallel/test-config-file.js @@ -0,0 +1,256 @@ +'use strict'; + +const { spawnPromisified } = require('../common'); +const fixtures = require('../common/fixtures'); +const { match, strictEqual } = require('node:assert'); +const { test } = require('node:test'); + +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-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); +}); diff --git a/test/parallel/test-config-json-schema.js b/test/parallel/test-config-json-schema.js new file mode 100644 index 00000000000000..0872a2419c0224 --- /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_json_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_json_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..83a0323fa88ef1 --- /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_json_schema.json', `${JSON.stringify(schema, null, 2)}\n`);