Commit 2052d0dcdf7 for nodejs

commit 2052d0dcdf7f75e9d65618db8f7e60027ef94588
Author: Marco Ippolito <marcoippolito54@gmail.com>
Date:   Mon Oct 19 15:03:51 2026 +0200

    module: add --experimental-strip-private-modules

    Signed-off-by: Marco Ippolito <marcoippolito54@gmail.com>
    PR-URL: https://github.com/nodejs/node/pull/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>

diff --git a/doc/api/cli.md b/doc/api/cli.md
index b326cfeb2b9..7230231decd 100644
--- a/doc/api/cli.md
+++ b/doc/api/cli.md
@@ -1647,6 +1647,25 @@ added:

 Enable the experimental [`node:stream/iter`][] module.

+### `--experimental-strip-private-modules`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+> Stability: 1 - Experimental
+
+By default, Node.js refuses to strip types from TypeScript files inside
+folders under a `node_modules` path. This flag enables type stripping for
+such files when they belong to a package whose `package.json` contains
+`"private": true`. Files belonging to non-private packages still throw
+[`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`][].
+The limitation to private packages should never be lifted,
+as publishing packages with TypeScript sources
+is highly discouraged.
+Stripping types inside `node_modules` is intended only
+for local development.
+
 ### `--experimental-test-coverage`

 <!-- YAML
@@ -4287,6 +4306,7 @@ one is included in the list below.
 * `--experimental-shadow-realm`
 * `--experimental-specifier-resolution`
 * `--experimental-stream-iter`
+* `--experimental-strip-private-modules`
 * `--experimental-test-isolation`
 * `--experimental-top-level-await`
 * `--experimental-vm-modules`
@@ -4941,6 +4961,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
 [`CRYPTO_secure_malloc_init`]: https://www.openssl.org/docs/man3.0/man3/CRYPTO_secure_malloc_init.html
 [`ERR_INVALID_TYPESCRIPT_SYNTAX`]: errors.md#err_invalid_typescript_syntax
 [`ERR_RX_MEMORY_NOT_SUPPORTED`]: errors.md#err_rx_memory_not_supported
+[`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`]: errors.md#err_unsupported_node_modules_type_stripping
 [`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`]: errors.md#err_unsupported_typescript_syntax
 [`ERR_WEBASSEMBLY_NOT_SUPPORTED`]: errors.md#err_webassembly_not_supported
 [`NODE_OPTIONS`]: #node_optionsoptions
diff --git a/doc/api/packages.md b/doc/api/packages.md
index 8e1f5038f96..f448cd68ba4 100644
--- a/doc/api/packages.md
+++ b/doc/api/packages.md
@@ -1153,6 +1153,9 @@ The following fields in `package.json` files are used in Node.js:
   limits which submodules can be loaded from within the package.
 * [`"imports"`][] - Package imports, for use by modules within the package
   itself.
+* [`"private"`][] - When `true`, marks the package as not for publication. Used
+  by [`--experimental-strip-private-modules`][] to allow type stripping for the
+  package's TypeScript files under `node_modules`.

 ### `"name"`

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

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

+### `"private"`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* Type: {boolean}
+
+```json
+{
+  "private": true
+}
+```
+
+The `"private"` field marks a package as private, signaling to package managers
+such as _npm_ that it must not be published to a public registry.
+By default, Node.js refuses to strip types from TypeScript files inside
+`node_modules`. When [`--experimental-strip-private-modules`][] is enabled,
+Node.js allows type stripping for:
+
+* files that are not inside a `node_modules` directory.
+* files inside `node_modules`, but only when the package that contains the
+  file sets `"private": true`. Only the `package.json` at each package boundary
+  is considered: the package's root directory, which is the directory directly
+  under `node_modules` (or under `node_modules/<@scope>` for scoped packages).
+  Nested `package.json` files are ignored.
+
 [CommonJS]: modules.md
 [Conditional exports]: #conditional-exports
 [ES module]: esm.md
@@ -1362,10 +1392,12 @@ This field defines [subpath imports][] for the current package.
 [`"imports"`]: #imports
 [`"main"`]: #main
 [`"name"`]: #name
+[`"private"`]: #private
 [`"type"`]: #type
 [`--conditions` / `-C` flag]: #resolving-user-conditions
 [`--experimental-addon-modules`]: cli.md#--experimental-addon-modules
 [`--experimental-package-map`]: cli.md#--experimental-package-mappath
+[`--experimental-strip-private-modules`]: cli.md#--experimental-strip-private-modules
 [`--no-addons` flag]: cli.md#--no-addons
 [`ERR_PACKAGE_MAP_EXTERNAL_FILE`]: errors.md#err_package_map_external_file
 [`ERR_PACKAGE_PATH_NOT_EXPORTED`]: errors.md#err_package_path_not_exported
