Skip to content

Commit 49e60ef

Browse files
module: add --experimental-strip-private-modules
Signed-off-by: Marco Ippolito <marcoippolito54@gmail.com>
1 parent 141a504 commit 49e60ef

21 files changed

Lines changed: 193 additions & 3 deletions

doc/api/cli.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,6 +1386,22 @@ added:
13861386
13871387
Enable the experimental [`node:stream/iter`][] module.
13881388

1389+
### `--experimental-strip-private-modules`
1390+
1391+
<!-- YAML
1392+
added: REPLACEME
1393+
-->
1394+
1395+
> Stability: 1 - Experimental
1396+
1397+
By default, Node.js refuses to strip types from TypeScript files inside
1398+
folders under a `node_modules` path. This flag enables type stripping for
1399+
such files when they belong to a package whose `package.json` contains
1400+
`"private": true`. Files belonging to non-private packages still throw
1401+
[`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`][].
1402+
This flag will never be enabled by default, as publishing packages with TypeScript sources
1403+
is highly discouraged, but it can be useful for local development in monorepos.
1404+
13891405
### `--experimental-test-coverage`
13901406

13911407
<!-- YAML
@@ -3794,6 +3810,7 @@ one is included in the list below.
37943810
* `--experimental-shadow-realm`
37953811
* `--experimental-specifier-resolution`
37963812
* `--experimental-stream-iter`
3813+
* `--experimental-strip-private-modules`
37973814
* `--experimental-test-isolation`
37983815
* `--experimental-top-level-await`
37993816
* `--experimental-vfs`
@@ -4424,6 +4441,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
44244441
[`Buffer`]: buffer.md#class-buffer
44254442
[`CRYPTO_secure_malloc_init`]: https://www.openssl.org/docs/man3.0/man3/CRYPTO_secure_malloc_init.html
44264443
[`ERR_INVALID_TYPESCRIPT_SYNTAX`]: errors.md#err_invalid_typescript_syntax
4444+
[`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`]: errors.md#err_unsupported_node_modules_type_stripping
44274445
[`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`]: errors.md#err_unsupported_typescript_syntax
44284446
[`NODE_OPTIONS`]: #node_optionsoptions
44294447
[`NODE_USE_ENV_PROXY=1`]: #node_use_env_proxy1

doc/api/typescript.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,12 @@ To discourage package authors from publishing packages written in TypeScript,
217217
Node.js refuses to handle TypeScript files inside folders under a `node_modules`
218218
path.
219219

220+
The [`--experimental-strip-private-modules`][] flag relaxes this restriction
221+
for packages whose `package.json` contains `"private": true`, such as
222+
workspace packages in a monorepo, which are not published to a registry.
223+
This flag will never be enabled by default, as publishing packages with TypeScript sources
224+
is highly discouraged, but it can be useful for local development in monorepos.
225+
220226
### Paths aliases
221227

222228
[`tsconfig` "paths"][] won't be transformed and therefore produce an error. The closest
@@ -226,6 +232,7 @@ with `#`.
226232
[CommonJS]: modules.md
227233
[ES Modules]: esm.md
228234
[Full TypeScript support]: #full-typescript-support
235+
[`--experimental-strip-private-modules`]: cli.md#--experimental-strip-private-modules
229236
[`--no-strip-types`]: cli.md#--no-strip-types
230237
[`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`]: errors.md#err_unsupported_typescript_syntax
231238
[`tsconfig` "paths"]: https://www.typescriptlang.org/tsconfig/#paths

doc/node.1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,10 @@ Use this flag to enable ShadowRealm support.
774774
.It Fl -experimental-storage-inspection
775775
Enable experimental support for storage inspection
776776
.
777+
.It Fl -experimental-strip-private-modules
778+
Enable type-stripping for TypeScript files under node_modules belonging to
779+
packages whose package.json contains \fB"private": true\fR.
780+
.
777781
.It Fl -experimental-test-coverage
778782
When used in conjunction with the \fBnode:test\fR module, a code coverage report is
779783
generated as part of the test runner output. If no tests are run, a coverage

lib/internal/modules/package_json_reader.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ function deserializePackageJSON(path, contents) {
6060
3: plainImports,
6161
4: plainExports,
6262
5: optionalFilePath,
63+
6: isPrivate,
6364
} = contents;
6465

