diff --git a/doc/api/esm.md b/doc/api/esm.md index 0fa6a37d89b6a1..d966c92f2a5490 100644 --- a/doc/api/esm.md +++ b/doc/api/esm.md @@ -1307,18 +1307,21 @@ The resolver can throw the following errors: > 11. While _parentURL_ is not the file system root, > 1. Let _packageURL_ be the URL resolution of _"node\_modules/"_ > concatenated with _packageSpecifier_, relative to _parentURL_. -> 2. Set _parentURL_ to the parent folder URL of _parentURL_. -> 3. If the folder at _packageURL_ does not exist, then -> 1. Continue the next loop iteration. -> 4. Let _pjson_ be the result of **READ\_PACKAGE\_JSON**(_packageURL_). -> 5. If _pjson_ is not **null** and _pjson_._exports_ is not **null** or +> 2. Let _ceilingURL_ be the URL resolution of _"node\_ceiling"_, +> relative to _parentURL_. +> 3. Set _parentURL_ to the parent folder URL of _parentURL_. +> 4. If the folder at _packageURL_ does not exist, then +> 1. If the file at _ceilingURL_ exists, then break out of the loop. +> 2. Otherwise, continue the next loop iteration. +> 5. Let _pjson_ be the result of **READ\_PACKAGE\_JSON**(_packageURL_). +> 6. If _pjson_ is not **null** and _pjson_._exports_ is not **null** or > **undefined**, then > 1. Return the result of **PACKAGE\_EXPORTS\_RESOLVE**(_packageURL_, > _packageSubpath_, _pjson.exports_, _defaultConditions_). -> 6. Otherwise, if _packageSubpath_ is equal to _"."_, then +> 7. Otherwise, if _packageSubpath_ is equal to _"."_, then > 1. If _pjson.main_ is a string, then > 1. Return the URL resolution of _main_ in _packageURL_. -> 7. Otherwise, +> 8. Otherwise, > 1. Return the URL resolution of _packageSubpath_ in _packageURL_. > 12. Throw a _Module Not Found_ error. @@ -1503,6 +1506,10 @@ _internal_, _conditions_) > _scopeURL_. > 4. if the file at _pjsonURL_ exists, then > 1. Return _scopeURL_. +> 5. Let _ceilingURL_ be the resolution of _"node\_ceiling"_ within +> _scopeURL_. +> 6. if the file at _ceilingURL_ exists, then +> 1. Return **null**. > 3. Return **null**. **READ\_PACKAGE\_JSON**(_packageURL_) diff --git a/doc/api/modules.md b/doc/api/modules.md index 3cc81bf844901b..e89b819e826933 100644 --- a/doc/api/modules.md +++ b/doc/api/modules.md @@ -226,10 +226,16 @@ LOAD_AS_DIRECTORY(X) LOAD_NODE_MODULES(X, START) 1. let DIRS = NODE_MODULES_PATHS(START) -2. for each DIR in DIRS: - a. LOAD_PACKAGE_EXPORTS(X, DIR) - b. LOAD_AS_FILE(DIR/X) - c. LOAD_AS_DIRECTORY(DIR/X) +2. let FOUND_ROOT = false +3. let FOUND_CEILING = false +4. for each DIR in DIRS: + a. If FOUND_ROOT is false + 1. If path resolve(DIR, "../../node_modules") = DIR, let FOUND_ROOT = true + 2. If FOUND_CEILING is true, CONTINUE + b. LOAD_PACKAGE_EXPORTS(X, DIR) + c. LOAD_AS_FILE(DIR/X) + d. LOAD_AS_DIRECTORY(DIR/X) + e. If FOUND_ROOT is false && FOUND_CEILING is false && path resolve(DIR, "../node_ceiling") is a file, let FOUND_CEILING = true NODE_MODULES_PATHS(START) 1. let PARTS = path split(START) @@ -493,7 +499,9 @@ Node.js will not append `node_modules` to a path already ending in `node_modules`. If it is not found there, then it moves to the parent directory, and so -on, until the root of the file system is reached. +on, until the root of the file system is reached. If a `node_ceiling` +file is found in a directory, Node.js stops moving to the parent directory +before the root of the file system is reached. For example, if the file at `'/home/ry/projects/foo.js'` called `require('bar.js')`, then Node.js would look in the following locations, in diff --git a/doc/api/packages.md b/doc/api/packages.md index 2dce76f8539c43..b29dbb6f461604 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -48,7 +48,7 @@ changes: A package is a folder tree described by a `package.json` file. The package consists of the folder containing the `package.json` file and all subfolders until the next folder containing another `package.json` file, or a folder -named `node_modules`. +named `node_modules`, or a folder containing a `node_ceiling` file. This page provides guidance for package authors writing `package.json` files along with a reference for the [`package.json`][] fields defined by Node.js. @@ -1201,7 +1201,8 @@ Files ending with `.js` are loaded as ES modules when the nearest parent The nearest parent `package.json` is defined as the first `package.json` found when searching in the current folder, that folder's parent, and so on up -until a node\_modules folder or the volume root is reached. +until a node\_modules folder or a folder containing a `node_ceiling` file or +the volume root is reached. ```json // package.json @@ -1216,7 +1217,8 @@ node my-app.js # Runs as ES module ``` If the nearest parent `package.json` lacks a `"type"` field, or contains -`"type": "commonjs"`, `.js` files are treated as [CommonJS][]. If the volume +`"type": "commonjs"`, `.js` files are treated as [CommonJS][]. If a folder +containing a `node_ceiling` file is reached or the volume root is reached and no `package.json` is found, `.js` files are treated as [CommonJS][]. diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 711589894d5d19..c24a555fa0ea60 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -342,6 +342,12 @@ function readPackageScope(checkPath) { data: pjson, path: checkPath, }; + const ceilingPath = checkPath + sep + 'node_ceiling'; + const rc = stat(ceilingPath); + if (rc === 0) { // File. + debug('found node_ceiling at', ceilingPath); + return false; + } } while (separatorIndex > rootSeparatorIndex); return false; } @@ -501,11 +507,25 @@ function resolveExports(nmPath, request) { } } +function lookForCeiling(foundCeiling, foundRoot, curPath) { + if (!foundCeiling && !foundRoot) { + const ceilingPath = path.resolve(curPath, '../node_ceiling'); + const rc = stat(ceilingPath); + if (rc === 0) { // File. + debug('found node_ceiling at', ceilingPath); + return true; + } + } + return foundCeiling; +} const trailingSlashRegex = /(?:^|\/)\.?\.$/; Module._findPath = function(request, paths, isMain) { + let foundRoot = false; + let foundCeiling = false; const absoluteRequest = path.isAbsolute(request); if (absoluteRequest) { paths = ['']; + foundRoot = true; } else if (!paths || paths.length === 0) { return false; } @@ -527,7 +547,21 @@ Module._findPath = function(request, paths, isMain) { for (let i = 0; i < paths.length; i++) { // Don't search further if path doesn't exist const curPath = paths[i]; - if (curPath && stat(curPath) < 1) continue; + + if (!foundRoot) { + foundRoot = curPath === path.resolve(curPath, '../../node_modules'); + if (foundRoot) { + debug('foundRoot', curPath); + } + if (foundCeiling) { + continue; + } + } + + if (curPath && stat(curPath) < 1) { + foundCeiling = lookForCeiling(foundCeiling, foundRoot, curPath); + continue; + } if (!absoluteRequest) { const exportsResolved = resolveExports(curPath, request); @@ -581,6 +615,8 @@ Module._findPath = function(request, paths, isMain) { Module._pathCache[cacheKey] = filename; return filename; } + + foundCeiling = lookForCeiling(foundCeiling, foundRoot, curPath); } return false; diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js index c6abfc9b2b3ecd..8eef73f3dea198 100644 --- a/lib/internal/modules/esm/resolve.js +++ b/lib/internal/modules/esm/resolve.js @@ -32,6 +32,9 @@ const { statSync, Stats, } = require('fs'); +let debug = require('internal/util/debuglog').debuglog('esm', (fn) => { + debug = fn; +}); const { getOptionValue } = require('internal/options'); // Do not eagerly grab .manifest, it may be in TDZ const policy = getOptionValue('--experimental-policy') ? @@ -235,6 +238,12 @@ function getPackageScopeConfig(resolved) { resolved); if (packageConfig.exists) return packageConfig; + const ceilingURL = new URL('./node_ceiling', packageJSONUrl); + if (fileExists(ceilingURL)) { + debug('found node_ceiling at', ceilingURL.href); + break; + } + const lastPackageJSONUrl = packageJSONUrl; packageJSONUrl = new URL('../package.json', packageJSONUrl); @@ -878,6 +887,12 @@ function packageResolve(specifier, base, conditions) { const stat = tryStatSync(StringPrototypeSlice(packageJSONPath, 0, packageJSONPath.length - 13)); if (!stat.isDirectory()) { + const ceilingURL = new URL(isScoped ? '../../../node_ceiling' : '../../node_ceiling', packageJSONUrl); + if (fileExists(ceilingURL)) { + debug('found node_ceiling at', ceilingURL.href); + break; + } + lastPath = packageJSONPath; packageJSONUrl = new URL((isScoped ? '../../../../node_modules/' : '../../../node_modules/') + @@ -904,8 +919,6 @@ function packageResolve(specifier, base, conditions) { // Cross-platform root check. } while (packageJSONPath.length !== lastPath.length); - // eslint can't handle the above code. - // eslint-disable-next-line no-unreachable throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base)); } diff --git a/test/common/README.md b/test/common/README.md index ef86f59c656f7b..08c9bd335a7fae 100644 --- a/test/common/README.md +++ b/test/common/README.md @@ -403,7 +403,8 @@ spawn(...common.pwdCommand, { stdio: ['pipe'] }); * `dir` [\][] default = \_\_dirname Throws an `AssertionError` if a `package.json` file exists in any ancestor -directory above `dir`. Such files may interfere with proper test functionality. +directory above `dir` but stops searching if a `node_ceiling` file is found +first. Such `package.json` files may interfere with proper test functionality. ### `runWithInvalidFD(func)` diff --git a/test/common/index.js b/test/common/index.js index 7d6fed2f32007c..194046df35efdd 100644 --- a/test/common/index.js +++ b/test/common/index.js @@ -834,6 +834,10 @@ function requireNoPackageJSONAbove(dir = __dirname) { 'This test shouldn\'t load properties from a package.json above ' + `its file location. Found package.json at ${possiblePackage}.`); } + const ceilingPath = path.join(possiblePackage, '../node_ceiling'); + if (fs.statSync(ceilingPath, { throwIfNoEntry: false })?.isFile() ?? false) { + break; + } lastPackage = possiblePackage; possiblePackage = path.join(possiblePackage, '..', '..', 'package.json'); } diff --git a/test/es-module/test-esm-node_ceiling.mjs b/test/es-module/test-esm-node_ceiling.mjs new file mode 100644 index 00000000000000..42cc9fbc0f6985 --- /dev/null +++ b/test/es-module/test-esm-node_ceiling.mjs @@ -0,0 +1,13 @@ +import * as common from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import assert from 'node:assert'; +import path from 'node:path'; + +// should not reject +await import(path.resolve(fixtures.path('/es-module-node_ceiling/nested-without-node_ceiling/find-dep.mjs'))); +await import(path.resolve(fixtures.path('/es-module-node_ceiling/find-dep.mjs'))); + +await assert.rejects( + import(path.resolve(fixtures.path('/es-module-node_ceiling/nested-with-node_ceiling/dep-not-found.mjs'))), + { code: 'ERR_MODULE_NOT_FOUND' }, +); diff --git a/test/es-module/test-esm-resolve-type.js b/test/es-module/test-esm-resolve-type.js index 05e908cd32fc34..41e994e9138bc5 100644 --- a/test/es-module/test-esm-resolve-type.js +++ b/test/es-module/test-esm-resolve-type.js @@ -38,6 +38,7 @@ try { */ [ [ '/es-modules/package-type-module/index.js', 'module' ], + [ '/es-modules/package-type-module/nested-with-node_ceiling/index.js', 'commonjs' ], [ '/es-modules/package-type-commonjs/index.js', 'commonjs' ], [ '/es-modules/package-without-type/index.js', 'commonjs' ], [ '/es-modules/package-without-pjson/index.js', 'commonjs' ], diff --git a/test/fixtures/cjs-loader-node_ceiling/find-dep.js b/test/fixtures/cjs-loader-node_ceiling/find-dep.js new file mode 100644 index 00000000000000..0af8a6d0fb586e --- /dev/null +++ b/test/fixtures/cjs-loader-node_ceiling/find-dep.js @@ -0,0 +1 @@ +require('dep'); diff --git a/test/fixtures/cjs-loader-node_ceiling/nested-with-node_ceiling/dep-not-found.js b/test/fixtures/cjs-loader-node_ceiling/nested-with-node_ceiling/dep-not-found.js new file mode 100644 index 00000000000000..0af8a6d0fb586e --- /dev/null +++ b/test/fixtures/cjs-loader-node_ceiling/nested-with-node_ceiling/dep-not-found.js @@ -0,0 +1 @@ +require('dep'); diff --git a/test/fixtures/cjs-loader-node_ceiling/nested-with-node_ceiling/node_ceiling b/test/fixtures/cjs-loader-node_ceiling/nested-with-node_ceiling/node_ceiling new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/fixtures/cjs-loader-node_ceiling/nested-without-node_ceiling/find-dep.js b/test/fixtures/cjs-loader-node_ceiling/nested-without-node_ceiling/find-dep.js new file mode 100644 index 00000000000000..0af8a6d0fb586e --- /dev/null +++ b/test/fixtures/cjs-loader-node_ceiling/nested-without-node_ceiling/find-dep.js @@ -0,0 +1 @@ +require('dep'); diff --git a/test/fixtures/cjs-loader-node_ceiling/node_modules/dep/index.js b/test/fixtures/cjs-loader-node_ceiling/node_modules/dep/index.js new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/fixtures/cjs-loader-node_ceiling/package-not-found-due-to-node_ceiling/dir-with-node_ceiling/dep-not-found.js b/test/fixtures/cjs-loader-node_ceiling/package-not-found-due-to-node_ceiling/dir-with-node_ceiling/dep-not-found.js new file mode 100644 index 00000000000000..89db4d7ce753a8 --- /dev/null +++ b/test/fixtures/cjs-loader-node_ceiling/package-not-found-due-to-node_ceiling/dir-with-node_ceiling/dep-not-found.js @@ -0,0 +1 @@ +require('#dep'); diff --git a/test/fixtures/cjs-loader-node_ceiling/package-not-found-due-to-node_ceiling/dir-with-node_ceiling/node_ceiling b/test/fixtures/cjs-loader-node_ceiling/package-not-found-due-to-node_ceiling/dir-with-node_ceiling/node_ceiling new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/fixtures/cjs-loader-node_ceiling/package-not-found-due-to-node_ceiling/node_modules/renamed/index.js b/test/fixtures/cjs-loader-node_ceiling/package-not-found-due-to-node_ceiling/node_modules/renamed/index.js new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/fixtures/cjs-loader-node_ceiling/package-not-found-due-to-node_ceiling/package.json b/test/fixtures/cjs-loader-node_ceiling/package-not-found-due-to-node_ceiling/package.json new file mode 100644 index 00000000000000..df4239a1e6a2f4 --- /dev/null +++ b/test/fixtures/cjs-loader-node_ceiling/package-not-found-due-to-node_ceiling/package.json @@ -0,0 +1,7 @@ +{ + "imports": { + "#dep": { + "node": "renamed" + } + } +} diff --git a/test/fixtures/es-module-node_ceiling/find-dep.mjs b/test/fixtures/es-module-node_ceiling/find-dep.mjs new file mode 100644 index 00000000000000..407564372867dc --- /dev/null +++ b/test/fixtures/es-module-node_ceiling/find-dep.mjs @@ -0,0 +1 @@ +import dep from 'dep'; diff --git a/test/fixtures/es-module-node_ceiling/nested-with-node_ceiling/dep-not-found.mjs b/test/fixtures/es-module-node_ceiling/nested-with-node_ceiling/dep-not-found.mjs new file mode 100644 index 00000000000000..407564372867dc --- /dev/null +++ b/test/fixtures/es-module-node_ceiling/nested-with-node_ceiling/dep-not-found.mjs @@ -0,0 +1 @@ +import dep from 'dep'; diff --git a/test/fixtures/es-module-node_ceiling/nested-with-node_ceiling/node_ceiling b/test/fixtures/es-module-node_ceiling/nested-with-node_ceiling/node_ceiling new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/fixtures/es-module-node_ceiling/nested-without-node_ceiling/find-dep.mjs b/test/fixtures/es-module-node_ceiling/nested-without-node_ceiling/find-dep.mjs new file mode 100644 index 00000000000000..407564372867dc --- /dev/null +++ b/test/fixtures/es-module-node_ceiling/nested-without-node_ceiling/find-dep.mjs @@ -0,0 +1 @@ +import dep from 'dep'; diff --git a/test/fixtures/es-module-node_ceiling/node_modules/dep/index.js b/test/fixtures/es-module-node_ceiling/node_modules/dep/index.js new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/fixtures/es-modules/package-type-module/nested-with-node_ceiling/index.js b/test/fixtures/es-modules/package-type-module/nested-with-node_ceiling/index.js new file mode 100644 index 00000000000000..35eb3bf1d38b79 --- /dev/null +++ b/test/fixtures/es-modules/package-type-module/nested-with-node_ceiling/index.js @@ -0,0 +1,2 @@ +// No package.json -> should still be CommonJS as there is node_ceiling +module.exports = 42; diff --git a/test/fixtures/es-modules/package-type-module/nested-with-node_ceiling/node_ceiling b/test/fixtures/es-modules/package-type-module/nested-with-node_ceiling/node_ceiling new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/fixtures/test-module-loading-globalpaths/local-pkg/nested-with-node_ceiling/node_ceiling b/test/fixtures/test-module-loading-globalpaths/local-pkg/nested-with-node_ceiling/node_ceiling new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/fixtures/test-module-loading-globalpaths/local-pkg/nested-with-node_ceiling/test.js b/test/fixtures/test-module-loading-globalpaths/local-pkg/nested-with-node_ceiling/test.js new file mode 100644 index 00000000000000..8054983e992ce8 --- /dev/null +++ b/test/fixtures/test-module-loading-globalpaths/local-pkg/nested-with-node_ceiling/test.js @@ -0,0 +1,2 @@ +'use strict'; +console.log(require('foo').string); diff --git a/test/parallel/test-cjs-loader-node_ceiling.js b/test/parallel/test-cjs-loader-node_ceiling.js new file mode 100644 index 00000000000000..a78a02e9fd658b --- /dev/null +++ b/test/parallel/test-cjs-loader-node_ceiling.js @@ -0,0 +1,27 @@ +'use strict'; + +require('../common'); +const fixtures = require('../common/fixtures'); +const path = require('node:path'); +const assert = require('node:assert'); + +// should not throw +require(path.resolve(fixtures.path('/cjs-loader-node_ceiling/nested-without-node_ceiling/find-dep.js'))); +require(path.resolve(fixtures.path('/cjs-loader-node_ceiling/find-dep.js'))); + +assert.throws( + () => { + require(path.resolve(fixtures.path('/cjs-loader-node_ceiling/nested-with-node_ceiling/dep-not-found.js'))); + }, { + code: 'MODULE_NOT_FOUND', + } +); + +assert.throws( + () => { + require(path.resolve(fixtures.path('/cjs-loader-node_ceiling/package-not-found-due-to-node_ceiling/dir-with-node_ceiling/dep-not-found.js'))); + }, { + code: 'MODULE_NOT_FOUND', + } +); + diff --git a/test/parallel/test-module-loading-globalpaths.js b/test/parallel/test-module-loading-globalpaths.js index 5d6a104f29c9d7..7323d7f1ecf4bc 100644 --- a/test/parallel/test-module-loading-globalpaths.js +++ b/test/parallel/test-module-loading-globalpaths.js @@ -101,4 +101,12 @@ if (process.argv[2] === 'child') { [ path.join(localDir, 'test.js') ], { encoding: 'utf8', env: env }); assert.strictEqual(child.trim(), 'local'); + + // Test module in local folder above node_ceiling is not loaded but NODE_PATH is still loaded + env.HOME = env.USERPROFILE = bothHomeDir; + env.NODE_PATH = path.join(testFixturesDir, 'node_path'); + const child2 = child_process.execFileSync(testExecPath, + [ path.join(localDir, 'nested-with-node_ceiling', 'test.js') ], + { encoding: 'utf8', env: env }); + assert.strictEqual(child2.trim(), '$NODE_PATH'); }