diff --git a/doc/api/typescript.md b/doc/api/typescript.md
index 701280375ba..5ea7dea0202 100644
--- a/doc/api/typescript.md
+++ b/doc/api/typescript.md
@@ -222,6 +222,15 @@ To discourage package authors from publishing packages written in TypeScript,
 Node.js refuses to handle TypeScript files inside folders under a `node_modules`
 path.

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

 [`tsconfig` "paths"][] won't be transformed and therefore produce an error. The closest
@@ -231,6 +240,7 @@ with `#`.
 [CommonJS]: modules.md
 [ES Modules]: esm.md
 [Full TypeScript support]: #full-typescript-support
+[`--experimental-strip-private-modules`]: cli.md#--experimental-strip-private-modules
 [`--no-strip-types`]: cli.md#--no-strip-types
 [`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`]: errors.md#err_unsupported_typescript_syntax
 [`tsconfig` "paths"]: https://www.typescriptlang.org/tsconfig/#paths
diff --git a/doc/node-config-schema.json b/doc/node-config-schema.json
index 22523a4868f..6f809a0a355 100644
--- a/doc/node-config-schema.json
+++ b/doc/node-config-schema.json
@@ -367,6 +367,10 @@
               "type": "boolean",
               "description": "experimental iterable streams API (node:stream/iter)"
             },
