Skip to content

Commit 2052d0d

Browse files
module: add --experimental-strip-private-modules
Signed-off-by: Marco Ippolito <marcoippolito54@gmail.com> PR-URL: #63869 Reviewed-By: Paolo Insogna <paolo@cowtech.it> Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Filip Skokan <panva.ip@gmail.com> Reviewed-By: Pietro Marchini <pietro.marchini94@gmail.com>
1 parent 135b70e commit 2052d0d

36 files changed

Lines changed: 470 additions & 3 deletions

‎doc/api/cli.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1647,6 +1647,25 @@ added:
16471647
16481648
Enable the experimental [`node:stream/iter`][] module.
16491649

1650+
### `--experimental-strip-private-modules`
1651+
1652+
<!-- YAML
1653+
added: REPLACEME
1654+
-->
1655+
1656+
> Stability: 1 - Experimental
1657+
1658+
By default, Node.js refuses to strip types from TypeScript files inside
1659+
folders under a `node_modules` path. This flag enables type stripping for
1660+
such files when they belong to a package whose `package.json` contains
1661+
`"private": true`. Files belonging to non-private packages still throw
1662+
[`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`][].
1663+
The limitation to private packages should never be lifted,
1664+
as publishing packages with TypeScript sources
1665+
is highly discouraged.
1666+
Stripping types inside `node_modules` is intended only
1667+
for local development.
1668+
16501669
### `--experimental-test-coverage`
16511670

16521671
<!-- YAML
@@ -4287,6 +4306,7 @@ one is included in the list below.
42874306
* `--experimental-shadow-realm`
42884307
* `--experimental-specifier-resolution`
42894308
* `--experimental-stream-iter`
4309+
* `--experimental-strip-private-modules`
42904310
* `--experimental-test-isolation`
42914311
* `--experimental-top-level-await`
42924312
* `--experimental-vm-modules`
@@ -4941,6 +4961,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
49414961
[`CRYPTO_secure_malloc_init`]: https://www.openssl.org/docs/man3.0/man3/CRYPTO_secure_malloc_init.html
49424962
[`ERR_INVALID_TYPESCRIPT_SYNTAX`]: errors.md#err_invalid_typescript_syntax
49434963
[`ERR_RX_MEMORY_NOT_SUPPORTED`]: errors.md#err_rx_memory_not_supported
4964+
[`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`]: errors.md#err_unsupported_node_modules_type_stripping
49444965
[`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`]: errors.md#err_unsupported_typescript_syntax
49454966
[`ERR_WEBASSEMBLY_NOT_SUPPORTED`]: errors.md#err_webassembly_not_supported
49464967
[`NODE_OPTIONS`]: #node_optionsoptions

‎doc/api/packages.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,6 +1153,9 @@ The following fields in `package.json` files are used in Node.js:
11531153
limits which submodules can be loaded from within the package.
11541154
* [`"imports"`][] - Package imports, for use by modules within the package
11551155
itself.
1156+
* [`"private"`][] - When `true`, marks the package as not for publication. Used
1157+
by [`--experimental-strip-private-modules`][] to allow type stripping for the
1158+
package's TypeScript files under `node_modules`.
11561159

11571160
### `"name"`
11581161

@@ -1348,6 +1351,33 @@ Package imports permit mapping to external packages.
13481351

13491352
This field defines [subpath imports][] for the current package.
13501353

1354+
### `"private"`
1355+
1356+
<!-- YAML
1357+
added: REPLACEME
1358+
-->
1359+
1360+
* Type: {boolean}
1361+
1362+
```json
1363+
{
1364+
"private": true
1365+
}
1366+
```
1367+
1368+
The `"private"` field marks a package as private, signaling to package managers
1369+
such as _npm_ that it must not be published to a public registry.
1370+
By default, Node.js refuses to strip types from TypeScript files inside
1371+
`node_modules`. When [`--experimental-strip-private-modules`][] is enabled,
1372+
Node.js allows type stripping for:
1373+
1374+
* files that are not inside a `node_modules` directory.
1375+
* files inside `node_modules`, but only when the package that contains the
1376+
file sets `"private": true`. Only the `package.json` at each package boundary
1377+
is considered: the package's root directory, which is the directory directly
1378+
under `node_modules` (or under `node_modules/<@scope>` for scoped packages).
1379+
Nested `package.json` files are ignored.
1380+
13511381
[CommonJS]: modules.md
13521382
[Conditional exports]: #conditional-exports
13531383
[ES module]: esm.md
@@ -1362,10 +1392,12 @@ This field defines [subpath imports][] for the current package.
13621392
[`"imports"`]: #imports
13631393
[`"main"`]: #main
13641394
[`"name"`]: #name
1395+
[`"private"`]: #private
13651396
[`"type"`]: #type
13661397
[`--conditions` / `-C` flag]: #resolving-user-conditions
13671398
[`--experimental-addon-modules`]: cli.md#--experimental-addon-modules
13681399
[`--experimental-package-map`]: cli.md#--experimental-package-mappath
1400+
[`--experimental-strip-private-modules`]: cli.md#--experimental-strip-private-modules
13691401
[`--no-addons` flag]: cli.md#--no-addons
13701402
[`ERR_PACKAGE_MAP_EXTERNAL_FILE`]: errors.md#err_package_map_external_file
13711403
[`ERR_PACKAGE_PATH_NOT_EXPORTED`]: errors.md#err_package_path_not_exported

