src: add config file support

PR-URL: https://github.com/nodejs/node/pull/57016
Refs: https://github.com/nodejs/node/issues/53787
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Tierney Cyren <hello@bnb.im>
Reviewed-By: Michael Dawson <midawson@redhat.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
This commit is contained in:
Marco Ippolito 2024-08-27 13:46:11 +02:00 committed by Node.js GitHub Bot
parent 5ab7c4c5b0
commit f0e653d2af
33 changed files with 1438 additions and 1 deletions

View File

@ -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.

View File

@ -911,6 +911,69 @@ added: v23.6.0
Enable experimental import support for `.node` addons.
### `--experimental-config-file`
<!-- YAML
added: REPLACEME
-->
> 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`
<!-- YAML

View File

@ -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().
.

View File

@ -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"
}

View File

@ -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,
};

View File

@ -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;

View File

@ -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',

View File

@ -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<std::string> 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.

195
src/node_config_file.cc Normal file
View File

@ -0,0 +1,195 @@
#include "node_config_file.h"
#include "debug_utils-inl.h"
#include "simdjson.h"
#include <string>
namespace node {
std::optional<std::string_view> ConfigReader::GetDataFromArgs(
const std::vector<std::string>& 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<std::string> 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

43
src/node_config_file.h Normal file
View File

@ -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 <map>
#include <string>
#include <variant>
#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<std::string_view> GetDataFromArgs(
const std::vector<std::string>& args);
std::string AssignNodeOptions();
size_t GetFlagsSize();
private:
std::vector<std::string> flags_;
};
} // namespace node
#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
#endif // SRC_NODE_CONFIG_FILE_H_

View File

@ -29,6 +29,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 +682,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 +1303,19 @@ std::string GetBashCompletion() {
return out.str();
}
std::unordered_map<std::string, options_parser::OptionType>
MapEnvOptionsFlagInputType() {
std::unordered_map<std::string, options_parser::OptionType> 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
@ -1542,6 +1559,81 @@ void GetEmbedderOptions(const FunctionCallbackInfo<Value>& 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<Value>& args) {
Isolate* isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
Environment* env = Environment::GetCurrent(context);
if (!env->has_run_bootstrapping_code()) {
// No code because this is an assertion.
return env->ThrowError(
"Should not query options before bootstrapping is done");
}
Mutex::ScopedLock lock(per_process::cli_options_mutex);
Local<Map> 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<int>(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<String> value;
if (!String::NewFromUtf8(
isolate, type.data(), v8::NewStringType::kNormal, type.size())
.ToLocal(&value)) {
continue;
}
Local<String> 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<Object> target,
Local<Value> unused,
Local<Context> context,
@ -1554,7 +1646,8 @@ void Initialize(Local<Object> target,
context, target, "getCLIOptionsInfo", GetCLIOptionsInfo);
SetMethodNoSideEffect(
context, target, "getEmbedderOptions", GetEmbedderOptions);
SetMethodNoSideEffect(
context, target, "getEnvOptionsInputType", GetEnvOptionsInputType);
Local<Object> env_settings = Object::New(isolate);
NODE_DEFINE_CONSTANT(env_settings, kAllowedInEnvvar);
NODE_DEFINE_CONSTANT(env_settings, kDisallowedInEnvvar);
@ -1580,6 +1673,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(GetCLIOptionsValues);
registry->Register(GetCLIOptionsInfo);
registry->Register(GetEmbedderOptions);
registry->Register(GetEnvOptionsInputType);
}
} // namespace options_parser

View File

@ -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<std::string, OptionType> MapEnvOptionsFlagInputType();
template <typename Options>
class OptionsParser {
@ -570,6 +572,10 @@ class OptionsParser {
friend void GetCLIOptionsInfo(
const v8::FunctionCallbackInfo<v8::Value>& args);
friend std::string GetBashCompletion();
friend std::unordered_map<std::string, OptionType>
MapEnvOptionsFlagInputType();
friend void GetEnvOptionsInputType(
const v8::FunctionCallbackInfo<v8::Value>& args);
};
using StringVector = std::vector<std::string>;

View File

@ -0,0 +1 @@
NODE_OPTIONS="--no-experimental-strip-types"

3
test/fixtures/rc/empty-object.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
}

1
test/fixtures/rc/empty.json vendored Normal file
View File

@ -0,0 +1 @@

3
test/fixtures/rc/host-port.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"inspect-port": 65535
}

View File

@ -0,0 +1,3 @@
{
"import": "./test/fixtures/printA.js"
}

7
test/fixtures/rc/import.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"import": [
"./test/fixtures/printA.js",
"./test/fixtures/printB.js",
"./test/fixtures/printC.js"
]
}

3
test/fixtures/rc/invalid-import.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"import": [1]
}

View File

@ -0,0 +1,3 @@
{
"max-http-header-size": -1
}

3
test/fixtures/rc/no-op.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"http-parser": true
}

View File

@ -0,0 +1,3 @@
{
"--test": true
}

3
test/fixtures/rc/numeric.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"max-http-header-size": 4294967295
}

View File

@ -0,0 +1,4 @@
{
"experimental-transform-types": true,
"experimental-transform-types": false
}

3
test/fixtures/rc/sneaky-flag.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"import": "./test/fixtures/printA.js --experimental-transform-types"
}

3
test/fixtures/rc/string.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"test-reporter": "dot"
}

6
test/fixtures/rc/test.js vendored Normal file
View File

@ -0,0 +1,6 @@
const { test } = require('node:test');
const { ok } = require('node:assert');
test('should pass', () => {
ok(true);
});

3
test/fixtures/rc/transform-types.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"experimental-transform-types": true
}

3
test/fixtures/rc/unknown-flag.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"some-unknown-flag": true
}

3
test/fixtures/rc/v8-flag.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"abort-on-uncaught-exception": true
}

View File

@ -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);
});

View File

@ -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.');

View File

@ -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`);