+            "experimental-strip-private-modules": {
+              "type": "boolean",
+              "description": "Type-stripping for TypeScript files under node_modules belonging to packages marked \"private\": true"
+            },
             "experimental-vm-modules": {
               "type": "boolean",
               "description": "experimental ES Module support in vm module"
diff --git a/doc/node.1 b/doc/node.1
index 6294c2af42c..821fe8d8e0f 100644
--- a/doc/node.1
+++ b/doc/node.1
@@ -909,6 +909,18 @@ Enable experimental support for storage inspection
 .It Fl -experimental-stream-iter
 Enable the experimental \fBnode:stream/iter\fR module.
 .
+.It Fl -experimental-strip-private-modules
+By default, Node.js refuses to strip types from TypeScript files inside
+folders under a \fBnode_modules\fR path. This flag enables type stripping for
+such files when they belong to a package whose \fBpackage.json\fR contains
+\fB"private": true\fR. Files belonging to non-private packages still throw
+\fBERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING\fR.
+The limitation to private packages should never be lifted,
+as publishing packages with TypeScript sources
+is highly discouraged.
+Stripping types inside \fBnode_modules\fR is intended only
+for local development.
+.
 .It Fl -experimental-test-coverage
 When used in conjunction with the \fBnode:test\fR module, a code coverage report is
 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.
 .It
 \fB--experimental-stream-iter\fR
 .It
+\fB--experimental-strip-private-modules\fR
+.It
 \fB--experimental-test-isolation\fR
 .It
 \fB--experimental-top-level-await\fR
diff --git a/lib/internal/modules/helpers.js b/lib/internal/modules/helpers.js
index 98d9e04d0c9..851547d2216 100644
--- a/lib/internal/modules/helpers.js
+++ b/lib/internal/modules/helpers.js
@@ -85,6 +85,8 @@ const nativeLoaderMethods = {
     modulesBinding.readPackageJSON(jsonPath, isESM, base, specifier),
   getNearestParentPackageJSON: (checkPath) =>
     modulesBinding.getNearestParentPackageJSON(checkPath),
+  getRootPackageJSON: (checkPath) =>
+    modulesBinding.getRootPackageJSON(checkPath),
   getNearestParentPackageJSONType: (checkPath) =>
     modulesBinding.getNearestParentPackageJSONType(checkPath),
   getPackageScopeConfig: (resolved) =>
diff --git a/lib/internal/modules/package_json_reader.js b/lib/internal/modules/package_json_reader.js
index ea6b7d26395..2ef5d1ebed3 100644
--- a/lib/internal/modules/package_json_reader.js
+++ b/lib/internal/modules/package_json_reader.js
@@ -64,6 +64,7 @@ function deserializePackageJSON(path, contents) {
     3: plainImports,
     4: plainExports,
     5: optionalFilePath,
+    6: isPrivate,
   } = contents;

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

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

+const moduleToRootPackageJSONCache = new SafeMap();
+
+/**
+ * This is stricter than {@link getNearestParentPackageJSON}: nested
+ * `package.json` files inside a package are ignored, and a non-private package
+ * anywhere in the `node_modules` chain (for example a publishable package that
+ * bundles a private dependency) disqualifies the path. A directory under
+ * `node_modules` with no `package.json` (such as pnpm's `.pnpm` store) is not
+ * a package and is skipped.
+ *
+ * Returns the package.json data and the path to the package.json file, or undefined.
+ * @param {string} checkPath The path to start searching from.
+ * @returns {undefined | DeserializedPackageConfig}
+ */
+function getRootPackageJSON(checkPath) {
+  const packageJSONPath = moduleToRootPackageJSONCache.get(checkPath);
+  if (packageJSONPath !== undefined) {
+    return deserializedPackageJSONCache.get(packageJSONPath);
+  }
+
+  const result = loaderMethods.getRootPackageJSON(checkPath);
+  const packageConfig = deserializePackageJSON(checkPath, result);
+
+  moduleToRootPackageJSONCache.set(checkPath, packageConfig.path);
+
+  const maybeCachedPackageConfig = deserializedPackageJSONCache.get(packageConfig.path);
+  if (maybeCachedPackageConfig !== undefined) {
+    return maybeCachedPackageConfig;
+  }
+
+  deserializedPackageJSONCache.set(packageConfig.path, packageConfig);
+  return packageConfig;
+}
+
 /**
  * Returns the package configuration for the given resolved URL.
  * @param {URL | string} resolved - The resolved URL.
@@ -412,6 +448,7 @@ function purgePackageJSONCacheForPrefix(mountPoint) {
 module.exports = {
   read,
   getNearestParentPackageJSON,
+  getRootPackageJSON,
   getPackageScopeConfig,
   getPackageType,
   getPackageJSONURL,
diff --git a/lib/internal/modules/typescript.js b/lib/internal/modules/typescript.js
index 43bc57d977e..ebe41e308bc 100644
--- a/lib/internal/modules/typescript.js
+++ b/lib/internal/modules/typescript.js
@@ -18,6 +18,7 @@ const {
   ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING,
   ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX,
 } = require('internal/errors').codes;
+const { getOptionValue } = require('internal/options');
 const assert = require('internal/assert');
 const {
   getCompileCacheEntry,
@@ -141,6 +142,35 @@ function stripTypeScriptTypesForCoverage(code) {
 }


+/**
+ * Whether type-stripping is allowed for files under node_modules belonging
+ * to packages whose package.json contains `"private": true`.
+ * @returns {boolean}
+ */
+const isStripPrivateModulesEnabled = getLazy(
+  () => getOptionValue('--experimental-strip-private-modules'),
+);
+
+/**
+ * Checks whether a file under node_modules is owned by a private package, in
+ * which case type stripping is allowed under `--experimental-strip-private-modules`.
+ *
+ * Every package enclosing the file inside `node_modules` must be marked
+ * `"private": true`. npm refuses to publish a private package, so requiring
+ * the package root (not a nested package.json, which is published verbatim)
+ * and rejecting any non-private enclosing package (e.g. a publishable package
+ * bundling a private dependency) prevents published packages from shipping
+ * strippable TypeScript. See {@link getRootPackageJSON}.
+ * @param {string} filename The filename (path or file URL) of the source code.
+ * @returns {boolean} Whether the file belongs to a package with `"private": true`.
+ */
+function isInsidePrivatePackage(filename) {
+  const { getRootPackageJSON } = require('internal/modules/package_json_reader');
+  const { urlToFilename } = require('internal/modules/helpers');
+  const packageJSON = getRootPackageJSON(urlToFilename(filename));
+  return packageJSON?.data.private === true;
+}
+
 /**
  * Performs type-stripping to TypeScript source code internally.
  * It is used by internal loaders.
@@ -152,7 +182,10 @@ function stripTypeScriptTypesForCoverage(code) {
 function stripTypeScriptModuleTypes(source, filename, sourceURL) {
   assert(typeof source === 'string');
   if (isUnderNodeModules(filename)) {
-    throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
+    if (!isStripPrivateModulesEnabled() || !isInsidePrivatePackage(filename)) {
+      throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
+    }
+    emitExperimentalWarning('Type stripping in node_modules');
   }
   // Get a compile cache entry into the native compile cache store,
   // keyed by the filename. If the cache can already be loaded on disk,
diff --git a/src/node_modules.cc b/src/node_modules.cc
index ec4f6bdd79a..f88fc461f4d 100644
--- a/src/node_modules.cc
+++ b/src/node_modules.cc
@@ -21,6 +21,7 @@ namespace node {
 namespace modules {

 using v8::Array;
+using v8::Boolean;
 using v8::Context;
 using v8::External;
 using v8::FunctionCallbackInfo;
@@ -80,15 +81,18 @@ Local<Array> BindingData::PackageConfig::Serialize(Realm* realm) const {
                isolate, input.data(), NewStringType::kNormal, input.size())
         .ToLocalChecked();
   };
-  Local<Value> values[6] = {
+  Local<Value> 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),
+      is_private.has_value()
+          ? Boolean::New(isolate, *is_private).As<Primitive>()
+          : Undefined(isolate),
   };
-  return Array::New(isolate, values, 6);
+  return Array::New(isolate, values, 7);
 }

 bool BindingData::ParsePackageJSON(Realm* realm,
@@ -207,6 +211,11 @@ bool BindingData::ParsePackageJSON(Realm* realm,
       if (field_value == "commonjs" || field_value == "module") {
         package_config->type = field_value;
       }
+    } else if (key == "private") {
+      bool is_private;
+      if (!value.get_bool().get(is_private)) {
+        package_config->is_private = is_private;
+      }
     } else if (key == "scripts") {
       if (value.type().get(field_type)) {
         return throw_invalid_package_config();
@@ -405,6 +414,72 @@ const BindingData::PackageConfig* BindingData::TraverseParent(
   return nullptr;
 }

+const BindingData::PackageConfig* BindingData::FindNodeModulesPackage(
+    Realm* realm, const std::filesystem::path& check_path) {
+  std::filesystem::path current_path = check_path;
+  std::filesystem::path child;
+  std::filesystem::path grandchild;
+  const PackageConfig* innermost = nullptr;
+  auto env = realm->env();
+  const bool is_permissions_enabled = env->permission()->enabled();
+
+  while (true) {
+    auto parent_path = current_path.parent_path();
+    if (parent_path == current_path) {
+      // Reached the filesystem root.
+      break;
+    }
+
+    if (current_path.filename() == "node_modules") {
+      // `child` is the directory directly under node_modules. For scoped
+      // packages the package root is one level further down.
+      const std::string child_name = child.filename().generic_string();
+      const std::filesystem::path& package_root =
+          (!child_name.empty() && child_name[0] == '@') ? grandchild : child;
+      if (package_root.empty()) {
+        // The path points at a node_modules or bare scope directory rather
+        // than into a package.
+        return nullptr;
+      }
+
+      auto package_json_path = package_root / "package.json";
+
+      // GetPackageJSON()'s ReadFileSync() reads the file directly without
+      // consulting the permission model, so check read access here as
+      // TraverseParent() does. Without permission to confirm the package is
+      // private, fail closed and deny stripping.
+      if (is_permissions_enabled) [[unlikely]] {
+        if (!env->permission()->is_granted(
+                env,
+                permission::PermissionScope::kFileSystemRead,
+                ConvertGenericPathToUTF8(package_json_path))) {
+          return nullptr;
+        }
+      }
+
+      auto package_json =
+          GetPackageJSON(realm, ConvertPathToUTF8(package_json_path), nullptr);
+      if (package_json != nullptr) {
+        if (!package_json->is_private.value_or(false)) {
+          // A publishable (non-private) package encloses the file.
+          return nullptr;
+        }
+        if (innermost == nullptr) {
+          innermost = package_json;
+        }
+      }
+      // A directory under node_modules with no package.json is not a package
+      // (e.g. pnpm's `.pnpm` store); skip it and keep walking upwards.
+    }
+
+    grandchild = child;
+    child = current_path;
+    current_path = parent_path;
+  }
+
+  return innermost;
+}
+
 const std::filesystem::path BindingData::NormalizePath(
     Realm* realm, BufferValue* path_value) {
   // Check if the path has a trailing slash. If so, add it after
@@ -439,6 +514,22 @@ void BindingData::GetNearestParentPackageJSON(
   }
 }

+void BindingData::GetRootPackageJSON(const FunctionCallbackInfo<Value>& args) {
+  CHECK_GE(args.Length(), 1);
+  CHECK(args[0]->IsString());
+
+  Realm* realm = Realm::GetCurrent(args);
+  BufferValue path_value(realm->isolate(), args[0]);
+
+  auto path = NormalizePath(realm, &path_value);
+
+  auto package_json = FindNodeModulesPackage(realm, path);
+
+  if (package_json != nullptr) {
+    args.GetReturnValue().Set(package_json->Serialize(realm));
+  }
+}
+
 void BindingData::GetNearestParentPackageJSONType(
     const FunctionCallbackInfo<Value>& args) {
   CHECK_GE(args.Length(), 1);
@@ -696,6 +787,7 @@ void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data,
             target,
             "getNearestParentPackageJSON",
             GetNearestParentPackageJSON);
+  SetMethod(isolate, target, "getRootPackageJSON", GetRootPackageJSON);
   SetMethod(
       isolate, target, "getPackageScopeConfig", GetPackageScopeConfig<false>);
   SetMethod(isolate, target, "getPackageType", GetPackageScopeConfig<true>);
@@ -769,6 +861,7 @@ void BindingData::RegisterExternalReferences(
   registry->Register(ParsePackageJSONBinding);
   registry->Register(GetNearestParentPackageJSONType);
   registry->Register(GetNearestParentPackageJSON);
+  registry->Register(GetRootPackageJSON);
   registry->Register(GetPackageScopeConfig<false>);
   registry->Register(GetPackageScopeConfig<true>);
   registry->Register(EnableCompileCache);
diff --git a/src/node_modules.h b/src/node_modules.h
index 54417731265..997f428b3a1 100644
--- a/src/node_modules.h
+++ b/src/node_modules.h
@@ -33,6 +33,7 @@ class BindingData : public SnapshotableObject {
     std::optional<std::string> exports;
     std::optional<std::string> imports;
     std::optional<std::string> scripts;
+    std::optional<bool> is_private;
     std::string raw_json;

     v8::Local<v8::Array> Serialize(Realm* realm) const;
@@ -62,6 +63,8 @@ class BindingData : public SnapshotableObject {
       const v8::FunctionCallbackInfo<v8::Value>& args);
   static void GetNearestParentPackageJSON(
       const v8::FunctionCallbackInfo<v8::Value>& args);
+  static void GetRootPackageJSON(
+      const v8::FunctionCallbackInfo<v8::Value>& args);
   static void GetNearestParentPackageJSONType(
       const v8::FunctionCallbackInfo<v8::Value>& args);
   template <bool return_only_type>
@@ -103,6 +106,11 @@ class BindingData : public SnapshotableObject {
                                ErrorContext* error_context);
   static const PackageConfig* TraverseParent(
       Realm* realm, const std::filesystem::path& check_path);
+  // Returns the package.json of the package that owns check_path inside the
+  // innermost node_modules directory, or null when check_path is not inside
+  // node_modules or the package root has no package.json.
+  static const PackageConfig* FindNodeModulesPackage(
+      Realm* realm, const std::filesystem::path& check_path);
 };

 }  // namespace modules
diff --git a/src/node_options.cc b/src/node_options.cc
index 6bf5a0270c7..d066f4af772 100644
--- a/src/node_options.cc
+++ b/src/node_options.cc
@@ -1479,6 +1479,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
             kAllowedInEnvvar,
             HAVE_AMARO);
   AddAlias("--experimental-strip-types", "--strip-types");
+  AddOption("--experimental-strip-private-modules",
+            "Type-stripping for TypeScript files under node_modules "
+            "belonging to packages marked \"private\": true",
+            BOOL_FIELD(experimental_strip_private_modules),
+            kAllowedInEnvvar);
   AddOption("--interactive",
             "always enter the REPL even if stdin does not appear "
             "to be a terminal",
diff --git a/src/node_options.h b/src/node_options.h
index bb11922f84f..b5f2084ab74 100644
--- a/src/node_options.h
+++ b/src/node_options.h
@@ -298,6 +298,8 @@ class EnvironmentOptions : public Options {
   DEFINE_BOOL_FIELD(tls_max_v1_2) = false;
   DEFINE_BOOL_FIELD(tls_max_v1_3) = false;
   DEFINE_BOOL_FIELD(strip_types) = HAVE_AMARO;
+  DEFINE_BOOL_FIELD(experimental_strip_private_modules) =
+      EXPERIMENTALS_DEFAULT_VALUE;
   DEFINE_BOOL_FIELD(report_exclude_env) = false;
   DEFINE_BOOL_FIELD(report_exclude_network) = false;
 #if HAVE_INSPECTOR
diff --git a/test/es-module/test-typescript.mjs b/test/es-module/test-typescript.mjs
index fd58d1d990a..07b556c2db1 100644
--- a/test/es-module/test-typescript.mjs
+++ b/test/es-module/test-typescript.mjs
@@ -61,6 +61,136 @@ test('execute a TypeScript file with node_modules', async () => {
   assert.strictEqual(result.code, 0);
 });

+test('execute a TypeScript file from a private package in node_modules', async () => {
+  const result = await spawnPromisified(process.execPath, [
+    '--experimental-strip-private-modules',
+    '--no-warnings',
+    fixtures.path('typescript/ts/test-typescript-private-node-modules.ts'),
+  ]);
+
+  assert.strictEqual(result.stderr, '');
+  assert.match(result.stdout, /Hello, TypeScript!/);
+  assert.strictEqual(result.code, 0);
+});
+
+test('execute a TypeScript file from a private package with a non-private nested package.json',
+     async () => {
+       const result = await spawnPromisified(process.execPath, [
+         '--experimental-strip-private-modules',
+         '--no-warnings',
+         fixtures.path('typescript/ts/test-typescript-nested-nonprivate-node-modules.ts'),
+       ]);
+
+       assert.strictEqual(result.stderr, '');
+       assert.match(result.stdout, /Hello, TypeScript!/);
+       assert.strictEqual(result.code, 0);
+     });
+
+test('emit an experimental warning when stripping types in a private node_modules package',
+     async () => {
+       const result = await spawnPromisified(process.execPath, [
+         '--experimental-strip-private-modules',
+         fixtures.path('typescript/ts/test-typescript-private-node-modules.ts'),
+       ]);
+
+       assert.match(result.stderr, /Type stripping in node_modules is an experimental feature/);
+       assert.match(result.stdout, /Hello, TypeScript!/);
+       assert.strictEqual(result.code, 0);
+     });
+
+test('require a TypeScript file from a private package in node_modules', async () => {
+  const result = await spawnPromisified(process.execPath, [
+    '--experimental-strip-private-modules',
+    '--no-warnings',
+    fixtures.path('typescript/ts/test-typescript-private-node-modules-require.ts'),
+  ]);
+
+  assert.strictEqual(result.stderr, '');
+  assert.match(result.stdout, /Hello, TypeScript!/);
+  assert.strictEqual(result.code, 0);
+});
+
+test('expect failure for a private package in node_modules without --experimental-strip-private-modules',
+     async () => {
+       const result = await spawnPromisified(process.execPath, [
+         '--no-warnings',
+         fixtures.path('typescript/ts/test-typescript-private-node-modules.ts'),
+       ]);
+
+       assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
+       assert.strictEqual(result.stdout, '');
+       assert.strictEqual(result.code, 1);
+     });
+
+// A nested package.json must not be able to enable type stripping: npm only
+// refuses to publish packages whose root package.json has "private": true, so
+// a published package can freely ship a nested package.json with
+// "private": true. Only the package.json at the package root is consulted.
+test('expect failure for a nested package.json faking "private": true in node_modules',
+     async () => {
+       const result = await spawnPromisified(process.execPath, [
+         '--experimental-strip-private-modules',
+         '--no-warnings',
+         fixtures.path('typescript/ts/test-typescript-fake-private-node-modules.ts'),
+       ]);
+
+       assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
+       assert.strictEqual(result.stdout, '');
+       assert.strictEqual(result.code, 1);
+     });
+
+test('expect failure for a private package bundled inside a non-private package',
+     async () => {
+       const result = await spawnPromisified(process.execPath, [
+         '--experimental-strip-private-modules',
+         '--no-warnings',
+         fixtures.path('typescript/ts/test-typescript-bundled-private-node-modules.ts'),
+       ]);
+
+       assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
+       assert.strictEqual(result.stdout, '');
+       assert.strictEqual(result.code, 1);
+     });
+
+test('expect failure for a relative import into a non-private node_modules package',
+     async () => {
+       const result = await spawnPromisified(process.execPath, [
+         '--experimental-strip-private-modules',
+         '--no-warnings',
+         fixtures.path('typescript/ts/test-typescript-relative-node-modules.ts'),
+       ]);
+
+       assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
+       assert.strictEqual(result.stdout, '');
+       assert.strictEqual(result.code, 1);
+     });
+
+test('expect failure for a ".." path resolving to a non-private node_modules package',
+     async () => {
+       const result = await spawnPromisified(process.execPath, [
+         '--experimental-strip-private-modules',
+         '--no-warnings',
+         fixtures.path('typescript/ts/test-typescript-dotdot-node-modules.ts'),
+       ]);
+
+       assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
+       assert.strictEqual(result.stdout, '');
+       assert.strictEqual(result.code, 1);
+     });
+
+test('expect failure for a non-private package in node_modules with --experimental-strip-private-modules',
+     async () => {
+       const result = await spawnPromisified(process.execPath, [
+         '--experimental-strip-private-modules',
+         '--no-warnings',
+         fixtures.path('typescript/ts/test-import-ts-node-modules.ts'),
+       ]);
+
+       assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
+       assert.strictEqual(result.stdout, '');
+       assert.strictEqual(result.code, 1);
+     });
+
 test('expect error when executing a TypeScript file with imports with no extensions', async () => {
   const result = await spawnPromisified(process.execPath, [
     fixtures.path('typescript/ts/test-import-no-extension.ts'),
diff --git a/test/fixtures/typescript/ts/node_modules/bundler-pkg/index.js b/test/fixtures/typescript/ts/node_modules/bundler-pkg/index.js
new file mode 100644
index 00000000000..d342ad0fa36
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/bundler-pkg/index.js
@@ -0,0 +1 @@
+export { baz } from 'bundled-private';
diff --git a/test/fixtures/typescript/ts/node_modules/bundler-pkg/node_modules/bundled-private/bundled-private.ts b/test/fixtures/typescript/ts/node_modules/bundler-pkg/node_modules/bundled-private/bundled-private.ts
new file mode 100644
index 00000000000..746109acf8c
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/bundler-pkg/node_modules/bundled-private/bundled-private.ts
@@ -0,0 +1 @@
+export const baz: string = 'Hello, TypeScript!';
diff --git a/test/fixtures/typescript/ts/node_modules/bundler-pkg/node_modules/bundled-private/package.json b/test/fixtures/typescript/ts/node_modules/bundler-pkg/node_modules/bundled-private/package.json
new file mode 100644
index 00000000000..b0f08d814dc
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/bundler-pkg/node_modules/bundled-private/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "bundled-private",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "main": "bundled-private.ts"
+}
diff --git a/test/fixtures/typescript/ts/node_modules/bundler-pkg/package.json b/test/fixtures/typescript/ts/node_modules/bundler-pkg/package.json
new file mode 100644
index 00000000000..cd729ba337a
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/bundler-pkg/package.json
@@ -0,0 +1,6 @@
+{
+  "name": "bundler-pkg",
+  "version": "1.0.0",
+  "type": "module",
+  "exports": "./index.js"
+}
diff --git a/test/fixtures/typescript/ts/node_modules/fake-private-pkg/lib/fake-private-pkg.ts b/test/fixtures/typescript/ts/node_modules/fake-private-pkg/lib/fake-private-pkg.ts
new file mode 100644
index 00000000000..746109acf8c
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/fake-private-pkg/lib/fake-private-pkg.ts
@@ -0,0 +1 @@
+export const baz: string = 'Hello, TypeScript!';
diff --git a/test/fixtures/typescript/ts/node_modules/fake-private-pkg/lib/package.json b/test/fixtures/typescript/ts/node_modules/fake-private-pkg/lib/package.json
new file mode 100644
index 00000000000..e986b24bbae
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/fake-private-pkg/lib/package.json
@@ -0,0 +1,4 @@
+{
+  "private": true,
+  "type": "module"
+}
diff --git a/test/fixtures/typescript/ts/node_modules/fake-private-pkg/package.json b/test/fixtures/typescript/ts/node_modules/fake-private-pkg/package.json
new file mode 100644
index 00000000000..1ef96e66059
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/fake-private-pkg/package.json
@@ -0,0 +1,5 @@
+{
+  "name": "fake-private-pkg",
+  "version": "1.0.0",
+  "main": "lib/fake-private-pkg.ts"
+}
diff --git a/test/fixtures/typescript/ts/node_modules/nested-nonprivate-pkg/lib/index.ts b/test/fixtures/typescript/ts/node_modules/nested-nonprivate-pkg/lib/index.ts
new file mode 100644
index 00000000000..746109acf8c
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/nested-nonprivate-pkg/lib/index.ts
@@ -0,0 +1 @@
+export const baz: string = 'Hello, TypeScript!';
diff --git a/test/fixtures/typescript/ts/node_modules/nested-nonprivate-pkg/lib/package.json b/test/fixtures/typescript/ts/node_modules/nested-nonprivate-pkg/lib/package.json
new file mode 100644
index 00000000000..3dbc1ca591c
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/nested-nonprivate-pkg/lib/package.json
@@ -0,0 +1,3 @@
+{
+  "type": "module"
+}
diff --git a/test/fixtures/typescript/ts/node_modules/nested-nonprivate-pkg/package.json b/test/fixtures/typescript/ts/node_modules/nested-nonprivate-pkg/package.json
new file mode 100644
index 00000000000..a0ba58582ef
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/nested-nonprivate-pkg/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "nested-nonprivate-pkg",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "exports": "./lib/index.ts"
+}
diff --git a/test/fixtures/typescript/ts/node_modules/private-pkg-cjs/package.json b/test/fixtures/typescript/ts/node_modules/private-pkg-cjs/package.json
new file mode 100644
index 00000000000..d6cdf88f497
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/private-pkg-cjs/package.json
@@ -0,0 +1,6 @@
+{
+  "name": "private-pkg-cjs",
+  "version": "1.0.0",
+  "private": true,
+  "main": "private-pkg-cjs.ts"
+}
diff --git a/test/fixtures/typescript/ts/node_modules/private-pkg-cjs/private-pkg-cjs.ts b/test/fixtures/typescript/ts/node_modules/private-pkg-cjs/private-pkg-cjs.ts
new file mode 100644
index 00000000000..2d42fcdf192
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/private-pkg-cjs/private-pkg-cjs.ts
@@ -0,0 +1,3 @@
+const baz: string = 'Hello, TypeScript!';
+
+module.exports = { baz };
diff --git a/test/fixtures/typescript/ts/node_modules/private-pkg/greeting.ts b/test/fixtures/typescript/ts/node_modules/private-pkg/greeting.ts
new file mode 100644
index 00000000000..00b0820776c
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/private-pkg/greeting.ts
@@ -0,0 +1 @@
+export const greeting: string = 'Hello, TypeScript!';
diff --git a/test/fixtures/typescript/ts/node_modules/private-pkg/package.json b/test/fixtures/typescript/ts/node_modules/private-pkg/package.json
new file mode 100644
index 00000000000..2fd30de076e
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/private-pkg/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "private-pkg",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "main": "private-pkg.ts"
+}
diff --git a/test/fixtures/typescript/ts/node_modules/private-pkg/private-pkg.ts b/test/fixtures/typescript/ts/node_modules/private-pkg/private-pkg.ts
new file mode 100644
index 00000000000..25228dcb9ea
--- /dev/null
+++ b/test/fixtures/typescript/ts/node_modules/private-pkg/private-pkg.ts
@@ -0,0 +1,3 @@
+import { greeting } from './greeting.ts';
+
+export const baz: string = greeting;
diff --git a/test/fixtures/typescript/ts/test-typescript-bundled-private-node-modules.ts b/test/fixtures/typescript/ts/test-typescript-bundled-private-node-modules.ts
new file mode 100644
index 00000000000..6d57c6d8e04
--- /dev/null
+++ b/test/fixtures/typescript/ts/test-typescript-bundled-private-node-modules.ts
@@ -0,0 +1,3 @@
+import { baz } from 'bundler-pkg';
+
+console.log(baz);
diff --git a/test/fixtures/typescript/ts/test-typescript-dotdot-node-modules.ts b/test/fixtures/typescript/ts/test-typescript-dotdot-node-modules.ts
new file mode 100644
index 00000000000..5f9ed34c790
--- /dev/null
+++ b/test/fixtures/typescript/ts/test-typescript-dotdot-node-modules.ts
@@ -0,0 +1 @@
+require('./node_modules/private-pkg-cjs/../bar/bar.ts');
diff --git a/test/fixtures/typescript/ts/test-typescript-fake-private-node-modules.ts b/test/fixtures/typescript/ts/test-typescript-fake-private-node-modules.ts
new file mode 100644
index 00000000000..f340f6b5963
--- /dev/null
+++ b/test/fixtures/typescript/ts/test-typescript-fake-private-node-modules.ts
@@ -0,0 +1,3 @@
+import { baz } from 'fake-private-pkg';
+
+console.log(baz);
diff --git a/test/fixtures/typescript/ts/test-typescript-nested-nonprivate-node-modules.ts b/test/fixtures/typescript/ts/test-typescript-nested-nonprivate-node-modules.ts
new file mode 100644
index 00000000000..30c2c46864e
--- /dev/null
+++ b/test/fixtures/typescript/ts/test-typescript-nested-nonprivate-node-modules.ts
@@ -0,0 +1,3 @@
+import { baz } from 'nested-nonprivate-pkg';
+
+console.log(baz);
diff --git a/test/fixtures/typescript/ts/test-typescript-private-node-modules-require.ts b/test/fixtures/typescript/ts/test-typescript-private-node-modules-require.ts
new file mode 100644
index 00000000000..f2aa981bfaa
--- /dev/null
+++ b/test/fixtures/typescript/ts/test-typescript-private-node-modules-require.ts
@@ -0,0 +1,3 @@
+const { baz } = require('private-pkg-cjs');
+
+console.log(baz);
diff --git a/test/fixtures/typescript/ts/test-typescript-private-node-modules.ts b/test/fixtures/typescript/ts/test-typescript-private-node-modules.ts
new file mode 100644
index 00000000000..92a5cec8ecd
--- /dev/null
+++ b/test/fixtures/typescript/ts/test-typescript-private-node-modules.ts
@@ -0,0 +1,3 @@
+import { baz } from 'private-pkg';
+
+console.log(baz);
diff --git a/test/fixtures/typescript/ts/test-typescript-relative-node-modules.ts b/test/fixtures/typescript/ts/test-typescript-relative-node-modules.ts
new file mode 100644
index 00000000000..7691013129f
--- /dev/null
+++ b/test/fixtures/typescript/ts/test-typescript-relative-node-modules.ts
@@ -0,0 +1 @@
+require('./node_modules/bar/bar.ts');
diff --git a/typings/internalBinding/modules.d.ts b/typings/internalBinding/modules.d.ts
index 0b1d0e29383..73bfd2a5aca 100644
--- a/typings/internalBinding/modules.d.ts
+++ b/typings/internalBinding/modules.d.ts
@@ -5,6 +5,7 @@ export type PackageConfig = {
   type: PackageType
   exports?: string | string[] | Record<string, unknown>
   imports?: string | string[] | Record<string, unknown>
+  private?: boolean
 }
 export type DeserializedPackageConfig = {
   data: PackageConfig,
@@ -18,12 +19,14 @@ export type SerializedPackageConfig = [
   string | undefined, // exports
   string | undefined, // imports
   DeserializedPackageConfig['path'], // pjson file path
+  PackageConfig['private'], // private
 ]

 export interface ModulesBinding {
   readPackageJSON(path: string): SerializedPackageConfig | undefined;
   getNearestParentPackageJSONType(path: string): PackageConfig['type']
   getNearestParentPackageJSON(path: string): SerializedPackageConfig | undefined
+  getRootPackageJSON(path: string): SerializedPackageConfig | undefined
   getPackageScopeConfig(path: string): SerializedPackageConfig | undefined
   getPackageType(path: string): PackageConfig['type'] | undefined
   enableCompileCache(path?: string): { status: number, message?: string, directory?: string }