6566
const pjsonPath = optionalFilePath ?? path;
@@ -69,6 +70,7 @@ function deserializePackageJSON(path, contents) {
6970
...(name != null && { name }),
7071
...(main != null && { main }),
7172
...(type != null && { type }),
73+
...(isPrivate != null && { private: isPrivate }),
7274
};
7375

7476
if (plainExports !== null) {

lib/internal/modules/typescript.js

Lines changed: 27 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,28 @@ 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 if the nearest parent package.json of a file marks its package
156+
* as private.
157+
* @param {string} filename The filename (path or file URL) of the source code.
158+
* @returns {boolean} Whether the file belongs to a package with `"private": true`.
159+
*/
160+
function isInsidePrivatePackage(filename) {
161+
const { getNearestParentPackageJSON } = require('internal/modules/package_json_reader');
162+
const { urlToFilename } = require('internal/modules/helpers');
163+
const packageJSON = getNearestParentPackageJSON(urlToFilename(filename));
164+
return packageJSON?.data.private === true;
165+
}
166+
144167
/**
145168
* Performs type-stripping to TypeScript source code internally.
146169
* It is used by internal loaders.
@@ -152,7 +175,10 @@ function stripTypeScriptTypesForCoverage(code) {
152175
function stripTypeScriptModuleTypes(source, filename, sourceURL) {
153176
assert(typeof source === 'string');
154177
if (isUnderNodeModules(filename)) {
155-
throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
178+
if (!isStripPrivateModulesEnabled() || !isInsidePrivatePackage(filename)) {
179+
throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
180+
}
181+
emitExperimentalWarning('Type stripping in node_modules');
156182
}
157183
// Get a compile cache entry into the native compile cache store,
158184
// keyed by the filename. If the cache can already be loaded on disk,

src/node_modules.cc

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ namespace node {
2121
namespace modules {
2222

2323
using v8::Array;
24+
using v8::Boolean;
2425
using v8::Context;
2526
using v8::External;
2627
using v8::FunctionCallbackInfo;
@@ -80,15 +81,18 @@ Local<Array> BindingData::PackageConfig::Serialize(Realm* realm) const {
8081
isolate, input.data(), NewStringType::kNormal, input.size())
8182
.ToLocalChecked();
8283
};
83-
Local<Value> values[6] = {
84+
Local<Value> values[7] = {
8485
name.has_value() ? ToString(*name) : Undefined(isolate),
8586
main.has_value() ? ToString(*main) : Undefined(isolate),
8687
ToString(type),
8788
imports.has_value() ? ToString(*imports) : Undefined(isolate),
8889
exports.has_value() ? ToString(*exports) : Undefined(isolate),
8990
ToString(file_path),
91+
is_private.has_value()
92+
? Boolean::New(isolate, *is_private).As<Primitive>()
93+
: Undefined(isolate),
9094
};
91-
return Array::New(isolate, values, 6);
95+
return Array::New(isolate, values, 7);
9296
}
9397

9498
const BindingData::PackageConfig* BindingData::GetPackageJSON(
@@ -228,6 +232,11 @@ const BindingData::PackageConfig* BindingData::GetPackageJSON(
228232
if (field_value == "commonjs" || field_value == "module") {
229233
package_config.type = field_value;
230234
}
235+
} else if (key == "private") {
236+
bool is_private;
237+
if (!value.get_bool().get(is_private)) {
238+
package_config.is_private = is_private;
239+
}
231240
} else if (key == "scripts") {
232241
if (value.type().get(field_type)) {
233242
return throw_invalid_package_config();

src/node_modules.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class BindingData : public SnapshotableObject {
3333
std::optional<std::string> exports;
3434
std::optional<std::string> imports;
3535
std::optional<std::string> scripts;
36+
std::optional<bool> is_private;
3637
std::string raw_json;
3738

3839
v8::Local<v8::Array> Serialize(Realm* realm) const;

src/node_options.cc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,6 +1200,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
12001200
kAllowedInEnvvar,
12011201
HAVE_AMARO);
12021202
AddAlias("--experimental-strip-types", "--strip-types");
1203+
AddOption("--experimental-strip-private-modules",
1204+
"Type-stripping for TypeScript files under node_modules "
1205+
"belonging to packages marked \"private\": true",
1206+
&EnvironmentOptions::experimental_strip_private_modules,
1207+
kAllowedInEnvvar);
12031208
AddOption("--interactive",
12041209
"always enter the REPL even if stdin does not appear "
12051210
"to be a terminal",

src/node_options.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ class EnvironmentOptions : public Options {
273273
std::vector<std::string> preload_esm_modules;
274274

275275
bool strip_types = HAVE_AMARO;
276+
bool experimental_strip_private_modules = EXPERIMENTALS_DEFAULT_VALUE;
276277

277278
std::vector<std::string> user_argv;
278279

test/es-module/test-typescript.mjs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,85 @@ test('execute a TypeScript file with node_modules', async () => {
6161
assert.strictEqual(result.code, 0);
6262
});
6363

64+
test('execute a TypeScript file from a private package in node_modules', async () => {
65+
const result = await spawnPromisified(process.execPath, [
66+
'--experimental-strip-private-modules',
67+
'--no-warnings',
68+
fixtures.path('typescript/ts/test-typescript-private-node-modules.ts'),
69+
]);
70+
71+
assert.strictEqual(result.stderr, '');
72+
assert.match(result.stdout, /Hello, TypeScript!/);
73+
assert.strictEqual(result.code, 0);
74+
});
75+
76+
test('emit an experimental warning when stripping types in a private node_modules package',
77+
async () => {
78+
const result = await spawnPromisified(process.execPath, [
79+
'--experimental-strip-private-modules',
80+
fixtures.path('typescript/ts/test-typescript-private-node-modules.ts'),
81+
]);
82+
83+
assert.match(result.stderr, /Type stripping in node_modules is an experimental feature/);
84+
assert.match(result.stdout, /Hello, TypeScript!/);
85+
assert.strictEqual(result.code, 0);
86+
});
87+
88+
test('require a TypeScript file from a private package in node_modules', async () => {
89+
const result = await spawnPromisified(process.execPath, [
90+
'--experimental-strip-private-modules',
91+
'--no-warnings',
92+
fixtures.path('typescript/ts/test-typescript-private-node-modules-require.ts'),
93+
]);
94+
95+
assert.strictEqual(result.stderr, '');
96+
assert.match(result.stdout, /Hello, TypeScript!/);
97+
assert.strictEqual(result.code, 0);
98+
});
99+
100+
test('expect failure for a private package in node_modules without --experimental-strip-private-modules',
101+
async () => {
102+
const result = await spawnPromisified(process.execPath, [
103+
'--no-warnings',
104+
fixtures.path('typescript/ts/test-typescript-private-node-modules.ts'),
105+
]);
106+
107+
assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
108+
assert.strictEqual(result.stdout, '');
109+
assert.strictEqual(result.code, 1);
110+
});
111+
112+
// TODO(marco-ippolito): a nested package.json must not be able to enable type
113+
// stripping: npm only refuses to publish packages whose root package.json has
114+
// "private": true, so a published package can freely ship a nested
115+
// package.json with "private": true and bypass the restriction. Only the
116+
// package.json at the package root should be consulted.
117+
test('nested package.json faking "private": true in node_modules enables type stripping',
118+
async () => {
119+
const result = await spawnPromisified(process.execPath, [
120+
'--experimental-strip-private-modules',
121+
'--no-warnings',
122+
fixtures.path('typescript/ts/test-typescript-fake-private-node-modules.ts'),
123+
]);
124+
125+
assert.strictEqual(result.stderr, '');
126+
assert.match(result.stdout, /Hello, TypeScript!/);
127+
assert.strictEqual(result.code, 0);
128+
});
129+
130+
test('expect failure for a non-private package in node_modules with --experimental-strip-private-modules',
131+
async () => {
132+
const result = await spawnPromisified(process.execPath, [
133+
'--experimental-strip-private-modules',
134+
'--no-warnings',
135+
fixtures.path('typescript/ts/test-import-ts-node-modules.ts'),
136+
]);
137+
138+
assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
139+
assert.strictEqual(result.stdout, '');
140+
assert.strictEqual(result.code, 1);
141+
});
142+
64143
test('expect error when executing a TypeScript file with imports with no extensions', async () => {
65144
const result = await spawnPromisified(process.execPath, [
66145
fixtures.path('typescript/ts/test-import-no-extension.ts'),

0 commit comments

Comments
 (0)