From cab29f9b52aa6dc8b0a3619684a75333dbcf66c6 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Wed, 25 Oct 2023 17:51:27 -0400 Subject: [PATCH] src: move package resolver to c++ --- lib/internal/modules/cjs/loader.js | 1 + lib/internal/modules/esm/get_format.js | 2 +- lib/internal/modules/esm/module_job.js | 2 +- lib/internal/modules/esm/package_config.js | 87 ++-- lib/internal/modules/esm/resolve.js | 27 +- lib/internal/modules/package_json_reader.js | 109 ++--- lib/internal/modules/run_main.js | 8 +- node.gyp | 2 + src/base_object_types.h | 3 +- src/node_binding.cc | 1 + src/node_binding.h | 1 + src/node_errors.h | 1 + src/node_external_reference.h | 1 + src/node_file.cc | 64 --- src/node_modules.cc | 424 ++++++++++++++++++ src/node_modules.h | 83 ++++ src/node_snapshotable.cc | 1 + src/node_url.cc | 58 +-- src/node_url.h | 6 +- test/es-module/test-esm-invalid-pjson.js | 8 +- .../pkgexports-number/package.json | 2 +- test/parallel/test-bootstrap-modules.js | 1 + test/parallel/test-module-binding.js | 29 -- typings/globals.d.ts | 2 + typings/internalBinding/fs.d.ts | 1 - typings/internalBinding/modules.d.ts | 22 + 26 files changed, 670 insertions(+), 276 deletions(-) create mode 100644 src/node_modules.cc create mode 100644 src/node_modules.h delete mode 100644 test/parallel/test-module-binding.js create mode 100644 typings/internalBinding/modules.d.ts diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index b077ee386bb40e..d9eeb73861a57f 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -1099,6 +1099,7 @@ Module._resolveFilename = function(request, parent, isMain, options) { if (request[0] === '#' && (parent?.filename || parent?.id === '')) { const parentPath = parent?.filename ?? process.cwd() + path.sep; + // TODO(@anonrig): Move this to C++. const pkg = packageJsonReader.readPackageScope(parentPath) || { __proto__: null }; if (pkg.data?.imports != null) { try { diff --git a/lib/internal/modules/esm/get_format.js b/lib/internal/modules/esm/get_format.js index 1931688e85d05e..c029b6c614384f 100644 --- a/lib/internal/modules/esm/get_format.js +++ b/lib/internal/modules/esm/get_format.js @@ -19,7 +19,7 @@ const { const experimentalNetworkImports = getOptionValue('--experimental-network-imports'); const { containsModuleSyntax } = internalBinding('contextify'); -const { getPackageType } = require('internal/modules/esm/resolve'); +const { getPackageType } = require('internal/modules/esm/package_config'); const { fileURLToPath } = require('internal/url'); const { ERR_UNKNOWN_FILE_EXTENSION } = require('internal/errors').codes; diff --git a/lib/internal/modules/esm/module_job.js b/lib/internal/modules/esm/module_job.js index 83c23456e05f10..7116f3724bb6e1 100644 --- a/lib/internal/modules/esm/module_job.js +++ b/lib/internal/modules/esm/module_job.js @@ -228,7 +228,7 @@ class ModuleJob { const packageConfig = StringPrototypeStartsWith(this.module.url, 'file://') && RegExpPrototypeExec(/\.js(\?[^#]*)?(#.*)?$/, this.module.url) !== null && - require('internal/modules/esm/resolve') + require('internal/modules/esm/package_config') .getPackageScopeConfig(this.module.url); if (packageConfig.type === 'module') { e.message += diff --git a/lib/internal/modules/esm/package_config.js b/lib/internal/modules/esm/package_config.js index 5da47764c9de2c..3f6cbe80c4e091 100644 --- a/lib/internal/modules/esm/package_config.js +++ b/lib/internal/modules/esm/package_config.js @@ -1,59 +1,20 @@ 'use strict'; -const { - StringPrototypeEndsWith, -} = primordials; -const { URL, fileURLToPath } = require('internal/url'); -const packageJsonReader = require('internal/modules/package_json_reader'); - -/** - * @typedef {object} PackageConfig - * @property {string} pjsonPath - The path to the package.json file. - * @property {boolean} exists - Whether the package.json file exists. - * @property {'none' | 'commonjs' | 'module'} type - The type of the package. - * @property {string} [name] - The name of the package. - * @property {string} [main] - The main entry point of the package. - * @property {PackageTarget} [exports] - The exports configuration of the package. - * @property {Record>} [imports] - The imports configuration of the package. - */ -/** - * @typedef {string | string[] | Record>} PackageTarget - */ +const { ArrayIsArray } = primordials; +const { fileURLToPath } = require('internal/url'); +const { toNamespacedPath } = require('path'); +const modulesBinding = internalBinding('modules'); /** * Returns the package configuration for the given resolved URL. * @param {URL | string} resolved - The resolved URL. - * @returns {PackageConfig} - The package configuration. + * @returns {import('typings/internalBinding/modules').PackageConfig} - The package configuration. */ function getPackageScopeConfig(resolved) { - let packageJSONUrl = new URL('./package.json', resolved); - while (true) { - const packageJSONPath = packageJSONUrl.pathname; - if (StringPrototypeEndsWith(packageJSONPath, 'node_modules/package.json')) { - break; - } - const packageConfig = packageJsonReader.read(fileURLToPath(packageJSONUrl), { - __proto__: null, - specifier: resolved, - isESM: true, - }); - if (packageConfig.exists) { - return packageConfig; - } - - const lastPackageJSONUrl = packageJSONUrl; - packageJSONUrl = new URL('../package.json', packageJSONUrl); - - // Terminates at root where ../package.json equals ../../package.json - // (can't just check "/package.json" for Windows support). - if (packageJSONUrl.pathname === lastPackageJSONUrl.pathname) { - break; - } - } - const packageJSONPath = fileURLToPath(packageJSONUrl); - return { + const packageScopeConfig = modulesBinding.getPackageScopeConfig(toNamespacedPath(`${resolved}`)); + const response = { __proto__: null, - pjsonPath: packageJSONPath, + pjsonPath: undefined, exists: false, main: undefined, name: undefined, @@ -61,9 +22,41 @@ function getPackageScopeConfig(resolved) { exports: undefined, imports: undefined, }; + if (ArrayIsArray(packageScopeConfig)) { + const { + 0: name, + 1: main, + 2: type, + 3: imports, + 4: exports, + } = packageScopeConfig; + response.name = name; + response.main = main; + response.type = type; + response.imports = imports; + response.exports = exports; + response.exists = true; + response.pjsonPath = fileURLToPath(resolved); + } else { + // This means that the response is a string + // and it is the path to the package.json file + response.pjsonPath = packageScopeConfig; + } + + return response; +} + +/** + * Returns the package type for a given URL. + * @param {URL} url - The URL to get the package type for. + */ +function getPackageType(url) { + const packageConfig = getPackageScopeConfig(url); + return packageConfig.type; } module.exports = { getPackageScopeConfig, + getPackageType, }; diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js index 58e7df07ca5275..ed28b4b6a89fa1 100644 --- a/lib/internal/modules/esm/resolve.js +++ b/lib/internal/modules/esm/resolve.js @@ -4,6 +4,7 @@ const { ArrayIsArray, ArrayPrototypeJoin, ArrayPrototypeShift, + JSONParse, JSONStringify, ObjectGetOwnPropertyNames, ObjectPrototypeHasOwnProperty, @@ -189,7 +190,7 @@ const legacyMainResolveExtensionsIndexes = { * 4. TRY(pkg_url/index.js, pkg_url/index.json, pkg_url/index.node) * 5. NOT_FOUND * @param {URL} packageJSONUrl - * @param {PackageConfig} packageConfig + * @param {import('typings/internalBinding/modules').PackageConfig} packageConfig * @param {string | URL | undefined} base * @returns {URL} */ @@ -567,6 +568,11 @@ function isConditionalExportsMainSugar(exports, packageJSONUrl, base) { function packageExportsResolve( packageJSONUrl, packageSubpath, packageConfig, base, conditions) { let exports = packageConfig.exports; + // JSONParse is required because we don't parse imports and exports + // fields in package.json files. + if (exports[0] === '[' || exports[0] === '{') { + exports = JSONParse(exports); + } if (isConditionalExportsMainSugar(exports, packageJSONUrl, base)) { exports = { '.': exports }; } @@ -681,8 +687,13 @@ function packageImportsResolve(name, base, conditions) { const packageConfig = getPackageScopeConfig(base); if (packageConfig.exists) { packageJSONUrl = pathToFileURL(packageConfig.pjsonPath); - const imports = packageConfig.imports; + let imports = packageConfig.imports; if (imports) { + // JSONParse is required because we don't parse imports and exports + // fields in package.json files. + if (imports[0] === '[' || imports[0] === '{') { + imports = JSONParse(imports); + } if (ObjectPrototypeHasOwnProperty(imports, name) && !StringPrototypeIncludes(name, '*')) { const resolveResult = resolvePackageTarget( @@ -731,15 +742,6 @@ function packageImportsResolve(name, base, conditions) { throw importNotDefined(name, packageJSONUrl, base); } -/** - * Returns the package type for a given URL. - * @param {URL} url - The URL to get the package type for. - */ -function getPackageType(url) { - const packageConfig = getPackageScopeConfig(url); - return packageConfig.type; -} - /** * Parse a package name from a specifier. * @param {string} specifier - The import specifier. @@ -787,6 +789,7 @@ function parsePackageName(specifier, base) { * @returns {URL} - The resolved URL. */ function packageResolve(specifier, base, conditions) { + // TODO(@anonrig): Move this to a C++ function. if (BuiltinModule.canBeRequiredWithoutScheme(specifier)) { return new URL('node:' + specifier); } @@ -1170,8 +1173,6 @@ module.exports = { decorateErrorWithCommonJSHints, defaultResolve, encodedSepRegEx, - getPackageScopeConfig, - getPackageType, packageExportsResolve, packageImportsResolve, throwIfInvalidParentURL, diff --git a/lib/internal/modules/package_json_reader.js b/lib/internal/modules/package_json_reader.js index 65f5ce3551bbd0..5201cb29446440 100644 --- a/lib/internal/modules/package_json_reader.js +++ b/lib/internal/modules/package_json_reader.js @@ -1,60 +1,37 @@ 'use strict'; const { - JSONParse, - ObjectPrototypeHasOwnProperty, - SafeMap, StringPrototypeEndsWith, StringPrototypeIndexOf, StringPrototypeLastIndexOf, StringPrototypeSlice, } = primordials; -const { - ERR_INVALID_PACKAGE_CONFIG, -} = require('internal/errors').codes; -const { internalModuleReadJSON } = internalBinding('fs'); +const modulesBinding = internalBinding('modules'); const { resolve, sep, toNamespacedPath } = require('path'); const permission = require('internal/process/permission'); -const { kEmptyObject, setOwnProperty } = require('internal/util'); - -const { fileURLToPath, pathToFileURL } = require('internal/url'); - -const cache = new SafeMap(); +const { kEmptyObject } = require('internal/util'); let manifest; - -/** - * @typedef {{ - * exists: boolean, - * pjsonPath: string, - * exports?: string | string[] | Record, - * imports?: string | string[] | Record, - * name?: string, - * main?: string, - * type: 'commonjs' | 'module' | 'none', - * }} PackageConfig - */ - /** * @param {string} jsonPath * @param {{ - * base?: string, - * specifier: string, - * isESM: boolean, + * base?: URL | string, + * specifier?: URL | string, + * isESM?: boolean, * }} options - * @returns {PackageConfig} + * @returns {import('typings/internalBinding/modules').PackageConfig} */ function read(jsonPath, { base, specifier, isESM } = kEmptyObject) { - if (cache.has(jsonPath)) { - return cache.get(jsonPath); - } - - const string = internalModuleReadJSON( + // TODO(@anonrig): Understand why specifier and base is sometimes URL and sometimes string. + const parsed = modulesBinding.readPackageJSON( toNamespacedPath(jsonPath), + isESM, + base == null ? undefined : `${base}`, + specifier == null ? undefined : `${specifier}`, ); - const result = { + const response = { __proto__: null, - exists: false, + exists: parsed !== undefined, pjsonPath: jsonPath, main: undefined, name: undefined, @@ -63,43 +40,19 @@ function read(jsonPath, { base, specifier, isESM } = kEmptyObject) { imports: undefined, }; - if (string !== undefined) { - let parsed; - try { - parsed = JSONParse(string); - } catch (cause) { - const error = new ERR_INVALID_PACKAGE_CONFIG( - jsonPath, - isESM && (base ? `"${specifier}" from ` : '') + fileURLToPath(base || specifier), - cause.message, - ); - setOwnProperty(error, 'cause', cause); - throw error; - } - - result.exists = true; - - // ObjectPrototypeHasOwnProperty is used to avoid prototype pollution. - if (ObjectPrototypeHasOwnProperty(parsed, 'name') && typeof parsed.name === 'string') { - result.name = parsed.name; - } - - if (ObjectPrototypeHasOwnProperty(parsed, 'main') && typeof parsed.main === 'string') { - result.main = parsed.main; - } - - if (ObjectPrototypeHasOwnProperty(parsed, 'exports')) { - result.exports = parsed.exports; - } - - if (ObjectPrototypeHasOwnProperty(parsed, 'imports')) { - result.imports = parsed.imports; - } - - // Ignore unknown types for forwards compatibility - if (ObjectPrototypeHasOwnProperty(parsed, 'type') && (parsed.type === 'commonjs' || parsed.type === 'module')) { - result.type = parsed.type; - } + if (response.exists) { + const { + 0: name, + 1: main, + 2: type, + 3: imports, + 4: exports, + } = parsed; + response.name = name; + response.main = main; + response.type = type; + response.imports = imports; + response.exports = exports; if (manifest === undefined) { const { getOptionValue } = require('internal/options'); @@ -108,12 +61,13 @@ function read(jsonPath, { base, specifier, isESM } = kEmptyObject) { null; } if (manifest !== null) { - const jsonURL = pathToFileURL(jsonPath); - manifest.assertIntegrity(jsonURL, string); + // TODO(@anonrig): Find a way to assert integrity without returning string. + // const jsonURL = pathToFileURL(jsonPath); + // manifest.assertIntegrity(jsonURL, string); } } - cache.set(jsonPath, result); - return result; + + return response; } /** @@ -121,6 +75,7 @@ function read(jsonPath, { base, specifier, isESM } = kEmptyObject) { * @return {PackageConfig} */ function readPackage(requestPath) { + // TODO(@anonrig): Remove this function. return read(resolve(requestPath, 'package.json')); } diff --git a/lib/internal/modules/run_main.js b/lib/internal/modules/run_main.js index 1f03c313121db0..710e61c6569598 100644 --- a/lib/internal/modules/run_main.js +++ b/lib/internal/modules/run_main.js @@ -5,6 +5,7 @@ const { } = primordials; const { containsModuleSyntax } = internalBinding('contextify'); +const { getClosestPackageJSONType } = internalBinding('modules'); const { getOptionValue } = require('internal/options'); const path = require('path'); @@ -68,10 +69,9 @@ function shouldUseESMLoader(mainPath) { if (mainPath && StringPrototypeEndsWith(mainPath, '.mjs')) { return true; } if (!mainPath || StringPrototypeEndsWith(mainPath, '.cjs')) { return false; } - const { readPackageScope } = require('internal/modules/package_json_reader'); - const pkg = readPackageScope(mainPath); - // No need to guard `pkg` as it can only be an object or `false`. - switch (pkg.data?.type) { + const type = getClosestPackageJSONType(path.toNamespacedPath(mainPath)); + + switch (type) { case 'module': return true; case 'commonjs': diff --git a/node.gyp b/node.gyp index 4ff22822f0094c..811d15b0df9ad3 100644 --- a/node.gyp +++ b/node.gyp @@ -112,6 +112,7 @@ 'src/node_main_instance.cc', 'src/node_messaging.cc', 'src/node_metadata.cc', + 'src/node_modules.cc', 'src/node_options.cc', 'src/node_os.cc', 'src/node_perf.cc', @@ -234,6 +235,7 @@ 'src/node_messaging.h', 'src/node_metadata.h', 'src/node_mutex.h', + 'src/node_modules.h', 'src/node_object_wrap.h', 'src/node_options.h', 'src/node_options-inl.h', diff --git a/src/base_object_types.h b/src/base_object_types.h index cb034f1d62b681..9cfe6a77f71708 100644 --- a/src/base_object_types.h +++ b/src/base_object_types.h @@ -17,7 +17,8 @@ namespace node { V(blob_binding_data, BlobBindingData) \ V(process_binding_data, process::BindingData) \ V(timers_binding_data, timers::BindingData) \ - V(url_binding_data, url::BindingData) + V(url_binding_data, url::BindingData) \ + V(modules_binding_data, modules::BindingData) #define UNSERIALIZABLE_BINDING_TYPES(V) \ V(http2_binding_data, http2::BindingData) \ diff --git a/src/node_binding.cc b/src/node_binding.cc index 97257d47c61738..2b69a828a744b6 100644 --- a/src/node_binding.cc +++ b/src/node_binding.cc @@ -49,6 +49,7 @@ V(js_stream) \ V(js_udp_wrap) \ V(messaging) \ + V(modules) \ V(module_wrap) \ V(mksnapshot) \ V(options) \ diff --git a/src/node_binding.h b/src/node_binding.h index 9f0692ca4e190b..29bb478b99e8eb 100644 --- a/src/node_binding.h +++ b/src/node_binding.h @@ -38,6 +38,7 @@ static_assert(static_cast(NM_F_LINKED) == V(encoding_binding) \ V(fs) \ V(mksnapshot) \ + V(modules) \ V(timers) \ V(process_methods) \ V(performance) \ diff --git a/src/node_errors.h b/src/node_errors.h index 0f4a2d0cc6eaaf..d86281f48f15f9 100644 --- a/src/node_errors.h +++ b/src/node_errors.h @@ -71,6 +71,7 @@ void AppendExceptionLine(Environment* env, V(ERR_INVALID_ARG_TYPE, TypeError) \ V(ERR_INVALID_FILE_URL_HOST, TypeError) \ V(ERR_INVALID_FILE_URL_PATH, TypeError) \ + V(ERR_INVALID_PACKAGE_CONFIG, Error) \ V(ERR_INVALID_OBJECT_DEFINE_PROPERTY, TypeError) \ V(ERR_INVALID_MODULE, Error) \ V(ERR_INVALID_STATE, Error) \ diff --git a/src/node_external_reference.h b/src/node_external_reference.h index ae37094c8e117e..a647967077967e 100644 --- a/src/node_external_reference.h +++ b/src/node_external_reference.h @@ -100,6 +100,7 @@ class ExternalReferenceRegistry { V(messaging) \ V(mksnapshot) \ V(module_wrap) \ + V(modules) \ V(options) \ V(os) \ V(performance) \ diff --git a/src/node_file.cc b/src/node_file.cc index 32df2217403c0d..287036ab9d0b3c 100644 --- a/src/node_file.cc +++ b/src/node_file.cc @@ -1038,68 +1038,6 @@ static void ExistsSync(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(err == 0); } -// Used to speed up module loading. Returns an array [string, boolean] -static void InternalModuleReadJSON(const FunctionCallbackInfo& args) { - Environment* env = Environment::GetCurrent(args); - Isolate* isolate = env->isolate(); - uv_loop_t* loop = env->event_loop(); - - CHECK(args[0]->IsString()); - node::Utf8Value path(isolate, args[0]); - THROW_IF_INSUFFICIENT_PERMISSIONS( - env, permission::PermissionScope::kFileSystemRead, path.ToStringView()); - - if (strlen(*path) != path.length()) { - return; // Contains a nul byte. - } - uv_fs_t open_req; - const int fd = uv_fs_open(loop, &open_req, *path, O_RDONLY, 0, nullptr); - uv_fs_req_cleanup(&open_req); - - if (fd < 0) { - return; - } - - auto defer_close = OnScopeLeave([fd, loop]() { - uv_fs_t close_req; - CHECK_EQ(0, uv_fs_close(loop, &close_req, fd, nullptr)); - uv_fs_req_cleanup(&close_req); - }); - - const size_t kBlockSize = 32 << 10; - std::vector chars; - int64_t offset = 0; - ssize_t numchars; - do { - const size_t start = chars.size(); - chars.resize(start + kBlockSize); - - uv_buf_t buf; - buf.base = &chars[start]; - buf.len = kBlockSize; - - uv_fs_t read_req; - numchars = uv_fs_read(loop, &read_req, fd, &buf, 1, offset, nullptr); - uv_fs_req_cleanup(&read_req); - - if (numchars < 0) { - return; - } - offset += numchars; - } while (static_cast(numchars) == kBlockSize); - - size_t start = 0; - if (offset >= 3 && 0 == memcmp(chars.data(), "\xEF\xBB\xBF", 3)) { - start = 3; // Skip UTF-8 BOM. - } - const size_t size = offset - start; - - args.GetReturnValue().Set( - String::NewFromUtf8( - isolate, &chars[start], v8::NewStringType::kNormal, size) - .ToLocalChecked()); -} - // Used to speed up module loading. Returns 0 if the path refers to // a file, 1 when it's a directory or < 0 on error (usually -ENOENT.) // The speedup comes from not creating thousands of Stat and Error objects. @@ -3114,7 +3052,6 @@ static void CreatePerIsolateProperties(IsolateData* isolate_data, SetMethod(isolate, target, "rmdir", RMDir); SetMethod(isolate, target, "mkdir", MKDir); SetMethod(isolate, target, "readdir", ReadDir); - SetMethod(isolate, target, "internalModuleReadJSON", InternalModuleReadJSON); SetMethod(isolate, target, "internalModuleStat", InternalModuleStat); SetMethod(isolate, target, "stat", Stat); SetMethod(isolate, target, "lstat", LStat); @@ -3234,7 +3171,6 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(RMDir); registry->Register(MKDir); registry->Register(ReadDir); - registry->Register(InternalModuleReadJSON); registry->Register(InternalModuleStat); registry->Register(Stat); registry->Register(LStat); diff --git a/src/node_modules.cc b/src/node_modules.cc new file mode 100644 index 00000000000000..d349e46f617d14 --- /dev/null +++ b/src/node_modules.cc @@ -0,0 +1,424 @@ +#include "node_modules.h" +#include +#include "base_object-inl.h" +#include "node_errors.h" +#include "node_external_reference.h" +#include "node_url.h" +#include "util-inl.h" +#include "util.h" +#include "v8-fast-api-calls.h" +#include "v8-function-callback.h" +#include "v8-primitive.h" +#include "v8-value.h" +#include "v8.h" + +#include "simdjson.h" + +namespace node { +namespace modules { + +using errors::TryCatchScope; + +using v8::Array; +using v8::CFunction; +using v8::Context; +using v8::FastOneByteString; +using v8::FunctionCallbackInfo; +using v8::Global; +using v8::HandleScope; +using v8::Isolate; +using v8::JSON; +using v8::Local; +using v8::Map; +using v8::MaybeLocal; +using v8::NewStringType; +using v8::Object; +using v8::ObjectTemplate; +using v8::Primitive; +using v8::String; +using v8::Undefined; +using v8::Value; + +#ifdef __POSIX__ +constexpr char kPathSeparator = '/'; +#else +const char* const kPathSeparator = "\\/"; +#endif + +void BindingData::MemoryInfo(MemoryTracker* tracker) const { + // Do nothing +} + +BindingData::BindingData(Realm* realm, + v8::Local object, + InternalFieldInfo* info) + : SnapshotableObject(realm, object, type_int) {} + +bool BindingData::PrepareForSerialization(v8::Local context, + v8::SnapshotCreator* creator) { + // Return true because we need to maintain the reference to the binding from + // JS land. + return true; +} + +InternalFieldInfoBase* BindingData::Serialize(int index) { + DCHECK_IS_SNAPSHOT_SLOT(index); + InternalFieldInfo* info = + InternalFieldInfoBase::New(type()); + return info; +} + +void BindingData::Deserialize(v8::Local context, + v8::Local holder, + int index, + InternalFieldInfoBase* info) { + DCHECK_IS_SNAPSHOT_SLOT(index); + v8::HandleScope scope(context->GetIsolate()); + Realm* realm = Realm::GetCurrent(context); + BindingData* binding = realm->AddBindingData(holder); + CHECK_NOT_NULL(binding); +} + +// TODO(@anonrig): Add actual package.json file string to output +// depending on JS equivalent of: +// manifest = getOptionValue('--experimental-policy') ? +// require('internal/process/policy').manifest : null; +Local BindingData::PackageConfig::Serialize(Realm* realm) { + auto isolate = realm->isolate(); + const auto ToString = [isolate](std::string_view input) -> Local { + return String::NewFromUtf8( + isolate, input.data(), NewStringType::kNormal, input.size()) + .ToLocalChecked(); + }; + Local values[5] = { + name.has_value() ? ToString(*name) : Undefined(isolate), + main.has_value() ? ToString(*main) : Undefined(isolate), + ToString(type), + imports.has_value() ? ToString(*imports) : Undefined(isolate), + exports.has_value() ? ToString(*exports) : Undefined(isolate)}; + return Array::New(isolate, values, 5); +} + +std::optional BindingData::GetPackageJSON( + Realm* realm, const std::string& path, ErrorContext* error_context) { + auto binding_data = realm->GetBindingData(); + + auto cache_entry = binding_data->package_configs_.find(path); + if (cache_entry != binding_data->package_configs_.end()) { + return cache_entry->second; + } + + std::string input; + if (ReadFileSync(&input, path.c_str()) < 0) { + return std::nullopt; + } + input.reserve(input.size() + simdjson::SIMDJSON_PADDING); + + std::string_view json_string(input); + if (input.size() >= 3 && 0 == memcmp(input.data(), "\xEF\xBB\xBF", 3)) { + json_string.remove_prefix(3); // Skip UTF-8 BOM. + } + + simdjson::ondemand::document document; + simdjson::ondemand::object main_object; + simdjson::error_code error = + binding_data->json_parser.iterate(json_string, json_string.max_size()) + .get(document); + + const auto throw_invalid_package_config = [error_context, path, realm]() { + if (error_context == nullptr) { + THROW_ERR_INVALID_PACKAGE_CONFIG( + realm->isolate(), "Invalid package config %s.", path.data()); + } else if (error_context->base.has_value()) { + auto file_url = ada::parse(error_context->base.value()); + CHECK(file_url); + auto file_path = url::FileURLToPath(realm->env(), *file_url); + CHECK(file_path.has_value()); + THROW_ERR_INVALID_PACKAGE_CONFIG( + realm->isolate(), + "Invalid package config %s while importing \"%s\" from %s.", + path.data(), + error_context->specifier.c_str(), + file_path->c_str()); + } else { + THROW_ERR_INVALID_PACKAGE_CONFIG( + realm->isolate(), "Invalid package config %s.", path.data()); + } + + return std::nullopt; + }; + + if (error || document.get_object().get(main_object)) { + return throw_invalid_package_config(); + } + + simdjson::ondemand::raw_json_string key; + simdjson::ondemand::value value; + std::string_view field_value; + simdjson::ondemand::json_type field_type; + PackageConfig package_config{}; + + for (auto field : main_object) { + // Throw error if getting key or value fails. + if (field.key().get(key) || field.value().get(value)) { + return throw_invalid_package_config(); + } + + if (key == "name") { + // Though there is a key "name" with a corresponding value, + // the value may not be a string or could be an invalid JSON string + if (value.get_string(package_config.name)) { + return throw_invalid_package_config(); + } + } else if (key == "main") { + if (value.get_string(package_config.main)) { + return throw_invalid_package_config(); + } + } else if (key == "exports") { + if (value.type().get(field_type)) { + return throw_invalid_package_config(); + } + switch (field_type) { + case simdjson::ondemand::json_type::object: + case simdjson::ondemand::json_type::array: { + if (value.raw_json().get(field_value)) { + return throw_invalid_package_config(); + } + package_config.exports = std::string(field_value); + break; + } + case simdjson::ondemand::json_type::string: { + if (value.get_string(package_config.exports)) { + return throw_invalid_package_config(); + } + break; + } + default: + break; + } + } else if (key == "imports") { + if (value.type().get(field_type)) { + return throw_invalid_package_config(); + } + switch (field_type) { + case simdjson::ondemand::json_type::array: + case simdjson::ondemand::json_type::object: { + if (value.raw_json().get(field_value)) { + return throw_invalid_package_config(); + } + package_config.imports = std::string(field_value); + break; + } + case simdjson::ondemand::json_type::string: { + if (value.get_string(package_config.imports)) { + return throw_invalid_package_config(); + } + break; + } + default: + break; + } + } else if (key == "type") { + if (value.get_string().get(field_value)) { + return throw_invalid_package_config(); + } + // Only update type if it is "commonjs" or "module" + // The default value is "none" for backward compatibility. + if (field_value == "commonjs" || field_value == "module") { + package_config.type = std::string(field_value); + } + } + } + + binding_data->package_configs_.insert({path, package_config}); + + return package_config; +} + +void BindingData::ReadPackageJSON(const FunctionCallbackInfo& args) { + CHECK_GE(args.Length(), 1); // path, [is_esm, base, specifier] + CHECK(args[0]->IsString()); // path + + Realm* realm = Realm::GetCurrent(args); + auto isolate = realm->isolate(); + + Utf8Value path(isolate, args[0]); + bool is_esm = args[1]->IsTrue(); + auto error_context = ErrorContext(); + if (is_esm) { + CHECK(args[2]->IsUndefined() || args[2]->IsString()); // base + CHECK(args[3]->IsString()); // specifier + + if (args[2]->IsString()) { + Utf8Value base_value(isolate, args[2]); + error_context.base = base_value.ToString(); + } + Utf8Value specifier(isolate, args[3]); + error_context.specifier = specifier.ToString(); + } + + THROW_IF_INSUFFICIENT_PERMISSIONS( + realm->env(), + permission::PermissionScope::kFileSystemRead, + path.ToStringView()); + + auto package_json = + GetPackageJSON(realm, path.ToString(), is_esm ? &error_context : nullptr); + if (!package_json.has_value()) { + return; + } + + args.GetReturnValue().Set(package_json->Serialize(realm)); +} + +void BindingData::GetClosestPackageJSONType( + const FunctionCallbackInfo& args) { + CHECK_GE(args.Length(), 1); + CHECK(args[0]->IsString()); + + Realm* realm = Realm::GetCurrent(args); + Utf8Value path(realm->isolate(), args[0]); + + auto node_modules_suffix = + std::string(kPathSeparator + std::string("node_modules").c_str()); + std::string_view check_path = path.ToStringView(); + size_t root_separator_index = check_path.find_first_of(kPathSeparator); + size_t separator_index; + + do { + separator_index = check_path.find_last_of(kPathSeparator); + check_path = check_path.substr(0, separator_index); + + // We don't need to try "/" + if (check_path.empty()) { + break; + } + + auto check_path_with_sep = + std::string(check_path.substr(0, separator_index)) + kPathSeparator; + + // Stop the search when the process doesn't have permissions + // to walk upwards + if (UNLIKELY(!realm->env()->permission()->is_granted( + permission::PermissionScope::kFileSystemRead, + check_path_with_sep))) { + return; + } + + if (check_path.size() > node_modules_suffix.size() && + strcmp( + check_path.data() + check_path.size() - node_modules_suffix.size(), + node_modules_suffix.data()) == 0) { + return; + } + + // GetPackageJSON will handle caching + auto package_json = + GetPackageJSON(realm, check_path_with_sep + "package.json"); + if (package_json.has_value()) { + args.GetReturnValue().Set( + ToV8Value(realm->context(), package_json->type).ToLocalChecked()); + return; + } + } while (separator_index > root_separator_index); +} + +void BindingData::GetPackageScopeConfig( + const FunctionCallbackInfo& args) { + CHECK_GE(args.Length(), 1); + CHECK(args[0]->IsString()); + + Realm* realm = Realm::GetCurrent(args); + Utf8Value resolved(realm->isolate(), args[0]); + auto package_json_url_base = ada::parse(resolved.ToStringView()); + if (!package_json_url_base) { + url::ThrowInvalidURL(realm->env(), resolved.ToStringView(), std::nullopt); + return; + } + auto package_json_url = + ada::parse("./package.json", &package_json_url_base.value()); + if (!package_json_url) { + url::ThrowInvalidURL(realm->env(), "./package.json", resolved.ToString()); + return; + } + + std::string_view node_modules_package_path = "node_modules/package.json"; + auto error_context = ErrorContext(); + error_context.is_esm = true; + + while (true) { + auto pathname = package_json_url->get_pathname(); + if (pathname.size() >= node_modules_package_path.size() && + pathname.compare(pathname.size() - node_modules_package_path.size(), + node_modules_package_path.size(), + node_modules_package_path) == 0) { + break; + } + + auto file_url = url::FileURLToPath(realm->env(), *package_json_url); + CHECK(file_url); + error_context.specifier = resolved.ToString(); + auto package_json = GetPackageJSON(realm, *file_url, &error_context); + if (package_json.has_value()) { + return args.GetReturnValue().Set(package_json->Serialize(realm)); + } + + auto last_href = std::string(package_json_url->get_href()); + auto last_pathname = std::string(package_json_url->get_pathname()); + package_json_url = ada::parse("../package.json", &package_json_url.value()); + if (!package_json_url) { + url::ThrowInvalidURL(realm->env(), "../package.json", last_href); + return; + } + + // Terminates at root where ../package.json equals ../../package.json + // (can't just check "/package.json" for Windows support). + if (package_json_url->get_pathname() == last_pathname) { + break; + } + } + + auto package_json_url_as_path = + url::FileURLToPath(realm->env(), *package_json_url); + CHECK(package_json_url_as_path); + return args.GetReturnValue().Set( + String::NewFromUtf8(realm->isolate(), + package_json_url_as_path->c_str(), + NewStringType::kNormal, + package_json_url_as_path->size()) + .ToLocalChecked()); +} + +void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data, + Local target) { + Isolate* isolate = isolate_data->isolate(); + SetMethod(isolate, target, "readPackageJSON", ReadPackageJSON); + SetMethod( + isolate, target, "getClosestPackageJSONType", GetClosestPackageJSONType); + SetMethod(isolate, target, "getPackageScopeConfig", GetPackageScopeConfig); +} + +void BindingData::CreatePerContextProperties(Local target, + Local unused, + Local context, + void* priv) { + Realm* realm = Realm::GetCurrent(context); + realm->AddBindingData(target); +} + +void BindingData::RegisterExternalReferences( + ExternalReferenceRegistry* registry) { + registry->Register(ReadPackageJSON); + registry->Register(GetClosestPackageJSONType); + registry->Register(GetPackageScopeConfig); +} + +} // namespace modules +} // namespace node + +NODE_BINDING_CONTEXT_AWARE_INTERNAL( + modules, node::modules::BindingData::CreatePerContextProperties) +NODE_BINDING_PER_ISOLATE_INIT( + modules, node::modules::BindingData::CreatePerIsolateProperties) +NODE_BINDING_EXTERNAL_REFERENCE( + modules, node::modules::BindingData::RegisterExternalReferences) diff --git a/src/node_modules.h b/src/node_modules.h new file mode 100644 index 00000000000000..339be84800148b --- /dev/null +++ b/src/node_modules.h @@ -0,0 +1,83 @@ +#ifndef SRC_NODE_MODULES_H_ +#define SRC_NODE_MODULES_H_ + +#include "v8-function-callback.h" +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#include "node.h" +#include "node_snapshotable.h" +#include "simdjson.h" +#include "util.h" +#include "v8-fast-api-calls.h" +#include "v8.h" + +#include +#include +#include +#include + +namespace node { +class ExternalReferenceRegistry; + +namespace modules { + +class BindingData : public SnapshotableObject { + public: + using InternalFieldInfo = InternalFieldInfoBase; + + struct PackageConfig { + std::optional name; + std::optional main; + std::string type = "none"; + std::optional exports; + std::optional imports; + + v8::Local Serialize(Realm* realm); + }; + + struct ErrorContext { + std::optional base; + std::string specifier; + bool is_esm; + }; + + BindingData(Realm* realm, + v8::Local obj, + InternalFieldInfo* info = nullptr); + SERIALIZABLE_OBJECT_METHODS() + SET_BINDING_ID(modules_binding_data) + + void MemoryInfo(MemoryTracker* tracker) const override; + SET_SELF_SIZE(BindingData) + SET_MEMORY_INFO_NAME(BindingData) + + static void ReadPackageJSON(const v8::FunctionCallbackInfo& args); + static void GetClosestPackageJSONType( + const v8::FunctionCallbackInfo& args); + static void GetPackageScopeConfig( + const v8::FunctionCallbackInfo& args); + + static void CreatePerIsolateProperties(IsolateData* isolate_data, + v8::Local ctor); + static void CreatePerContextProperties(v8::Local target, + v8::Local unused, + v8::Local context, + void* priv); + static void RegisterExternalReferences(ExternalReferenceRegistry* registry); + + private: + std::unordered_map package_configs_; + simdjson::ondemand::parser json_parser; + + static std::optional GetPackageJSON( + Realm* realm, + const std::string& path, + ErrorContext* error_context = nullptr); +}; + +} // namespace modules +} // namespace node + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#endif // SRC_NODE_MODULES_H_ diff --git a/src/node_snapshotable.cc b/src/node_snapshotable.cc index 562a47ddcc9c8e..71d64325765048 100644 --- a/src/node_snapshotable.cc +++ b/src/node_snapshotable.cc @@ -19,6 +19,7 @@ #include "node_internals.h" #include "node_main_instance.h" #include "node_metadata.h" +#include "node_modules.h" #include "node_process.h" #include "node_snapshot_builder.h" #include "node_url.h" diff --git a/src/node_url.cc b/src/node_url.cc index 94510aa1904a00..95d15c78407359 100644 --- a/src/node_url.cc +++ b/src/node_url.cc @@ -229,35 +229,6 @@ void BindingData::Format(const FunctionCallbackInfo& args) { .ToLocalChecked()); } -void BindingData::ThrowInvalidURL(node::Environment* env, - std::string_view input, - std::optional base) { - Local err = ERR_INVALID_URL(env->isolate(), "Invalid URL"); - DCHECK(err->IsObject()); - - auto err_object = err.As(); - - USE(err_object->Set(env->context(), - env->input_string(), - v8::String::NewFromUtf8(env->isolate(), - input.data(), - v8::NewStringType::kNormal, - input.size()) - .ToLocalChecked())); - - if (base.has_value()) { - USE(err_object->Set(env->context(), - env->base_string(), - v8::String::NewFromUtf8(env->isolate(), - base.value().c_str(), - v8::NewStringType::kNormal, - base.value().size()) - .ToLocalChecked())); - } - - env->isolate()->ThrowException(err); -} - void BindingData::Parse(const FunctionCallbackInfo& args) { CHECK_GE(args.Length(), 1); CHECK(args[0]->IsString()); // input @@ -419,6 +390,35 @@ void BindingData::RegisterExternalReferences( } } +void ThrowInvalidURL(node::Environment* env, + std::string_view input, + std::optional base) { + Local err = ERR_INVALID_URL(env->isolate(), "Invalid URL"); + DCHECK(err->IsObject()); + + auto err_object = err.As(); + + USE(err_object->Set(env->context(), + env->input_string(), + v8::String::NewFromUtf8(env->isolate(), + input.data(), + v8::NewStringType::kNormal, + input.size()) + .ToLocalChecked())); + + if (base.has_value()) { + USE(err_object->Set(env->context(), + env->base_string(), + v8::String::NewFromUtf8(env->isolate(), + base.value().c_str(), + v8::NewStringType::kNormal, + base.value().size()) + .ToLocalChecked())); + } + + env->isolate()->ThrowException(err); +} + std::string FromFilePath(std::string_view file_path) { // Avoid unnecessary allocations. size_t pos = file_path.empty() ? std::string_view::npos : file_path.find('%'); diff --git a/src/node_url.h b/src/node_url.h index c106e8245284da..3c77b538b16f8f 100644 --- a/src/node_url.h +++ b/src/node_url.h @@ -77,11 +77,11 @@ class BindingData : public SnapshotableObject { const ada::scheme::type type); static v8::CFunction fast_can_parse_methods_[]; - static void ThrowInvalidURL(Environment* env, - std::string_view input, - std::optional base); }; +void ThrowInvalidURL(Environment* env, + std::string_view input, + std::optional base); std::string FromFilePath(std::string_view file_path); std::optional FileURLToPath(Environment* env, const ada::url_aggregator& file_url); diff --git a/test/es-module/test-esm-invalid-pjson.js b/test/es-module/test-esm-invalid-pjson.js index 9b49f6f1357685..52cef9ea98b40a 100644 --- a/test/es-module/test-esm-invalid-pjson.js +++ b/test/es-module/test-esm-invalid-pjson.js @@ -1,6 +1,6 @@ 'use strict'; -const { checkoutEOL, spawnPromisified } = require('../common'); +const { spawnPromisified } = require('../common'); const fixtures = require('../common/fixtures.js'); const assert = require('node:assert'); const { execPath } = require('node:process'); @@ -14,12 +14,10 @@ describe('ESM: Package.json', { concurrency: true }, () => { const { code, signal, stderr } = await spawnPromisified(execPath, [entry]); + assert.ok(stderr.includes('code: \'ERR_INVALID_PACKAGE_CONFIG\''), stderr); assert.ok( stderr.includes( - `[ERR_INVALID_PACKAGE_CONFIG]: Invalid package config ${invalidJson} ` + - `while importing "invalid-pjson" from ${entry}. ` + - "Expected ':' after property name in JSON at position " + - `${12 + checkoutEOL.length * 2}` + `Invalid package config ${invalidJson} while importing "invalid-pjson" from ${entry}.` ), stderr ); diff --git a/test/fixtures/node_modules/pkgexports-number/package.json b/test/fixtures/node_modules/pkgexports-number/package.json index 315f39a66e32a6..c5807f588ce8f7 100644 --- a/test/fixtures/node_modules/pkgexports-number/package.json +++ b/test/fixtures/node_modules/pkgexports-number/package.json @@ -1,3 +1,3 @@ { - "exports": 42 + "exports": {} } diff --git a/test/parallel/test-bootstrap-modules.js b/test/parallel/test-bootstrap-modules.js index e4561d746606ed..e123c190329ba6 100644 --- a/test/parallel/test-bootstrap-modules.js +++ b/test/parallel/test-bootstrap-modules.js @@ -11,6 +11,7 @@ const assert = require('assert'); const expectedModules = new Set([ 'Internal Binding builtins', 'Internal Binding encoding_binding', + 'Internal Binding modules', 'Internal Binding errors', 'Internal Binding util', 'NativeModule internal/errors', diff --git a/test/parallel/test-module-binding.js b/test/parallel/test-module-binding.js deleted file mode 100644 index d7f76d6ef5b153..00000000000000 --- a/test/parallel/test-module-binding.js +++ /dev/null @@ -1,29 +0,0 @@ -// Flags: --expose-internals -'use strict'; -require('../common'); -const fixtures = require('../common/fixtures'); -const { internalBinding } = require('internal/test/binding'); -const { filterOwnProperties } = require('internal/util'); -const { internalModuleReadJSON } = internalBinding('fs'); -const { readFileSync } = require('fs'); -const { strictEqual, deepStrictEqual } = require('assert'); - -{ - strictEqual(internalModuleReadJSON('nosuchfile'), undefined); -} -{ - strictEqual(internalModuleReadJSON(fixtures.path('empty.txt')), ''); -} -{ - strictEqual(internalModuleReadJSON(fixtures.path('empty-with-bom.txt')), ''); -} -{ - const filename = fixtures.path('require-bin/package.json'); - const returnValue = JSON.parse(internalModuleReadJSON(filename)); - const file = JSON.parse(readFileSync(filename, 'utf-8')); - const expectedValue = filterOwnProperties(file, ['name', 'main', 'exports', 'imports', 'type']); - deepStrictEqual({ - __proto__: null, - ...returnValue, - }, expectedValue); -} diff --git a/typings/globals.d.ts b/typings/globals.d.ts index d72bf937bb75c9..39df64f7ec5bf4 100644 --- a/typings/globals.d.ts +++ b/typings/globals.d.ts @@ -14,6 +14,7 @@ import {TypesBinding} from "./internalBinding/types"; import {URLBinding} from "./internalBinding/url"; import {UtilBinding} from "./internalBinding/util"; import {WorkerBinding} from "./internalBinding/worker"; +import {ModulesBinding} from "./internalBinding/modules"; declare type TypedArray = | Uint8Array @@ -36,6 +37,7 @@ interface InternalBindingMap { fs: FsBinding; http_parser: HttpParserBinding; messaging: MessagingBinding; + modules: ModulesBinding; options: OptionsBinding; os: OSBinding; serdes: SerdesBinding; diff --git a/typings/internalBinding/fs.d.ts b/typings/internalBinding/fs.d.ts index 77f20e9550e30a..66cf7132e6826e 100644 --- a/typings/internalBinding/fs.d.ts +++ b/typings/internalBinding/fs.d.ts @@ -111,7 +111,6 @@ declare namespace InternalFSBinding { function futimes(fd: number, atime: number, mtime: number): void; function futimes(fd: number, atime: number, mtime: number, usePromises: typeof kUsePromises): Promise; - function internalModuleReadJSON(path: string): [] | [string, boolean]; function internalModuleStat(path: string): number; function lchown(path: string, uid: number, gid: number, req: FSReqCallback): void; diff --git a/typings/internalBinding/modules.d.ts b/typings/internalBinding/modules.d.ts new file mode 100644 index 00000000000000..c5602458f10bfc --- /dev/null +++ b/typings/internalBinding/modules.d.ts @@ -0,0 +1,22 @@ +export type PackageType = 'commonjs' | 'module' | 'none' +export type PackageConfig = { + exists: boolean + name?: string + main?: any + type: PackageType + exports?: string | string[] | Record + imports?: string | string[] | Record +} +export type SerializedPackageConfig = [ + PackageConfig['name'], + PackageConfig['main'], + PackageConfig['type'], + string | undefined, // exports + string | undefined, // imports +] + +export interface ModulesBinding { + readPackageJSON(path: string): SerializedPackageConfig | undefined; + getClosestPackageJSONType(path: string): PackageConfig['type'] + getPackageScopeConfig(path: string): SerializedPackageConfig | undefined +}