diff --git a/doc/api/errors.md b/doc/api/errors.md index 61ba0d9e4a91d4..02af1e50afd210 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -3041,7 +3041,8 @@ import 'package-name'; // supported added: v22.6.0 --> -Type stripping is not supported for files descendent of a `node_modules` directory. +Type stripping is not supported for files descendent of a `node_modules` directory, +where package.json is missing or does not contain the property `"private": true`. diff --git a/doc/api/typescript.md b/doc/api/typescript.md index bad8f44001c04e..0f376f97831bdc 100644 --- a/doc/api/typescript.md +++ b/doc/api/typescript.md @@ -155,7 +155,7 @@ are enabled by default. To discourage package authors from publishing packages written in TypeScript, Node.js will by default refuse to handle TypeScript files inside folders under -a `node_modules` path. +a `node_modules` path, unless the package.json contains the property `"private": true`. [CommonJS]: modules.md [ES Modules]: esm.md diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 8a97384201341f..696e0c48f57f63 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1839,7 +1839,8 @@ E('ERR_UNSUPPORTED_ESM_URL_SCHEME', (url, supported) => { return msg; }, Error); E('ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING', - 'Stripping types is currently unsupported for files under node_modules, for "%s"', + 'Stripping types is currently unsupported for files under node_modules that ' + + 'do not contain a package.json with the property private: true, for "%s"', Error); E('ERR_UNSUPPORTED_RESOLVE_REQUEST', 'Failed to resolve module specifier "%s" from "%s": Invalid relative URL or base scheme is not hierarchical.', diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index ef94dd768fbd52..013f2795fe9579 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -124,7 +124,6 @@ const { pathToFileURL, fileURLToPath, isURL } = require('internal/url'); const { pendingDeprecate, emitExperimentalWarning, - isUnderNodeModules, kEmptyObject, setOwnProperty, getLazy, @@ -170,7 +169,6 @@ const { ERR_REQUIRE_CYCLE_MODULE, ERR_REQUIRE_ESM, ERR_UNKNOWN_BUILTIN_MODULE, - ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, }, setArrowMessage, } = require('internal/errors'); @@ -1370,9 +1368,6 @@ let hasPausedEntry = false; function loadESMFromCJS(mod, filename) { let source = getMaybeCachedSource(mod, filename); if (getOptionValue('--experimental-strip-types') && path.extname(filename) === '.mts') { - if (isUnderNodeModules(filename)) { - throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename); - } source = stripTypeScriptTypes(source, filename); } const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader(); @@ -1594,9 +1589,6 @@ function getMaybeCachedSource(mod, filename) { } function loadCTS(module, filename) { - if (isUnderNodeModules(filename)) { - throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename); - } const source = getMaybeCachedSource(module, filename); const code = stripTypeScriptTypes(source, filename); module._compile(code, filename, 'commonjs'); @@ -1608,14 +1600,12 @@ function loadCTS(module, filename) { * @param {string} filename The file path of the module */ function loadTS(module, filename) { - if (isUnderNodeModules(filename)) { - throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename); - } // If already analyzed the source, then it will be cached. const source = getMaybeCachedSource(module, filename); - const content = stripTypeScriptTypes(source, filename); let format; const pkg = packageJsonReader.getNearestParentPackageJSON(filename); + const isPrivatePackageJSON = pkg?.data.private; + const content = stripTypeScriptTypes(source, filename, isPrivatePackageJSON); // Function require shouldn't be used in ES modules. if (pkg?.data.type === 'module') { if (getOptionValue('--experimental-require-module')) { @@ -1633,7 +1623,7 @@ function loadTS(module, filename) { if (Module._cache[parentPath]) { let parentSource; try { - parentSource = stripTypeScriptTypes(fs.readFileSync(parentPath, 'utf8'), parentPath); + parentSource = stripTypeScriptTypes(fs.readFileSync(parentPath, 'utf8'), parentPath, isPrivatePackageJSON); } catch { // Continue regardless of error. } diff --git a/lib/internal/modules/esm/load.js b/lib/internal/modules/esm/load.js index d56dae3f001b1c..5ba13096b98047 100644 --- a/lib/internal/modules/esm/load.js +++ b/lib/internal/modules/esm/load.js @@ -4,7 +4,6 @@ const { RegExpPrototypeExec, } = primordials; const { - isUnderNodeModules, kEmptyObject, } = require('internal/util'); @@ -23,7 +22,6 @@ const { ERR_INVALID_URL, ERR_UNKNOWN_MODULE_FORMAT, ERR_UNSUPPORTED_ESM_URL_SCHEME, - ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, } = require('internal/errors').codes; const { @@ -131,12 +129,6 @@ async function defaultLoad(url, context = kEmptyObject) { validateAttributes(url, format, importAttributes); - if (getOptionValue('--experimental-strip-types') && - (format === 'module-typescript' || format === 'commonjs-typescript') && - isUnderNodeModules(url)) { - throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(url); - } - return { __proto__: null, format, diff --git a/lib/internal/modules/helpers.js b/lib/internal/modules/helpers.js index 172f0fdc02a686..2f7fcc6bfae39a 100644 --- a/lib/internal/modules/helpers.js +++ b/lib/internal/modules/helpers.js @@ -16,6 +16,7 @@ const { ERR_INVALID_ARG_TYPE, ERR_INVALID_RETURN_PROPERTY_VALUE, ERR_INVALID_TYPESCRIPT_SYNTAX, + ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, } = require('internal/errors').codes; const { BuiltinModule } = require('internal/bootstrap/realm'); @@ -28,7 +29,7 @@ const assert = require('internal/assert'); const { Buffer } = require('buffer'); const { getOptionValue } = require('internal/options'); -const { assertTypeScript, setOwnProperty, getLazy } = require('internal/util'); +const { assertTypeScript, setOwnProperty, getLazy, isUnderNodeModules } = require('internal/util'); const { inspect } = require('internal/util/inspect'); const lazyTmpdir = getLazy(() => require('os').tmpdir()); @@ -347,6 +348,21 @@ function parseTypeScript(source, options) { } } +/** + * + * @param {string} filename + * @returns {boolean} Returns true if the nearest package.json is private. + */ +function isPrivateNodeModule(filename) { + const packageJsonReader = require('internal/modules/package_json_reader'); + const resolved = StringPrototypeStartsWith(filename, 'file://') ? filename : pathToFileURL(filename).href; + const packageConfig = packageJsonReader.getPackageScopeConfig(resolved); + if (!packageConfig?.exists) { + return false; + } + return packageConfig.isPrivate === true; +} + /** * @typedef {object} TransformOutput * @property {string} code The compiled code. @@ -355,9 +371,16 @@ function parseTypeScript(source, options) { * Performs type-stripping to TypeScript source code. * @param {string} source TypeScript code to parse. * @param {string} filename The filename of the source code. + * @param {string} isPackageJsonPrivate Whether the nearest package.json is private. * @returns {TransformOutput} The stripped TypeScript code. */ -function stripTypeScriptTypes(source, filename) { +function stripTypeScriptTypes(source, filename, isPackageJsonPrivate = false) { + // If the package.json is private, we can + // allow the stripping of types because it is + // not possible to publish it on npm. + if (isUnderNodeModules(filename) && isPackageJsonPrivate !== true && !isPrivateNodeModule(filename)) { + throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename); + } assert(typeof source === 'string'); const options = { __proto__: null, diff --git a/lib/internal/modules/package_json_reader.js b/lib/internal/modules/package_json_reader.js index 9a9dcebb799c00..5225a5c7a4f707 100644 --- a/lib/internal/modules/package_json_reader.js +++ b/lib/internal/modules/package_json_reader.js @@ -34,6 +34,7 @@ function deserializePackageJSON(path, contents) { 3: plainImports, 4: plainExports, 5: optionalFilePath, + 6: isPrivate, } = contents; // This is required to be used in getPackageScopeConfig. @@ -64,6 +65,7 @@ function deserializePackageJSON(path, contents) { ObjectDefineProperty(this, 'exports', { __proto__: null, value }); return this.exports; }, + isPrivate, }; } diff --git a/src/node_modules.cc b/src/node_modules.cc index dfd115a9eccc6b..456c9f6f379f38 100644 --- a/src/node_modules.cc +++ b/src/node_modules.cc @@ -20,6 +20,7 @@ namespace node { namespace modules { using v8::Array; +using v8::Boolean; using v8::Context; using v8::FunctionCallbackInfo; using v8::HandleScope; @@ -74,15 +75,16 @@ Local BindingData::PackageConfig::Serialize(Realm* realm) const { isolate, input.data(), NewStringType::kNormal, input.size()) .ToLocalChecked(); }; - Local values[6] = { + Local values[7] = { 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), ToString(file_path), + Boolean::New(isolate, is_private), }; - return Array::New(isolate, values, 6); + return Array::New(isolate, values, 7); } const BindingData::PackageConfig* BindingData::GetPackageJSON( @@ -225,6 +227,10 @@ const BindingData::PackageConfig* BindingData::GetPackageJSON( default: break; } + } else if (key == "private") { + if (value.get_bool().get(package_config.is_private)) { + return throw_invalid_package_config(); + } } } // package_config could be quite large, so we should move it instead of diff --git a/src/node_modules.h b/src/node_modules.h index 17909b2270454b..34bbc4425adc07 100644 --- a/src/node_modules.h +++ b/src/node_modules.h @@ -34,6 +34,7 @@ class BindingData : public SnapshotableObject { std::optional imports; std::optional scripts; std::string raw_json; + bool is_private; v8::Local Serialize(Realm* realm) const; }; diff --git a/test/es-module/test-typescript.mjs b/test/es-module/test-typescript.mjs index a7ca6d70dd5e10..68ac23f80357c1 100644 --- a/test/es-module/test-typescript.mjs +++ b/test/es-module/test-typescript.mjs @@ -414,3 +414,15 @@ test('expect error when executing a TypeScript file with generics', async () => strictEqual(result.stdout, ''); strictEqual(result.code, 1); }); + +test('execute TypeScript file from node_modules with private: true', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-strip-types', + '--no-warnings', + fixtures.path('typescript/ts/test-import-private-ts-node-module.ts'), + ]); + + strictEqual(result.stderr, ''); + match(result.stdout, /Hello, TypeScript!/); + strictEqual(result.code, 0); +}); diff --git a/test/fixtures/typescript/ts/node_modules/bar/package.json b/test/fixtures/typescript/ts/node_modules/bar/package.json index ff1ab7524e4743..1ecd5a3cdb5b47 100644 --- a/test/fixtures/typescript/ts/node_modules/bar/package.json +++ b/test/fixtures/typescript/ts/node_modules/bar/package.json @@ -2,6 +2,7 @@ "name": "bar", "version": "1.0.0", "main": "bar.ts", + "private": false, "devDependencies": {}, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" diff --git a/test/fixtures/typescript/ts/node_modules/private/package.json b/test/fixtures/typescript/ts/node_modules/private/package.json new file mode 100644 index 00000000000000..d66386a10d9247 --- /dev/null +++ b/test/fixtures/typescript/ts/node_modules/private/package.json @@ -0,0 +1,15 @@ +{ + "name": "private", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "private.ts", + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "" +} diff --git a/test/fixtures/typescript/ts/node_modules/private/private.ts b/test/fixtures/typescript/ts/node_modules/private/private.ts new file mode 100644 index 00000000000000..dfa706d4009a2b --- /dev/null +++ b/test/fixtures/typescript/ts/node_modules/private/private.ts @@ -0,0 +1 @@ +export const foo: string = "Hello, TypeScript!" diff --git a/test/fixtures/typescript/ts/test-import-private-ts-node-module.ts b/test/fixtures/typescript/ts/test-import-private-ts-node-module.ts new file mode 100644 index 00000000000000..087bd29a092318 --- /dev/null +++ b/test/fixtures/typescript/ts/test-import-private-ts-node-module.ts @@ -0,0 +1,5 @@ +import { foo } from 'private'; + +interface Bar { }; + +console.log(foo); diff --git a/typings/internalBinding/modules.d.ts b/typings/internalBinding/modules.d.ts index 8142874edfde88..80c63bedbbc0ef 100644 --- a/typings/internalBinding/modules.d.ts +++ b/typings/internalBinding/modules.d.ts @@ -6,7 +6,8 @@ export type PackageConfig = { main?: any type: PackageType exports?: string | string[] | Record - imports?: string | string[] | Record + imports?: string | string[] | Record, + isPrivate?: boolean } export type SerializedPackageConfig = [ PackageConfig['name'], @@ -15,6 +16,7 @@ export type SerializedPackageConfig = [ string | undefined, // exports string | undefined, // imports string | undefined, // raw json available for experimental policy + boolean | undefined // isPrivate ] export interface ModulesBinding {