‎doc/api/typescript.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,15 @@ To discourage package authors from publishing packages written in TypeScript,
222222
Node.js refuses to handle TypeScript files inside folders under a `node_modules`
223223
path.
224224

225+
The [`--experimental-strip-private-modules`][] flag relaxes this restriction
226+
for packages whose `package.json` contains `"private": true`, such as
227+
workspace packages in a monorepo, which are not published to a registry.
228+
The limitation to private packages should never be lifted,
229+
as publishing packages with TypeScript sources
230+
is highly discouraged.
231+
Stripping types inside `node_modules` is intended only
232+
for local development.
233+
225234
### Paths aliases
226235

227236
[`tsconfig` "paths"][] won't be transformed and therefore produce an error. The closest
@@ -231,6 +240,7 @@ with `#`.
231240
[CommonJS]: modules.md
232241
[ES Modules]: esm.md
233242
[Full TypeScript support]: #full-typescript-support
243+
[`--experimental-strip-private-modules`]: cli.md#--experimental-strip-private-modules
234244
[`--no-strip-types`]: cli.md#--no-strip-types
235245
[`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`]: errors.md#err_unsupported_typescript_syntax
236246
[`tsconfig` "paths"]: https://www.typescriptlang.org/tsconfig/#paths

‎doc/node-config-schema.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,10 @@
367367
"type": "boolean",
368368
"description": "experimental iterable streams API (node:stream/iter)"
369369
},
370+
"experimental-strip-private-modules": {
371+
"type": "boolean",
372+
"description": "Type-stripping for TypeScript files under node_modules belonging to packages marked \"private\": true"
373+
},
370374
"experimental-vm-modules": {
371375
"type": "boolean",
372376
"description": "experimental ES Module support in vm module"

‎doc/node.1‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,18 @@ Enable experimental support for storage inspection
909909
.It Fl -experimental-stream-iter
910910
Enable the experimental \fBnode:stream/iter\fR module.
911911
.
912+
.It Fl -experimental-strip-private-modules
913+
By default, Node.js refuses to strip types from TypeScript files inside
914+
folders under a \fBnode_modules\fR path. This flag enables type stripping for
915+
such files when they belong to a package whose \fBpackage.json\fR contains
916+
\fB"private": true\fR. Files belonging to non-private packages still throw
917+
\fBERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING\fR.
918+
The limitation to private packages should never be lifted,
919+
as publishing packages with TypeScript sources
920+
is highly discouraged.
921+
Stripping types inside \fBnode_modules\fR is intended only
922+
for local development.
923+
.
912924
.It Fl -experimental-test-coverage
913925
When used in conjunction with the \fBnode:test\fR module, a code coverage report is
914926
generated as part of the test runner output. If no tests are run, a coverage
@@ -2273,6 +2285,8 @@ one is included in the list below.
22732285
.It
22742286
\fB--experimental-stream-iter\fR
22752287
.It
2288+
\fB--experimental-strip-private-modules\fR
2289+
.It
22762290
\fB--experimental-test-isolation\fR
22772291
.It
22782292
\fB--experimental-top-level-await\fR

‎lib/internal/modules/helpers.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ const nativeLoaderMethods = {
8585
modulesBinding.readPackageJSON(jsonPath, isESM, base, specifier),
8686
getNearestParentPackageJSON: (checkPath) =>
8787
modulesBinding.getNearestParentPackageJSON(checkPath),
88+
getRootPackageJSON: (checkPath) =>
89+
modulesBinding.getRootPackageJSON(checkPath),
8890
getNearestParentPackageJSONType: (checkPath) =>
8991
modulesBinding.getNearestParentPackageJSONType(checkPath),
9092
getPackageScopeConfig: (resolved) =>

‎lib/internal/modules/package_json_reader.js‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ function deserializePackageJSON(path, contents) {
6464
3: plainImports,
6565
4: plainExports,
6666
5: optionalFilePath,
67+
6: isPrivate,
6768
} = contents;
6869

6970
const pjsonPath = optionalFilePath ?? path;
@@ -73,6 +74,7 @@ function deserializePackageJSON(path, contents) {
7374
...(name != null && { name }),
7475
...(main != null && { main }),
7576
...(type != null && { type }),
77+
...(isPrivate != null && { private: isPrivate }),
7678
};
7779

7880
if (plainExports !== null) {
@@ -217,6 +219,40 @@ function getNearestParentPackageJSON(checkPath) {
217219
return deserializedPackageJSONCache.get(parentPackageJSONPath);
218220
}
219221

222+
const moduleToRootPackageJSONCache = new SafeMap();
223+
224+
/**
225+
* This is stricter than {@link getNearestParentPackageJSON}: nested
226+
* `package.json` files inside a package are ignored, and a non-private package
227+
* anywhere in the `node_modules` chain (for example a publishable package that
228+
* bundles a private dependency) disqualifies the path. A directory under
229+
* `node_modules` with no `package.json` (such as pnpm's `.pnpm` store) is not
230+
* a package and is skipped.
231+
*
232+
* Returns the package.json data and the path to the package.json file, or undefined.
233+
* @param {string} checkPath The path to start searching from.
234+
* @returns {undefined | DeserializedPackageConfig}
235+
*/
236+
function getRootPackageJSON(checkPath) {
237+
const packageJSONPath = moduleToRootPackageJSONCache.get(checkPath);
238+
if (packageJSONPath !== undefined) {
239+
return deserializedPackageJSONCache.get(packageJSONPath);
240+
}
241+
242+
const result = loaderMethods.getRootPackageJSON(checkPath);
243+
const packageConfig = deserializePackageJSON(checkPath, result);
244+
245+
moduleToRootPackageJSONCache.set(checkPath, packageConfig.path);
246+
247+
const maybeCachedPackageConfig = deserializedPackageJSONCache.get(packageConfig.path);
248+
if (maybeCachedPackageConfig !== undefined) {
249+
return maybeCachedPackageConfig;
250+
}
251+
252+
deserializedPackageJSONCache.set(packageConfig.path, packageConfig);
253+
return packageConfig;
254+
}
255+
220256
/**
221257
* Returns the package configuration for the given resolved URL.
222258
* @param {URL | string} resolved - The resolved URL.
@@ -412,6 +448,7 @@ function purgePackageJSONCacheForPrefix(mountPoint) {
412448
module.exports = {
413449
read,
414450
getNearestParentPackageJSON,
451+
getRootPackageJSON,
415452
getPackageScopeConfig,
416453
getPackageType,
417454
getPackageJSONURL,

‎lib/internal/modules/typescript.js‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const {
1818
ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING,
1919
ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX,
2020
} = require('internal/errors').codes;
21+
const { getOptionValue } = require('internal/options');
2122
const assert = require('internal/assert');
2223
const {
2324
getCompileCacheEntry,
@@ -141,6 +142,35 @@ function stripTypeScriptTypesForCoverage(code) {
141142
}
142143

143144

145+
/**
146+
* Whether type-stripping is allowed for files under node_modules belonging
147+
* to packages whose package.json contains `"private": true`.
148+
* @returns {boolean}
149+
*/
150+
const isStripPrivateModulesEnabled = getLazy(
151+
() => getOptionValue('--experimental-strip-private-modules'),
152+
);
153+
154+
/**
155+
* Checks whether a file under node_modules is owned by a private package, in
156+
* which case type stripping is allowed under `--experimental-strip-private-modules`.
157+
*
158+
* Every package enclosing the file inside `node_modules` must be marked
159+
* `"private": true`. npm refuses to publish a private package, so requiring
160+
* the package root (not a nested package.json, which is published verbatim)
161+
* and rejecting any non-private enclosing package (e.g. a publishable package
162+
* bundling a private dependency) prevents published packages from shipping
163+
* strippable TypeScript. See {@link getRootPackageJSON}.
164+
* @param {string} filename The filename (path or file URL) of the source code.
165+
* @returns {boolean} Whether the file belongs to a package with `"private": true`.
166+
*/
167+
function isInsidePrivatePackage(filename) {
168+
const { getRootPackageJSON } = require('internal/modules/package_json_reader');
169+
const { urlToFilename } = require('internal/modules/helpers');
170+
const packageJSON = getRootPackageJSON(urlToFilename(filename));
171+
return packageJSON?.data.private === true;
172+
}
173+
144174
/**
145175
* Performs type-stripping to TypeScript source code internally.
146176
* It is used by internal loaders.
@@ -152,7 +182,10 @@ function stripTypeScriptTypesForCoverage(code) {
152182
function stripTypeScriptModuleTypes(source, filename, sourceURL) {
153183
assert(typeof source === 'string');
154184
if (isUnderNodeModules(filename)) {
155-
throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
185+
if (!isStripPrivateModulesEnabled() || !isInsidePrivatePackage(filename)) {
186+
throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
187+
}
188+
emitExperimentalWarning('Type stripping in node_modules');
156189
}
157190
// Get a compile cache entry into the native compile cache store,
158191
// keyed by the filename. If the cache can already be loaded on disk,

0 commit comments

Comments
 (0)