diff --git a/.eslintignore b/.eslintignore index 153ac6e24f731e..6f3d86e6bfac8a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,5 +8,7 @@ tools/lint-md/lint-md.mjs benchmark/tmp benchmark/fixtures doc/**/*.js +doc/changelogs/CHANGELOG_v1*.md +!doc/changelogs/CHANGELOG_v18.md !doc/api_assets/*.js !.eslintrc.js diff --git a/.eslintrc.js b/.eslintrc.js index 71c772b3be9e5c..9c31bf3da17dbf 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,7 +18,7 @@ const hacks = [ 'eslint-plugin-jsdoc', 'eslint-plugin-markdown', '@babel/eslint-parser', - '@babel/plugin-syntax-import-assertions', + '@babel/plugin-syntax-import-attributes', ]; Module._findPath = (request, paths, isMain) => { const r = ModuleFindPath(request, paths, isMain); @@ -44,7 +44,10 @@ module.exports = { parserOptions: { babelOptions: { plugins: [ - Module._findPath('@babel/plugin-syntax-import-assertions'), + [ + Module._findPath('@babel/plugin-syntax-import-attributes'), + { deprecatedAssertSyntax: true }, + ], ], }, requireConfigFile: false, @@ -53,10 +56,10 @@ module.exports = { overrides: [ { files: [ - 'test/es-module/test-esm-type-flag.js', - 'test/es-module/test-esm-type-flag-alias.js', '*.mjs', 'test/es-module/test-esm-example-loader.js', + 'test/es-module/test-esm-type-flag.js', + 'test/es-module/test-esm-type-flag-alias.js', ], parserOptions: { sourceType: 'module' }, }, @@ -111,6 +114,14 @@ module.exports = { }, ] }, }, + { + files: [ + 'lib/internal/modules/**/*.js', + ], + rules: { + 'curly': 'error', + }, + }, ], rules: { // ESLint built-in rules diff --git a/benchmark/esm/esm-loader-import.js b/benchmark/esm/esm-loader-import.js new file mode 100644 index 00000000000000..9967cd95275469 --- /dev/null +++ b/benchmark/esm/esm-loader-import.js @@ -0,0 +1,45 @@ +// Tests the impact on eager operations required for policies affecting +// general startup, does not test lazy operations +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const common = require('../common.js'); + +const tmpdir = require('../../test/common/tmpdir.js'); +const { pathToFileURL } = require('node:url'); + +const benchmarkDirectory = pathToFileURL(path.resolve(tmpdir.path, 'benchmark-import')); + +const configs = { + n: [1e3], + specifier: [ + 'data:text/javascript,{i}', + './relative-existing.js', + './relative-nonexistent.js', + 'node:prefixed-nonexistent', + 'node:os', + ], +}; + +const options = { + flags: ['--expose-internals'], +}; + +const bench = common.createBenchmark(main, configs, options); + +async function main(conf) { + tmpdir.refresh(); + + fs.mkdirSync(benchmarkDirectory, { recursive: true }); + fs.writeFileSync(new URL('./relative-existing.js', benchmarkDirectory), '\n'); + + bench.start(); + + for (let i = 0; i < conf.n; i++) { + try { + await import(new URL(conf.specifier.replace('{i}', i), benchmarkDirectory)); + } catch { /* empty */ } + } + + bench.end(conf.n); +} diff --git a/doc/api/cli.md b/doc/api/cli.md index f1124ab9cdaddd..54f747d8aaa18e 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -25,14 +25,16 @@ For more info about `node inspect`, see the [debugger][] documentation. The program entry point is a specifier-like string. If the string is not an absolute path, it's resolved as a relative path from the current working -directory. That path is then resolved by [CommonJS][] module loader. If no -corresponding file is found, an error is thrown. +directory. That path is then resolved by [CommonJS][] module loader, or by the +[ES module loader][Modules loaders] if [`--experimental-default-type=module`][] +is passed. If no corresponding file is found, an error is thrown. -If a file is found, its path will be passed to the [ECMAScript module loader][] -under any of the following conditions: +If a file is found, its path will be passed to the +[ES module loader][Modules loaders] under any of the following conditions: * The program was started with a command-line flag that forces the entry - point to be loaded with ECMAScript module loader. + point to be loaded with ECMAScript module loader, such as `--import` or + [`--experimental-default-type=module`][]. * The file has an `.mjs` extension. * The file does not have a `.cjs` extension, and the nearest parent `package.json` file contains a top-level [`"type"`][] field with a value of @@ -43,10 +45,11 @@ Otherwise, the file is loaded using the CommonJS module loader. See ### ECMAScript modules loader entry point caveat -When loading [ECMAScript module loader][] loads the program entry point, the `node` -command will only accept as input only files with `.js`, `.mjs`, or `.cjs` -extensions; and with `.wasm` extensions when -[`--experimental-wasm-modules`][] is enabled. +When loading, the [ES module loader][Modules loaders] loads the program +entry point, the `node` command will accept as input only files with `.js`, +`.mjs`, or `.cjs` extensions; with `.wasm` extensions when +[`--experimental-wasm-modules`][] is enabled; and with no extension when +[`--experimental-default-type=module`][] is passed. ## Options @@ -366,15 +369,54 @@ added: v17.6.0 Expose the [Web Crypto API][] on the global scope. +### `--experimental-default-type=type` + + + +> Stability: 1.0 - Early development + +Define which module system, `module` or `commonjs`, to use for the following: + +* String input provided via `--eval` or STDIN, if `--input-type` is unspecified. + +* Files ending in `.js` or with no extension, if there is no `package.json` file + present in the same folder or any parent folder. + +* Files ending in `.js` or with no extension, if the nearest parent + `package.json` field lacks a `"type"` field; unless the `package.json` folder + or any parent folder is inside a `node_modules` folder. + +In other words, `--experimental-default-type=module` flips all the places where +Node.js currently defaults to CommonJS to instead default to ECMAScript modules, +with the exception of folders and subfolders below `node_modules`, for backward +compatibility. + +Under `--experimental-default-type=module` and `--experimental-wasm-modules`, +files with no extension will be treated as WebAssembly if they begin with the +WebAssembly magic number (`\0asm`); otherwise they will be treated as ES module +JavaScript. + ### `--experimental-import-meta-resolve` -Enable experimental `import.meta.resolve()` support. +Enable experimental `import.meta.resolve()` parent URL support, which allows +passing a second `parentURL` argument for contextual resolution. + +Previously gated the entire `import.meta.resolve` feature. ### `--experimental-loader=module` @@ -387,7 +429,11 @@ changes: `--experimental-loader`. --> -Specify the `module` of a custom experimental [ECMAScript module loader][]. +> This flag is discouraged and may be removed in a future version of Node.js. +> Please use +> [`--import` with `register()`][module customization hooks: enabling] instead. + +Specify the `module` containing exported [module customization hooks][]. `module` may be any string accepted as an [`import` specifier][]. ### `--experimental-network-imports` @@ -1910,6 +1956,7 @@ Node.js options that are allowed are: * `--enable-network-family-autoselection` * `--enable-source-maps` * `--experimental-abortcontroller` +* `--experimental-default-type` * `--experimental-global-customevent` * `--experimental-global-webcrypto` * `--experimental-import-meta-resolve` @@ -2357,8 +2404,9 @@ done [CommonJS module]: modules.md [CustomEvent Web API]: https://dom.spec.whatwg.org/#customevent [ECMAScript module]: esm.md#modules-ecmascript-modules -[ECMAScript module loader]: esm.md#loaders [Fetch API]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API +[Module customization hooks]: module.md#customization-hooks +[Module customization hooks: enabling]: module.md#enabling [Modules loaders]: packages.md#modules-loaders [Node.js issue tracker]: https://github.com/nodejs/node/issues [OSSL_PROVIDER-legacy]: https://www.openssl.org/docs/man3.0/man7/OSSL_PROVIDER-legacy.html @@ -2372,6 +2420,7 @@ done [`"type"`]: packages.md#type [`--cpu-prof-dir`]: #--cpu-prof-dir [`--diagnostic-dir`]: #--diagnostic-dirdirectory +[`--experimental-default-type=module`]: #--experimental-default-typetype [`--experimental-wasm-modules`]: #--experimental-wasm-modules [`--heap-prof-dir`]: #--heap-prof-dir [`--import`]: #--importmodule diff --git a/doc/api/errors.md b/doc/api/errors.md index c589fcd375136d..ecfb20864c37d1 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -1762,7 +1762,8 @@ added: - v16.14.0 --> -An import assertion has failed, preventing the specified module to be imported. +An import `type` attribute was provided, but the specified module is of a +different type. @@ -1774,7 +1775,7 @@ added: - v16.14.0 --> -An import assertion is missing, preventing the specified module to be imported. +An import attribute is missing, preventing the specified module to be imported. @@ -1786,7 +1787,17 @@ added: - v16.14.0 --> -An import assertion is not supported by this version of Node.js. +An import attribute is not supported by this version of Node.js. + + + +### `ERR_IMPORT_ATTRIBUTE_UNSUPPORTED` + + + +An import attribute is not supported by this version of Node.js. diff --git a/doc/api/esm.md b/doc/api/esm.md index 0dd17d1950dd94..88d58735707123 100644 --- a/doc/api/esm.md +++ b/doc/api/esm.md @@ -7,21 +7,24 @@ @@ -230,17 +234,28 @@ absolute URL strings. import fs from 'node:fs/promises'; ``` -## Import assertions + + +## Import attributes -> Stability: 1 - Experimental +> Stability: 1.1 - Active development -The [Import Assertions proposal][] adds an inline syntax for module import +> This feature was previously named "Import assertions", and using the `assert` +> keyword instead of `with`. Because the version of V8 on this release line does +> not support the `with` keyword, you need to keep using `assert` to support +> this version of Node.js. + +The [Import Attributes proposal][] adds an inline syntax for module import statements to pass on more information alongside the module specifier. ```js @@ -250,10 +265,10 @@ const { default: barData } = await import('./bar.json', { assert: { type: 'json' } }); ``` -Node.js supports the following `type` values, for which the assertion is +Node.js supports the following `type` values, for which the attribute is mandatory: -| Assertion `type` | Needed for | +| Attribute `type` | Needed for | | ---------------- | ---------------- | | `'json'` | [JSON modules][] | @@ -318,13 +333,24 @@ import { readFileSync } from 'node:fs'; const buffer = readFileSync(new URL('./data.proto', import.meta.url)); ``` -### `import.meta.resolve(specifier[, parent])` +### `import.meta.resolve(specifier)` - -> Stability: 1 - Experimental - -This feature is only available with the `--experimental-import-meta-resolve` -command flag enabled. +> Stability: 1.2 - Release candidate -* `specifier` {string} The module specifier to resolve relative to `parent`. -* `parent` {string|URL} The absolute parent module URL to resolve from. If none - is specified, the value of `import.meta.url` is used as the default. -* Returns: {Promise} +* `specifier` {string} The module specifier to resolve relative to the + current module. +* Returns: {string} The absolute URL string that the specifier would resolve to. -Provides a module-relative resolution function scoped to each module, returning -the URL string. - - +[`import.meta.resolve`][] is a module-relative resolution function scoped to +each module, returning the URL string. ```js -const dependencyAsset = await import.meta.resolve('component-lib/asset.css'); +const dependencyAsset = import.meta.resolve('component-lib/asset.css'); +// file:///app/node_modules/component-lib/asset.css +import.meta.resolve('./dep.js'); +// file:///app/dep.js ``` -`import.meta.resolve` also accepts a second argument which is the parent module -from which to resolve from: +All features of the Node.js module resolution are supported. Dependency +resolutions are subject to the permitted exports resolutions within the package. - +**Caveats**: -```js -await import.meta.resolve('./dep', import.meta.url); -``` +* This can result in synchronous file-system operations, which + can impact performance similarly to `require.resolve`. +* This feature is not available within custom loaders (it would + create a deadlock). + +**Non-standard API**: -This function is asynchronous because the ES module resolver in Node.js is -allowed to be asynchronous. +When using the `--experimental-import-meta-resolve` flag, that function accepts +a second argument: + +* `parent` {string|URL} An optional absolute parent module URL to resolve from. + **Default:** `import.meta.url` ## Interoperability with CommonJS @@ -497,8 +526,8 @@ They can instead be loaded with [`module.createRequire()`][] or Relative resolution can be handled via `new URL('./local', import.meta.url)`. -For a complete `require.resolve` replacement, there is a flagged experimental -[`import.meta.resolve`][] API. +For a complete `require.resolve` replacement, there is the +[import.meta.resolve][] API. Alternatively `module.createRequire()` can be used. @@ -509,8 +538,8 @@ if this behavior is desired. #### No `require.extensions` -`require.extensions` is not used by `import`. The expectation is that loader -hooks can provide this workflow in the future. +`require.extensions` is not used by `import`. Module customization hooks can +provide a replacement. #### No `require.cache` @@ -529,7 +558,7 @@ JSON files can be referenced by `import`: import packageConfig from './package.json' assert { type: 'json' }; ``` -The `assert { type: 'json' }` syntax is mandatory; see [Import Assertions][]. +The `assert { type: 'json' }` syntax is mandatory; see [Import Attributes][]. The imported JSON only exposes a `default` export. There is no support for named exports. A cache entry is created in the CommonJS cache to avoid duplication. @@ -678,533 +707,8 @@ of Node.js applications. ## Loaders - - -> Stability: 1 - Experimental - -> This API is currently being redesigned and will still change. - - - -To customize the default module resolution, loader hooks can optionally be -provided via a `--experimental-loader ./loader-name.mjs` argument to Node.js. - -When hooks are used they apply to each subsequent loader, the entry point, and -all `import` calls. They won't apply to `require` calls; those still follow -[CommonJS][] rules. - -Loaders follow the pattern of `--require`: - -```console -node \ - --experimental-loader unpkg \ - --experimental-loader http-to-https \ - --experimental-loader cache-buster -``` - -These are called in the following sequence: `cache-buster` calls -`http-to-https` which calls `unpkg`. - -### Hooks - -Hooks are part of a chain, even if that chain consists of only one custom -(user-provided) hook and the default hook, which is always present. Hook -functions nest: each one must always return a plain object, and chaining happens -as a result of each function calling `next()`, which is a reference -to the subsequent loader’s hook. - -A hook that returns a value lacking a required property triggers an exception. -A hook that returns without calling `next()` _and_ without returning -`shortCircuit: true` also triggers an exception. These errors are to help -prevent unintentional breaks in the chain. - -#### `resolve(specifier, context, nextResolve)` - - - -> The loaders API is being redesigned. This hook may disappear or its -> signature may change. Do not rely on the API described below. - -* `specifier` {string} -* `context` {Object} - * `conditions` {string\[]} Export conditions of the relevant `package.json` - * `importAssertions` {Object} An object whose key-value pairs represent the - assertions for the module to import - * `parentURL` {string|undefined} The module importing this one, or undefined - if this is the Node.js entry point -* `nextResolve` {Function} The subsequent `resolve` hook in the chain, or the - Node.js default `resolve` hook after the last user-supplied `resolve` hook - * `specifier` {string} - * `context` {Object} -* Returns: {Object} - * `format` {string|null|undefined} A hint to the load hook (it might be - ignored) - `'builtin' | 'commonjs' | 'json' | 'module' | 'wasm'` - * `importAssertions` {Object|undefined} The import assertions to use when - caching the module (optional; if excluded the input will be used) - * `shortCircuit` {undefined|boolean} A signal that this hook intends to - terminate the chain of `resolve` hooks. **Default:** `false` - * `url` {string} The absolute URL to which this input resolves - -The `resolve` hook chain is responsible for telling Node.js where to find and -how to cache a given `import` statement or expression. It can optionally return -its format (such as `'module'`) as a hint to the `load` hook. If a format is -specified, the `load` hook is ultimately responsible for providing the final -`format` value (and it is free to ignore the hint provided by `resolve`); if -`resolve` provides a `format`, a custom `load` hook is required even if only to -pass the value to the Node.js default `load` hook. - -Import type assertions are part of the cache key for saving loaded modules into -the internal module cache. The `resolve` hook is responsible for -returning an `importAssertions` object if the module should be cached with -different assertions than were present in the source code. - -The `conditions` property in `context` is an array of conditions for -[package exports conditions][Conditional Exports] that apply to this resolution -request. They can be used for looking up conditional mappings elsewhere or to -modify the list when calling the default resolution logic. - -The current [package exports conditions][Conditional Exports] are always in -the `context.conditions` array passed into the hook. To guarantee _default -Node.js module specifier resolution behavior_ when calling `defaultResolve`, the -`context.conditions` array passed to it _must_ include _all_ elements of the -`context.conditions` array originally passed into the `resolve` hook. - -```js -export async function resolve(specifier, context, nextResolve) { - const { parentURL = null } = context; - - if (Math.random() > 0.5) { // Some condition. - // For some or all specifiers, do some custom logic for resolving. - // Always return an object of the form {url: }. - return { - shortCircuit: true, - url: parentURL ? - new URL(specifier, parentURL).href : - new URL(specifier).href, - }; - } - - if (Math.random() < 0.5) { // Another condition. - // When calling `defaultResolve`, the arguments can be modified. In this - // case it's adding another value for matching conditional exports. - return nextResolve(specifier, { - ...context, - conditions: [...context.conditions, 'another-condition'], - }); - } - - // Defer to the next hook in the chain, which would be the - // Node.js default resolve if this is the last user-specified loader. - return nextResolve(specifier); -} -``` - -#### `load(url, context, nextLoad)` - - - -> The loaders API is being redesigned. This hook may disappear or its -> signature may change. Do not rely on the API described below. - -> In a previous version of this API, this was split across 3 separate, now -> deprecated, hooks (`getFormat`, `getSource`, and `transformSource`). - -* `url` {string} The URL returned by the `resolve` chain -* `context` {Object} - * `conditions` {string\[]} Export conditions of the relevant `package.json` - * `format` {string|null|undefined} The format optionally supplied by the - `resolve` hook chain - * `importAssertions` {Object} -* `nextLoad` {Function} The subsequent `load` hook in the chain, or the - Node.js default `load` hook after the last user-supplied `load` hook - * `specifier` {string} - * `context` {Object} -* Returns: {Object} - * `format` {string} - * `shortCircuit` {undefined|boolean} A signal that this hook intends to - terminate the chain of `resolve` hooks. **Default:** `false` - * `source` {string|ArrayBuffer|TypedArray} The source for Node.js to evaluate - -The `load` hook provides a way to define a custom method of determining how -a URL should be interpreted, retrieved, and parsed. It is also in charge of -validating the import assertion. - -The final value of `format` must be one of the following: - -| `format` | Description | Acceptable types for `source` returned by `load` | -| ------------ | ------------------------------ | ----------------------------------------------------- | -| `'builtin'` | Load a Node.js builtin module | Not applicable | -| `'commonjs'` | Load a Node.js CommonJS module | Not applicable | -| `'json'` | Load a JSON file | { [`string`][], [`ArrayBuffer`][], [`TypedArray`][] } | -| `'module'` | Load an ES module | { [`string`][], [`ArrayBuffer`][], [`TypedArray`][] } | -| `'wasm'` | Load a WebAssembly module | { [`ArrayBuffer`][], [`TypedArray`][] } | - -The value of `source` is ignored for type `'builtin'` because currently it is -not possible to replace the value of a Node.js builtin (core) module. The value -of `source` is ignored for type `'commonjs'` because the CommonJS module loader -does not provide a mechanism for the ES module loader to override the -[CommonJS module return value](#commonjs-namespaces). This limitation might be -overcome in the future. - -> **Caveat**: The ESM `load` hook and namespaced exports from CommonJS modules -> are incompatible. Attempting to use them together will result in an empty -> object from the import. This may be addressed in the future. - -> These types all correspond to classes defined in ECMAScript. - -* The specific [`ArrayBuffer`][] object is a [`SharedArrayBuffer`][]. -* The specific [`TypedArray`][] object is a [`Uint8Array`][]. - -If the source value of a text-based format (i.e., `'json'`, `'module'`) -is not a string, it is converted to a string using [`util.TextDecoder`][]. - -The `load` hook provides a way to define a custom method for retrieving the -source code of an ES module specifier. This would allow a loader to potentially -avoid reading files from disk. It could also be used to map an unrecognized -format to a supported one, for example `yaml` to `module`. - -```js -export async function load(url, context, nextLoad) { - const { format } = context; - - if (Math.random() > 0.5) { // Some condition - /* - For some or all URLs, do some custom logic for retrieving the source. - Always return an object of the form { - format: , - source: , - }. - */ - return { - format, - shortCircuit: true, - source: '...', - }; - } - - // Defer to the next hook in the chain. - return nextLoad(url); -} -``` - -In a more advanced scenario, this can also be used to transform an unsupported -source to a supported one (see [Examples](#examples) below). - -#### `globalPreload()` - - - -> The loaders API is being redesigned. This hook may disappear or its -> signature may change. Do not rely on the API described below. - -> In a previous version of this API, this hook was named -> `getGlobalPreloadCode`. - -* `context` {Object} Information to assist the preload code - * `port` {MessagePort} -* Returns: {string} Code to run before application startup - -Sometimes it might be necessary to run some code inside of the same global -scope that the application runs in. This hook allows the return of a string -that is run as a sloppy-mode script on startup. - -Similar to how CommonJS wrappers work, the code runs in an implicit function -scope. The only argument is a `require`-like function that can be used to load -builtins like "fs": `getBuiltin(request: string)`. - -If the code needs more advanced `require` features, it has to construct -its own `require` using `module.createRequire()`. - -```js -export function globalPreload(context) { - return `\ -globalThis.someInjectedProperty = 42; -console.log('I just set some globals!'); - -const { createRequire } = getBuiltin('module'); -const { cwd } = getBuiltin('process'); - -const require = createRequire(cwd() + '/'); -// [...] -`; -} -``` - -In order to allow communication between the application and the loader, another -argument is provided to the preload code: `port`. This is available as a -parameter to the loader hook and inside of the source text returned by the hook. -Some care must be taken in order to properly call [`port.ref()`][] and -[`port.unref()`][] to prevent a process from being in a state where it won't -close normally. - -```js -/** - * This example has the application context send a message to the loader - * and sends the message back to the application context - */ -export function globalPreload({ port }) { - port.on('message', (msg) => { - port.postMessage(msg); - }); - return `\ - port.postMessage('console.log("I went to the Loader and back");'); - port.on('message', (data) => { - eval(data); - }); - `; -} -``` - -### Examples - -The various loader hooks can be used together to accomplish wide-ranging -customizations of the Node.js code loading and evaluation behaviors. - -#### HTTPS loader - -In current Node.js, specifiers starting with `https://` are experimental (see -[HTTPS and HTTP imports][]). - -The loader below registers hooks to enable rudimentary support for such -specifiers. While this may seem like a significant improvement to Node.js core -functionality, there are substantial downsides to actually using this loader: -performance is much slower than loading files from disk, there is no caching, -and there is no security. - -```js -// https-loader.mjs -import { get } from 'node:https'; - -export function load(url, context, nextLoad) { - // For JavaScript to be loaded over the network, we need to fetch and - // return it. - if (url.startsWith('https://')) { - return new Promise((resolve, reject) => { - get(url, (res) => { - let data = ''; - res.setEncoding('utf8'); - res.on('data', (chunk) => data += chunk); - res.on('end', () => resolve({ - // This example assumes all network-provided JavaScript is ES module - // code. - format: 'module', - shortCircuit: true, - source: data, - })); - }).on('error', (err) => reject(err)); - }); - } - - // Let Node.js handle all other URLs. - return nextLoad(url); -} -``` - -```js -// main.mjs -import { VERSION } from 'https://coffeescript.org/browser-compiler-modern/coffeescript.js'; - -console.log(VERSION); -``` - -With the preceding loader, running -`node --experimental-loader ./https-loader.mjs ./main.mjs` -prints the current version of CoffeeScript per the module at the URL in -`main.mjs`. - -#### Transpiler loader - -Sources that are in formats Node.js doesn't understand can be converted into -JavaScript using the [`load` hook][load hook]. - -This is less performant than transpiling source files before running -Node.js; a transpiler loader should only be used for development and testing -purposes. - -```js -// coffeescript-loader.mjs -import { readFile } from 'node:fs/promises'; -import { dirname, extname, resolve as resolvePath } from 'node:path'; -import { cwd } from 'node:process'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import CoffeeScript from 'coffeescript'; - -const baseURL = pathToFileURL(`${cwd()}/`).href; - -export async function load(url, context, nextLoad) { - if (extensionsRegex.test(url)) { - // Now that we patched resolve to let CoffeeScript URLs through, we need to - // tell Node.js what format such URLs should be interpreted as. Because - // CoffeeScript transpiles into JavaScript, it should be one of the two - // JavaScript formats: 'commonjs' or 'module'. - - // CoffeeScript files can be either CommonJS or ES modules, so we want any - // CoffeeScript file to be treated by Node.js the same as a .js file at the - // same location. To determine how Node.js would interpret an arbitrary .js - // file, search up the file system for the nearest parent package.json file - // and read its "type" field. - const format = await getPackageType(url); - // When a hook returns a format of 'commonjs', `source` is ignored. - // To handle CommonJS files, a handler needs to be registered with - // `require.extensions` in order to process the files with the CommonJS - // loader. Avoiding the need for a separate CommonJS handler is a future - // enhancement planned for ES module loaders. - if (format === 'commonjs') { - return { - format, - shortCircuit: true, - }; - } - - const { source: rawSource } = await nextLoad(url, { ...context, format }); - // This hook converts CoffeeScript source code into JavaScript source code - // for all imported CoffeeScript files. - const transformedSource = coffeeCompile(rawSource.toString(), url); - - return { - format, - shortCircuit: true, - source: transformedSource, - }; - } - - // Let Node.js handle all other URLs. - return nextLoad(url); -} - -async function getPackageType(url) { - // `url` is only a file path during the first iteration when passed the - // resolved url from the load() hook - // an actual file path from load() will contain a file extension as it's - // required by the spec - // this simple truthy check for whether `url` contains a file extension will - // work for most projects but does not cover some edge-cases (such as - // extensionless files or a url ending in a trailing space) - const isFilePath = !!extname(url); - // If it is a file path, get the directory it's in - const dir = isFilePath ? - dirname(fileURLToPath(url)) : - url; - // Compose a file path to a package.json in the same directory, - // which may or may not exist - const packagePath = resolvePath(dir, 'package.json'); - // Try to read the possibly nonexistent package.json - const type = await readFile(packagePath, { encoding: 'utf8' }) - .then((filestring) => JSON.parse(filestring).type) - .catch((err) => { - if (err?.code !== 'ENOENT') console.error(err); - }); - // Ff package.json existed and contained a `type` field with a value, voila - if (type) return type; - // Otherwise, (if not at the root) continue checking the next directory up - // If at the root, stop and return false - return dir.length > 1 && getPackageType(resolvePath(dir, '..')); -} -``` - -```coffee -# main.coffee -import { scream } from './scream.coffee' -console.log scream 'hello, world' - -import { version } from 'node:process' -console.log "Brought to you by Node.js version #{version}" -``` - -```coffee -# scream.coffee -export scream = (str) -> str.toUpperCase() -``` - -With the preceding loader, running -`node --experimental-loader ./coffeescript-loader.mjs main.coffee` -causes `main.coffee` to be turned into JavaScript after its source code is -loaded from disk but before Node.js executes it; and so on for any `.coffee`, -`.litcoffee` or `.coffee.md` files referenced via `import` statements of any -loaded file. - -#### "import map" loader - -The previous two loaders defined `load` hooks. This is an example of a loader -that does its work via the `resolve` hook. This loader reads an -`import-map.json` file that specifies which specifiers to override to another -URL (this is a very simplistic implemenation of a small subset of the -"import maps" specification). - -```js -// import-map-loader.js -import fs from 'node:fs/promises'; - -const { imports } = JSON.parse(await fs.readFile('import-map.json')); - -export async function resolve(specifier, context, nextResolve) { - if (Object.hasOwn(imports, specifier)) { - return nextResolve(imports[specifier], context); - } - - return nextResolve(specifier, context); -} -``` - -Let's assume we have these files: - -```js -// main.js -import 'a-module'; -``` - -```json -// import-map.json -{ - "imports": { - "a-module": "./some-module.js" - } -} -``` - -```js -// some-module.js -console.log('some module!'); -``` - -If you run `node --experimental-loader ./import-map-loader.js main.js` -the output will be `some module!`. +The former Loaders documentation is now at +[Modules: Customization hooks][Module customization hooks]. ## Resolution and loading algorithm @@ -1515,8 +1019,12 @@ _isImports_, _conditions_) > 5. Let _packageURL_ be the result of **LOOKUP\_PACKAGE\_SCOPE**(_url_). > 6. Let _pjson_ be the result of **READ\_PACKAGE\_JSON**(_packageURL_). > 7. If _pjson?.type_ exists and is _"module"_, then -> 1. If _url_ ends in _".js"_, then -> 1. Return _"module"_. +> 1. If _url_ ends in _".js"_ or has no file extension, then +> 1. If `--experimental-wasm-modules` is enabled and the file at _url_ +> contains the header for a WebAssembly module, then +> 1. Return _"wasm"_. +> 2. Otherwise, +> 1. Return _"module"_. > 2. Return **undefined**. > 8. Otherwise, > 1. Return **undefined**. @@ -1547,8 +1055,8 @@ _isImports_, _conditions_) > Stability: 1 - Experimental > Do not rely on this flag. We plan to remove it once the -> [Loaders API][] has advanced to the point that equivalent functionality can -> be achieved via custom loaders. +> [Module customization hooks][] have advanced to the point that equivalent +> functionality can be achieved via custom hooks. The current specifier resolution does not support all default behavior of the CommonJS loader. One of the behavior differences is automatic resolution @@ -1575,43 +1083,34 @@ success! [6.1.7 Array Index]: https://tc39.es/ecma262/#integer-index [Addons]: addons.md [CommonJS]: modules.md -[Conditional exports]: packages.md#conditional-exports [Core modules]: modules.md#core-modules [Determining module system]: packages.md#determining-module-system [Dynamic `import()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import [ES Module Integration Proposal for WebAssembly]: https://github.com/webassembly/esm-integration -[HTTPS and HTTP imports]: #https-and-http-imports -[Import Assertions]: #import-assertions -[Import Assertions proposal]: https://github.com/tc39/proposal-import-assertions +[Import Attributes]: #import-attributes +[Import Attributes proposal]: https://github.com/tc39/proposal-import-attributes [JSON modules]: #json-modules -[Loaders API]: #loaders +[Module customization hooks]: module.md#customization-hooks [Node.js Module Resolution And Loading Algorithm]: #resolution-algorithm-specification [Terminology]: #terminology [URL]: https://url.spec.whatwg.org/ [`"exports"`]: packages.md#exports [`"type"`]: packages.md#type +[`--experimental-default-type`]: cli.md#--experimental-default-typetype [`--input-type`]: cli.md#--input-typetype -[`ArrayBuffer`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer -[`SharedArrayBuffer`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer -[`TypedArray`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray -[`Uint8Array`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array [`data:` URLs]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs [`export`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export [`import()`]: #import-expressions -[`import.meta.resolve`]: #importmetaresolvespecifier-parent +[`import.meta.resolve`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta/resolve [`import.meta.url`]: #importmetaurl [`import`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import [`module.createRequire()`]: module.md#modulecreaterequirefilename [`module.syncBuiltinESMExports()`]: module.md#modulesyncbuiltinesmexports [`package.json`]: packages.md#nodejs-packagejson-field-definitions -[`port.ref()`]: https://nodejs.org/dist/latest-v17.x/docs/api/worker_threads.html#portref -[`port.unref()`]: https://nodejs.org/dist/latest-v17.x/docs/api/worker_threads.html#portunref [`process.dlopen`]: process.md#processdlopenmodule-filename-flags -[`string`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String -[`util.TextDecoder`]: util.md#class-utiltextdecoder [cjs-module-lexer]: https://github.com/nodejs/cjs-module-lexer/tree/1.2.2 -[custom https loader]: #https-loader -[load hook]: #loadurl-context-nextload +[custom https loader]: module.md#import-from-https +[import.meta.resolve]: #importmetaresolvespecifier [percent-encoded]: url.md#percent-encoding-in-urls [special scheme]: https://url.spec.whatwg.org/#special-scheme [status code]: process.md#exit-codes diff --git a/doc/api/fs.md b/doc/api/fs.md index 893eb7befb7fe5..711ed4a86da6ed 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -1153,6 +1153,9 @@ makeDirectory().catch(console.error); -* `prefix` {string} +* `prefix` {string|Buffer|URL} * `options` {string|Object} * `encoding` {string} **Default:** `'utf8'` * Returns: {Promise} Fulfills with a string containing the file system path @@ -3225,6 +3228,9 @@ See the POSIX mkdir(2) documentation for more details. -* `prefix` {string} +* `prefix` {string|Buffer|URL} * `options` {string|Object} * `encoding` {string} **Default:** `'utf8'` * `callback` {Function} @@ -5478,6 +5484,9 @@ See the POSIX mkdir(2) documentation for more details. -* `prefix` {string} +* `prefix` {string|Buffer|URL} * `options` {string|Object} * `encoding` {string} **Default:** `'utf8'` * Returns: {string} diff --git a/doc/api/module.md b/doc/api/module.md index cb0b27cb612be7..61776aefbebeef 100644 --- a/doc/api/module.md +++ b/doc/api/module.md @@ -78,6 +78,33 @@ isBuiltin('fs'); // true isBuiltin('wss'); // false ``` +### `module.register(specifier[, parentURL][, options])` + + + +> Stability: 1.1 - Active development + +* `specifier` {string|URL} Customization hooks to be registered; this should be + the same string that would be passed to `import()`, except that if it is + relative, it is resolved relative to `parentURL`. +* `parentURL` {string|URL} If you want to resolve `specifier` relative to a base + URL, such as `import.meta.url`, you can pass that URL here. **Default:** + `'data:'` +* `options` {Object} + * `data` {any} Any arbitrary, cloneable JavaScript value to pass into the + [`initialize`][] hook. + * `transferList` {Object\[]} [transferrable objects][] to be passed into the + `initialize` hook. + +Register a module that exports [hooks][] that customize Node.js module +resolution and loading behavior. See [Customization hooks][]. + ### `module.syncBuiltinESMExports()` + +> Stability: 1.1 - Active development + + + + + +### Enabling + +Module resolution and loading can be customized by registering a file which +exports a set of hooks. This can be done using the [`register`][] method +from `node:module`, which you can run before your application code by +using the `--import` flag: + +```bash +node --import ./register-hooks.js ./my-app.js +``` + +```mjs +// register-hooks.js +import { register } from 'node:module'; + +register('./hooks.mjs', import.meta.url); +``` + +```cjs +// register-hooks.js +const { register } = require('node:module'); +const { pathToFileURL } = require('node:url'); + +register('./hooks.mjs', pathToFileURL(__filename)); +``` + +The file passed to `--import` can also be an export from a dependency: + +```bash +node --import some-package/register ./my-app.js +``` + +Where `some-package` has an [`"exports"`][] field defining the `/register` +export to map to a file that calls `register()`, like the following `register-hooks.js` +example. + +Using `--import` ensures that the hooks are registered before any application +files are imported, including the entry point of the application. Alternatively, +`register` can be called from the entry point, but dynamic `import()` must be +used for any code that should be run after the hooks are registered: + +```mjs +import { register } from 'node:module'; + +register('http-to-https', import.meta.url); + +// Because this is a dynamic `import()`, the `http-to-https` hooks will run +// to handle `./my-app.js` and any other files it imports or requires. +await import('./my-app.js'); +``` + +```cjs +const { register } = require('node:module'); +const { pathToFileURL } = require('node:url'); + +register('http-to-https', pathToFileURL(__filename)); + +// Because this is a dynamic `import()`, the `http-to-https` hooks will run +// to handle `./my-app.js` and any other files it imports or requires. +import('./my-app.js'); +``` + +In this example, we are registering the `http-to-https` hooks, but they will +only be available for subsequently imported modules—in this case, `my-app.js` +and anything it references via `import` (and optionally `require`). If the +`import('./my-app.js')` had instead been a static `import './my-app.js'`, the +app would have _already_ been loaded **before** the `http-to-https` hooks were +registered. This due to the ES modules specification, where static imports are +evaluated from the leaves of the tree first, then back to the trunk. There can +be static imports _within_ `my-app.js`, which will not be evaluated until +`my-app.js` is dynamically imported. + +`my-app.js` can also be CommonJS. Customization hooks will run for any +modules that it references via `import` (and optionally `require`). + +Finally, if all you want to do is register hooks before your app runs and you +don't want to create a separate file for that purpose, you can pass a `data:` +URL to `--import`: + +```bash +node --import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("http-to-https", pathToFileURL("./"));' ./my-app.js +``` + +### Chaining + +It's possible to call `register` more than once: + +```mjs +// entrypoint.mjs +import { register } from 'node:module'; + +register('./first.mjs', import.meta.url); +register('./second.mjs', import.meta.url); +await import('./my-app.mjs'); +``` + +```cjs +// entrypoint.cjs +const { register } = require('node:module'); +const { pathToFileURL } = require('node:url'); + +const parentURL = pathToFileURL(__filename); +register('./first.mjs', parentURL); +register('./second.mjs', parentURL); +import('./my-app.mjs'); +``` + +In this example, the registered hooks will form chains. If both `first.mjs` and +`second.mjs` define a `resolve` hook, both will be called, in the order they +were registered. The same applies to all the other hooks. + +The registered hooks also affect `register` itself. In this example, +`second.mjs` will be resolved and loaded per the hooks registered by +`first.mjs`. This allows for things like writing hooks in non-JavaScript +languages, so long as an earlier registered loader is one that transpiles into +JavaScript. + +The `register` method cannot be called from within the module that defines the +hooks. + +### Communication with module customization hooks + +Module customization hooks run on a dedicated thread, separate from the main +thread that runs application code. This means mutating global variables won't +affect the other thread(s), and message channels must be used to communicate +between the threads. + +The `register` method can be used to pass data to an [`initialize`][] hook. The +data passed to the hook may include transferrable objects like ports. + +```mjs +import { register } from 'node:module'; +import { MessageChannel } from 'node:worker_threads'; + +// This example demonstrates how a message channel can be used to +// communicate with the hooks, by sending `port2` to the hooks. +const { port1, port2 } = new MessageChannel(); + +port1.on('message', (msg) => { + console.log(msg); +}); + +register('./my-hooks.mjs', { + parentURL: import.meta.url, + data: { number: 1, port: port2 }, + transferList: [port2], +}); +``` + +```cjs +const { register } = require('node:module'); +const { pathToFileURL } = require('node:url'); +const { MessageChannel } = require('node:worker_threads'); + +// This example showcases how a message channel can be used to +// communicate with the hooks, by sending `port2` to the hooks. +const { port1, port2 } = new MessageChannel(); + +port1.on('message', (msg) => { + console.log(msg); +}); + +register('./my-hooks.mjs', { + parentURL: pathToFileURL(__filename), + data: { number: 1, port: port2 }, + transferList: [port2], +}); +``` + +### Hooks + +The [`register`][] method can be used to register a module that exports a set of +hooks. The hooks are functions that are called by Node.js to customize the +module resolution and loading process. The exported functions must have specific +names and signatures, and they must be exported as named exports. + +```mjs +export async function initialize({ number, port }) { + // Receives data from `register`. +} + +export async function resolve(specifier, context, nextResolve) { + // Take an `import` or `require` specifier and resolve it to a URL. +} + +export async function load(url, context, nextLoad) { + // Take a resolved URL and return the source code to be evaluated. +} +``` + +Hooks are part of a chain, even if that chain consists of only one custom +(user-provided) hook and the default hook, which is always present. Hook +functions nest: each one must always return a plain object, and chaining happens +as a result of each function calling `next()`, which is a reference to +the subsequent loader's hook. + +A hook that returns a value lacking a required property triggers an exception. A +hook that returns without calling `next()` _and_ without returning +`shortCircuit: true` also triggers an exception. These errors are to help +prevent unintentional breaks in the chain. Return `shortCircuit: true` from a +hook to signal that the chain is intentionally ending at your hook. + +Hooks are run in a separate thread, isolated from the main thread where +application code runs. That means it is a different [realm][]. The hooks thread +may be terminated by the main thread at any time, so do not depend on +asynchronous operations (like `console.log`) to complete. + +#### `initialize()` + + + +> Stability: 1.1 - Active development + +* `data` {any} The data from `register(loader, import.meta.url, { data })`. + +The `initialize` hook provides a way to define a custom function that runs in +the hooks thread when the hooks module is initialized. Initialization happens +when the hooks module is registered via [`register`][]. + +This hook can receive data from a [`register`][] invocation, including +ports and other transferrable objects. The return value of `initialize` can be a +{Promise}, in which case it will be awaited before the main application thread +execution resumes. + +Module customization code: + +```mjs +// path-to-my-hooks.js + +export async function initialize({ number, port }) { + port.postMessage(`increment: ${number + 1}`); +} +``` + +Caller code: + +```mjs +import assert from 'node:assert'; +import { register } from 'node:module'; +import { MessageChannel } from 'node:worker_threads'; + +// This example showcases how a message channel can be used to communicate +// between the main (application) thread and the hooks running on the hooks +// thread, by sending `port2` to the `initialize` hook. +const { port1, port2 } = new MessageChannel(); + +port1.on('message', (msg) => { + assert.strictEqual(msg, 'increment: 2'); +}); + +register('./path-to-my-hooks.js', { + parentURL: import.meta.url, + data: { number: 1, port: port2 }, + transferList: [port2], +}); +``` + +```cjs +const assert = require('node:assert'); +const { register } = require('node:module'); +const { pathToFileURL } = require('node:url'); +const { MessageChannel } = require('node:worker_threads'); + +// This example showcases how a message channel can be used to communicate +// between the main (application) thread and the hooks running on the hooks +// thread, by sending `port2` to the `initialize` hook. +const { port1, port2 } = new MessageChannel(); + +port1.on('message', (msg) => { + assert.strictEqual(msg, 'increment: 2'); +}); + +register('./path-to-my-hooks.js', { + parentURL: pathToFileURL(__filename), + data: { number: 1, port: port2 }, + transferList: [port2], +}); +``` + +#### `resolve(specifier, context, nextResolve)` + + + +> Stability: 1.2 - Release candidate + +* `specifier` {string} +* `context` {Object} + * `conditions` {string\[]} Export conditions of the relevant `package.json` + * `importAttributes` {Object} An object whose key-value pairs represent the + attributes for the module to import + * `parentURL` {string|undefined} The module importing this one, or undefined + if this is the Node.js entry point +* `nextResolve` {Function} The subsequent `resolve` hook in the chain, or the + Node.js default `resolve` hook after the last user-supplied `resolve` hook + * `specifier` {string} + * `context` {Object} +* Returns: {Object|Promise} + * `format` {string|null|undefined} A hint to the load hook (it might be + ignored) + `'builtin' | 'commonjs' | 'json' | 'module' | 'wasm'` + * `importAttributes` {Object|undefined} The import attributes to use when + caching the module (optional; if excluded the input will be used) + * `shortCircuit` {undefined|boolean} A signal that this hook intends to + terminate the chain of `resolve` hooks. **Default:** `false` + * `url` {string} The absolute URL to which this input resolves + +> **Warning** Despite support for returning promises and async functions, calls +> to `resolve` may block the main thread which can impact performance. + +The `resolve` hook chain is responsible for telling Node.js where to find and +how to cache a given `import` statement or expression, or `require` call. It can +optionally return a format (such as `'module'`) as a hint to the `load` hook. If +a format is specified, the `load` hook is ultimately responsible for providing +the final `format` value (and it is free to ignore the hint provided by +`resolve`); if `resolve` provides a `format`, a custom `load` hook is required +even if only to pass the value to the Node.js default `load` hook. + +Import type attributes are part of the cache key for saving loaded modules into +the internal module cache. The `resolve` hook is responsible for returning an +`importAttributes` object if the module should be cached with different +attributes than were present in the source code. + +The `conditions` property in `context` is an array of conditions for +[package exports conditions][Conditional exports] that apply to this resolution +request. They can be used for looking up conditional mappings elsewhere or to +modify the list when calling the default resolution logic. + +The current [package exports conditions][Conditional exports] are always in +the `context.conditions` array passed into the hook. To guarantee _default +Node.js module specifier resolution behavior_ when calling `defaultResolve`, the +`context.conditions` array passed to it _must_ include _all_ elements of the +`context.conditions` array originally passed into the `resolve` hook. + +```mjs +export async function resolve(specifier, context, nextResolve) { + const { parentURL = null } = context; + + if (Math.random() > 0.5) { // Some condition. + // For some or all specifiers, do some custom logic for resolving. + // Always return an object of the form {url: }. + return { + shortCircuit: true, + url: parentURL ? + new URL(specifier, parentURL).href : + new URL(specifier).href, + }; + } + + if (Math.random() < 0.5) { // Another condition. + // When calling `defaultResolve`, the arguments can be modified. In this + // case it's adding another value for matching conditional exports. + return nextResolve(specifier, { + ...context, + conditions: [...context.conditions, 'another-condition'], + }); + } + + // Defer to the next hook in the chain, which would be the + // Node.js default resolve if this is the last user-specified loader. + return nextResolve(specifier); +} +``` + +#### `load(url, context, nextLoad)` + + + +> Stability: 1.2 - Release candidate + +* `url` {string} The URL returned by the `resolve` chain +* `context` {Object} + * `conditions` {string\[]} Export conditions of the relevant `package.json` + * `format` {string|null|undefined} The format optionally supplied by the + `resolve` hook chain + * `importAttributes` {Object} +* `nextLoad` {Function} The subsequent `load` hook in the chain, or the + Node.js default `load` hook after the last user-supplied `load` hook + * `specifier` {string} + * `context` {Object} +* Returns: {Object} + * `format` {string} + * `shortCircuit` {undefined|boolean} A signal that this hook intends to + terminate the chain of `resolve` hooks. **Default:** `false` + * `source` {string|ArrayBuffer|TypedArray} The source for Node.js to evaluate + +The `load` hook provides a way to define a custom method of determining how a +URL should be interpreted, retrieved, and parsed. It is also in charge of +validating the import assertion. + +The final value of `format` must be one of the following: + +| `format` | Description | Acceptable types for `source` returned by `load` | +| ------------ | ------------------------------ | ----------------------------------------------------- | +| `'builtin'` | Load a Node.js builtin module | Not applicable | +| `'commonjs'` | Load a Node.js CommonJS module | Not applicable | +| `'json'` | Load a JSON file | { [`string`][], [`ArrayBuffer`][], [`TypedArray`][] } | +| `'module'` | Load an ES module | { [`string`][], [`ArrayBuffer`][], [`TypedArray`][] } | +| `'wasm'` | Load a WebAssembly module | { [`ArrayBuffer`][], [`TypedArray`][] } | + +The value of `source` is ignored for type `'builtin'` because currently it is +not possible to replace the value of a Node.js builtin (core) module. The value +of `source` is ignored for type `'commonjs'` because the CommonJS module loader +does not provide a mechanism for the ES module loader to override the +[CommonJS module return value](esm.md#commonjs-namespaces). This limitation +might be overcome in the future. + +> **Warning**: The ESM `load` hook and namespaced exports from CommonJS modules +> are incompatible. Attempting to use them together will result in an empty +> object from the import. This may be addressed in the future. + +> These types all correspond to classes defined in ECMAScript. + +* The specific [`ArrayBuffer`][] object is a [`SharedArrayBuffer`][]. +* The specific [`TypedArray`][] object is a [`Uint8Array`][]. + +If the source value of a text-based format (i.e., `'json'`, `'module'`) +is not a string, it is converted to a string using [`util.TextDecoder`][]. + +The `load` hook provides a way to define a custom method for retrieving the +source code of a resolved URL. This would allow a loader to potentially avoid +reading files from disk. It could also be used to map an unrecognized format to +a supported one, for example `yaml` to `module`. + +```mjs +export async function load(url, context, nextLoad) { + const { format } = context; + + if (Math.random() > 0.5) { // Some condition + /* + For some or all URLs, do some custom logic for retrieving the source. + Always return an object of the form { + format: , + source: , + }. + */ + return { + format, + shortCircuit: true, + source: '...', + }; + } + + // Defer to the next hook in the chain. + return nextLoad(url); +} +``` + +In a more advanced scenario, this can also be used to transform an unsupported +source to a supported one (see [Examples](#examples) below). + +#### `globalPreload()` + + + +> Stability: 1.0 - Early development + +> **Warning:** This hook will be removed in a future version. Use +> [`initialize`][] instead. When a hooks module has an `initialize` export, +> `globalPreload` will be ignored. + +* `context` {Object} Information to assist the preload code + * `port` {MessagePort} +* Returns: {string} Code to run before application startup + +Sometimes it might be necessary to run some code inside of the same global +scope that the application runs in. This hook allows the return of a string +that is run as a sloppy-mode script on startup. + +Similar to how CommonJS wrappers work, the code runs in an implicit function +scope. The only argument is a `require`-like function that can be used to load +builtins like "fs": `getBuiltin(request: string)`. + +If the code needs more advanced `require` features, it has to construct +its own `require` using `module.createRequire()`. + +```mjs +export function globalPreload(context) { + return `\ +globalThis.someInjectedProperty = 42; +console.log('I just set some globals!'); + +const { createRequire } = getBuiltin('module'); +const { cwd } = getBuiltin('process'); + +const require = createRequire(cwd() + '/'); +// [...] +`; +} +``` + +Another argument is provided to the preload code: `port`. This is available as a +parameter to the hook and inside of the source text returned by the hook. This +functionality has been moved to the `initialize` hook. + +Care must be taken in order to properly call [`port.ref()`][] and +[`port.unref()`][] to prevent a process from being in a state where it won't +close normally. + +```mjs +/** + * This example has the application context send a message to the hook + * and sends the message back to the application context + */ +export function globalPreload({ port }) { + port.on('message', (msg) => { + port.postMessage(msg); + }); + return `\ + port.postMessage('console.log("I went to the hook and back");'); + port.on('message', (msg) => { + eval(msg); + }); + `; +} +``` + +### Examples + +The various module customization hooks can be used together to accomplish +wide-ranging customizations of the Node.js code loading and evaluation +behaviors. + +#### Import from HTTPS + +In current Node.js, specifiers starting with `https://` are experimental (see +[HTTPS and HTTP imports][]). + +The hook below registers hooks to enable rudimentary support for such +specifiers. While this may seem like a significant improvement to Node.js core +functionality, there are substantial downsides to actually using these hooks: +performance is much slower than loading files from disk, there is no caching, +and there is no security. + +```mjs +// https-hooks.mjs +import { get } from 'node:https'; + +export function load(url, context, nextLoad) { + // For JavaScript to be loaded over the network, we need to fetch and + // return it. + if (url.startsWith('https://')) { + return new Promise((resolve, reject) => { + get(url, (res) => { + let data = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => data += chunk); + res.on('end', () => resolve({ + // This example assumes all network-provided JavaScript is ES module + // code. + format: 'module', + shortCircuit: true, + source: data, + })); + }).on('error', (err) => reject(err)); + }); + } + + // Let Node.js handle all other URLs. + return nextLoad(url); +} +``` + +```mjs +// main.mjs +import { VERSION } from 'https://coffeescript.org/browser-compiler-modern/coffeescript.js'; + +console.log(VERSION); +``` + +With the preceding hooks module, running +`node --import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register(pathToFileURL("./https-hooks.mjs"));' ./main.mjs` +prints the current version of CoffeeScript per the module at the URL in +`main.mjs`. + +#### Transpilation + +Sources that are in formats Node.js doesn't understand can be converted into +JavaScript using the [`load` hook][load hook]. + +This is less performant than transpiling source files before running Node.js; +transpiler hooks should only be used for development and testing purposes. + +```mjs +// coffeescript-hooks.mjs +import { readFile } from 'node:fs/promises'; +import { dirname, extname, resolve as resolvePath } from 'node:path'; +import { cwd } from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import coffeescript from 'coffeescript'; + +const extensionsRegex = /\.(coffee|litcoffee|coffee\.md)$/; + +export async function load(url, context, nextLoad) { + if (extensionsRegex.test(url)) { + // CoffeeScript files can be either CommonJS or ES modules, so we want any + // CoffeeScript file to be treated by Node.js the same as a .js file at the + // same location. To determine how Node.js would interpret an arbitrary .js + // file, search up the file system for the nearest parent package.json file + // and read its "type" field. + const format = await getPackageType(url); + + const { source: rawSource } = await nextLoad(url, { ...context, format }); + // This hook converts CoffeeScript source code into JavaScript source code + // for all imported CoffeeScript files. + const transformedSource = coffeescript.compile(rawSource.toString(), url); + + return { + format, + shortCircuit: true, + source: transformedSource, + }; + } + + // Let Node.js handle all other URLs. + return nextLoad(url); +} + +async function getPackageType(url) { + // `url` is only a file path during the first iteration when passed the + // resolved url from the load() hook + // an actual file path from load() will contain a file extension as it's + // required by the spec + // this simple truthy check for whether `url` contains a file extension will + // work for most projects but does not cover some edge-cases (such as + // extensionless files or a url ending in a trailing space) + const isFilePath = !!extname(url); + // If it is a file path, get the directory it's in + const dir = isFilePath ? + dirname(fileURLToPath(url)) : + url; + // Compose a file path to a package.json in the same directory, + // which may or may not exist + const packagePath = resolvePath(dir, 'package.json'); + // Try to read the possibly nonexistent package.json + const type = await readFile(packagePath, { encoding: 'utf8' }) + .then((filestring) => JSON.parse(filestring).type) + .catch((err) => { + if (err?.code !== 'ENOENT') console.error(err); + }); + // Ff package.json existed and contained a `type` field with a value, voila + if (type) return type; + // Otherwise, (if not at the root) continue checking the next directory up + // If at the root, stop and return false + return dir.length > 1 && getPackageType(resolvePath(dir, '..')); +} +``` + +```coffee +# main.coffee +import { scream } from './scream.coffee' +console.log scream 'hello, world' + +import { version } from 'node:process' +console.log "Brought to you by Node.js version #{version}" +``` + +```coffee +# scream.coffee +export scream = (str) -> str.toUpperCase() +``` + +With the preceding hooks module, running +`node --import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register(pathToFileURL("./coffeescript-hooks.mjs"));' ./main.coffee` +causes `main.coffee` to be turned into JavaScript after its source code is +loaded from disk but before Node.js executes it; and so on for any `.coffee`, +`.litcoffee` or `.coffee.md` files referenced via `import` statements of any +loaded file. + +#### Import maps + +The previous two examples defined `load` hooks. This is an example of a +`resolve` hook. This hooks module reads an `import-map.json` file that defines +which specifiers to override to other URLs (this is a very simplistic +implementation of a small subset of the "import maps" specification). + +```mjs +// import-map-hooks.js +import fs from 'node:fs/promises'; + +const { imports } = JSON.parse(await fs.readFile('import-map.json')); + +export async function resolve(specifier, context, nextResolve) { + if (Object.hasOwn(imports, specifier)) { + return nextResolve(imports[specifier], context); + } + + return nextResolve(specifier, context); +} +``` + +With these files: + +```mjs +// main.js +import 'a-module'; +``` + +```json +// import-map.json +{ + "imports": { + "a-module": "./some-module.js" + } +} +``` + +```mjs +// some-module.js +console.log('some module!'); +``` + +Running `node --import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register(pathToFileURL("./import-map-hooks.js"));' main.js` +should print `some module!`. + ## Source map v3 support + * `linker` {Function} * `specifier` {string} The specifier of the requested module: ```mjs @@ -623,15 +631,14 @@ The identifier of the current module, as set in the constructor. * `referencingModule` {vm.Module} The `Module` object `link()` is called on. * `extra` {Object} - * `assert` {Object} The data from the assertion: - - ```js + * `attributes` {Object} The data from the attribute: + ```mjs import foo from 'foo' assert { name: 'value' }; - // ^^^^^^^^^^^^^^^^^ the assertion + // ^^^^^^^^^^^^^^^^^ the attribute ``` - Per ECMA-262, hosts are expected to ignore assertions that they do not - support, as opposed to, for example, triggering an error if an - unsupported assertion is present. + Per ECMA-262, hosts are expected to trigger an error if an + unsupported attribute is present. + * `assert` {Object} Alias for `extra.attributes`. * Returns: {vm.Module|Promise} * Returns: {Promise} @@ -730,7 +737,7 @@ changes: - v17.0.0 - v16.12.0 pr-url: https://github.com/nodejs/node/pull/40249 - description: Added support for import assertions to the + description: Added support for import attributes to the `importModuleDynamically` parameter. --> @@ -760,7 +767,7 @@ changes: `import()` will reject with [`ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`][]. * `specifier` {string} specifier passed to `import()` * `module` {vm.Module} - * `importAssertions` {Object} The `"assert"` value passed to the + * `importAttributes` {Object} The `"assert"` value passed to the [`optionsExpression`][] optional parameter, or an empty object if no value was provided. * Returns: {Module Namespace Object|vm.Module} Returning a `vm.Module` is @@ -974,7 +981,7 @@ changes: - v17.0.0 - v16.12.0 pr-url: https://github.com/nodejs/node/pull/40249 - description: Added support for import assertions to the + description: Added support for import attributes to the `importModuleDynamically` parameter. - version: v15.9.0 pr-url: https://github.com/nodejs/node/pull/35431 @@ -1018,7 +1025,7 @@ changes: considered stable. * `specifier` {string} specifier passed to `import()` * `function` {Function} - * `importAssertions` {Object} The `"assert"` value passed to the + * `importAttributes` {Object} The `"assert"` value passed to the [`optionsExpression`][] optional parameter, or an empty object if no value was provided. * Returns: {Module Namespace Object|vm.Module} Returning a `vm.Module` is @@ -1204,7 +1211,7 @@ changes: - v17.0.0 - v16.12.0 pr-url: https://github.com/nodejs/node/pull/40249 - description: Added support for import assertions to the + description: Added support for import attributes to the `importModuleDynamically` parameter. - version: v6.3.0 pr-url: https://github.com/nodejs/node/pull/6635 @@ -1242,7 +1249,7 @@ changes: using it in a production environment. * `specifier` {string} specifier passed to `import()` * `script` {vm.Script} - * `importAssertions` {Object} The `"assert"` value passed to the + * `importAttributes` {Object} The `"assert"` value passed to the [`optionsExpression`][] optional parameter, or an empty object if no value was provided. * Returns: {Module Namespace Object|vm.Module} Returning a `vm.Module` is @@ -1282,7 +1289,7 @@ changes: - v17.0.0 - v16.12.0 pr-url: https://github.com/nodejs/node/pull/40249 - description: Added support for import assertions to the + description: Added support for import attributes to the `importModuleDynamically` parameter. - version: v14.6.0 pr-url: https://github.com/nodejs/node/pull/34023 @@ -1341,7 +1348,7 @@ changes: using it in a production environment. * `specifier` {string} specifier passed to `import()` * `script` {vm.Script} - * `importAssertions` {Object} The `"assert"` value passed to the + * `importAttributes` {Object} The `"assert"` value passed to the [`optionsExpression`][] optional parameter, or an empty object if no value was provided. * Returns: {Module Namespace Object|vm.Module} Returning a `vm.Module` is @@ -1385,7 +1392,7 @@ changes: - v17.0.0 - v16.12.0 pr-url: https://github.com/nodejs/node/pull/40249 - description: Added support for import assertions to the + description: Added support for import attributes to the `importModuleDynamically` parameter. - version: v6.3.0 pr-url: https://github.com/nodejs/node/pull/6635 @@ -1421,7 +1428,7 @@ changes: using it in a production environment. * `specifier` {string} specifier passed to `import()` * `script` {vm.Script} - * `importAssertions` {Object} The `"assert"` value passed to the + * `importAttributes` {Object} The `"assert"` value passed to the [`optionsExpression`][] optional parameter, or an empty object if no value was provided. * Returns: {Module Namespace Object|vm.Module} Returning a `vm.Module` is diff --git a/doc/node.1 b/doc/node.1 index b7e69550b2fea8..362e9bfdc29cb8 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -140,6 +140,11 @@ Requires Node.js to be built with .It Fl -enable-source-maps Enable Source Map V3 support for stack traces. . +.It Fl -experimental-default-type Ns = Ns Ar type +Interpret as either ES modules or CommonJS modules input via --eval or STDIN, when --input-type is unspecified; +.js or extensionless files with no sibling or parent package.json; +.js or extensionless files whose nearest parent package.json lacks a "type" field, unless under node_modules. +. .It Fl -experimental-global-customevent Expose the CustomEvent on the global scope. . diff --git a/lib/fs.js b/lib/fs.js index 8b6af16e5e56b8..09cace93b542b2 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -139,7 +139,6 @@ const { validateFunction, validateInteger, validateObject, - validateString, } = require('internal/validators'); let truncateWarn = true; @@ -2884,7 +2883,7 @@ realpath.native = (path, options, callback) => { /** * Creates a unique temporary directory. - * @param {string} prefix + * @param {string | Buffer | URL} prefix * @param {string | { encoding?: string; }} [options] * @param {( * err?: Error, @@ -2896,27 +2895,40 @@ function mkdtemp(prefix, options, callback) { callback = makeCallback(typeof options === 'function' ? options : callback); options = getOptions(options); - validateString(prefix, 'prefix'); - nullCheck(prefix, 'prefix'); + prefix = getValidatedPath(prefix, 'prefix'); warnOnNonPortableTemplate(prefix); + + let path; + if (typeof prefix === 'string') { + path = `${prefix}XXXXXX`; + } else { + path = Buffer.concat([prefix, Buffer.from('XXXXXX')]); + } + const req = new FSReqCallback(); req.oncomplete = callback; - binding.mkdtemp(`${prefix}XXXXXX`, options.encoding, req); + binding.mkdtemp(path, options.encoding, req); } /** * Synchronously creates a unique temporary directory. - * @param {string} prefix + * @param {string | Buffer | URL} prefix * @param {string | { encoding?: string; }} [options] * @returns {string} */ function mkdtempSync(prefix, options) { options = getOptions(options); - validateString(prefix, 'prefix'); - nullCheck(prefix, 'prefix'); + prefix = getValidatedPath(prefix, 'prefix'); warnOnNonPortableTemplate(prefix); - const path = `${prefix}XXXXXX`; + + let path; + if (typeof prefix === 'string') { + path = `${prefix}XXXXXX`; + } else { + path = Buffer.concat([prefix, Buffer.from('XXXXXX')]); + } + const ctx = { path }; const result = binding.mkdtemp(path, options.encoding, undefined, ctx); diff --git a/lib/internal/dns/promises.js b/lib/internal/dns/promises.js index 79be8591bbcad2..1169b2735d4efe 100644 --- a/lib/internal/dns/promises.js +++ b/lib/internal/dns/promises.js @@ -113,6 +113,19 @@ function onlookupall(err, addresses) { } } +/** + * Creates a promise that resolves with the IP address of the given hostname. + * @param {0 | 4 | 6} family - The IP address family (4 or 6, or 0 for both). + * @param {string} hostname - The hostname to resolve. + * @param {boolean} all - Whether to resolve with all IP addresses for the hostname. + * @param {number} hints - One or more supported getaddrinfo flags (supply multiple via + * bitwise OR). + * @param {boolean} verbatim - Whether to use the hostname verbatim. + * @returns {Promise} The IP address(es) of the hostname. + * @typedef {object} DNSLookupResult + * @property {string} address - The IP address. + * @property {0 | 4 | 6} family - The IP address type. 4 for IPv4 or 6 for IPv6, or 0 (for both). + */ function createLookupPromise(family, hostname, all, hints, verbatim) { return new Promise((resolve, reject) => { if (!hostname) { @@ -154,6 +167,17 @@ function createLookupPromise(family, hostname, all, hints, verbatim) { } const validFamilies = [0, 4, 6]; +/** + * Get the IP address for a given hostname. + * @param {string} hostname - The hostname to resolve (ex. 'nodejs.org'). + * @param {object} [options] - Optional settings. + * @param {boolean} [options.all=false] - Whether to return all or just the first resolved address. + * @param {0 | 4 | 6} [options.family=0] - The record family. Must be 4, 6, or 0 (for both). + * @param {number} [options.hints] - One or more supported getaddrinfo flags (supply multiple via + * bitwise OR). + * @param {boolean} [options.verbatim=false] - Return results in same order DNS resolved them; + * otherwise IPv4 then IPv6. New code should supply `true`. + */ function lookup(hostname, options) { let hints = 0; let family = 0; diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 7bc7998f918ac6..5d488843f60ab8 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1177,12 +1177,17 @@ E('ERR_HTTP_SOCKET_ENCODING', E('ERR_HTTP_TRAILER_INVALID', 'Trailers are invalid with this transfer encoding', Error); E('ERR_ILLEGAL_CONSTRUCTOR', 'Illegal constructor', TypeError); +// TODO(aduh95): change the error to mention import attributes instead of import assertions. E('ERR_IMPORT_ASSERTION_TYPE_FAILED', 'Module "%s" is not of type "%s"', TypeError); +// TODO(aduh95): change the error to mention import attributes instead of import assertions. E('ERR_IMPORT_ASSERTION_TYPE_MISSING', - 'Module "%s" needs an import assertion of type "%s"', TypeError); + 'Module "%s" needs an import attribute of type "%s"', TypeError); +// TODO(aduh95): change the error to mention import attributes instead of import assertions. E('ERR_IMPORT_ASSERTION_TYPE_UNSUPPORTED', - 'Import assertion type "%s" is unsupported', TypeError); + 'Import attribute type "%s" is unsupported', TypeError); +E('ERR_IMPORT_ATTRIBUTE_UNSUPPORTED', + 'Import attribute "%s" with value "%s" is not supported', TypeError); E('ERR_INCOMPATIBLE_OPTION_PAIR', 'Option "%s" cannot be used in combination with option "%s"', TypeError); E('ERR_INPUT_TYPE_NOT_ALLOWED', '--input-type can only be used with string ' + @@ -1453,8 +1458,12 @@ E('ERR_MISSING_ARGS', return `${msg} must be specified`; }, TypeError); E('ERR_MISSING_OPTION', '%s is required', TypeError); -E('ERR_MODULE_NOT_FOUND', (path, base, type = 'package') => { - return `Cannot find ${type} '${path}' imported from ${base}`; +E('ERR_MODULE_NOT_FOUND', function(path, base, exactUrl) { + if (exactUrl) { + lazyInternalUtil().setOwnProperty(this, 'url', `${exactUrl}`); + } + return `Cannot find ${ + exactUrl ? 'module' : 'package'} '${path}' imported from ${base}`; }, Error); E('ERR_MULTIPLE_CALLBACK', 'Callback called multiple times', Error); E('ERR_NAPI_CONS_FUNCTION', 'Constructor must be a function', TypeError); @@ -1542,7 +1551,7 @@ E('ERR_REQUIRE_ESM', msg += `\n${basename} is treated as an ES module file as it is a .js ` + 'file whose nearest parent package.json contains "type": "module" ' + 'which declares all .js files in that package scope as ES modules.' + - `\nInstead rename ${basename} to end in .cjs, change the requiring ` + + `\nInstead either rename ${basename} to end in .cjs, change the requiring ` + 'code to use dynamic import() which is available in all CommonJS ' + 'modules, or change "type": "module" to "type": "commonjs" in ' + `${packageJsonPath} to treat all .js files as CommonJS (using .mjs for ` + @@ -1682,18 +1691,15 @@ E('ERR_UNHANDLED_ERROR', E('ERR_UNKNOWN_BUILTIN_MODULE', 'No such built-in module: %s', Error); E('ERR_UNKNOWN_CREDENTIAL', '%s identifier does not exist: %s', Error); E('ERR_UNKNOWN_ENCODING', 'Unknown encoding: %s', TypeError); -E('ERR_UNKNOWN_FILE_EXTENSION', (ext, path, suggestion) => { - let msg = `Unknown file extension "${ext}" for ${path}`; - if (suggestion) { - msg += `. ${suggestion}`; - } - return msg; -}, TypeError); +E('ERR_UNKNOWN_FILE_EXTENSION', 'Unknown file extension "%s" for %s', TypeError); E('ERR_UNKNOWN_MODULE_FORMAT', 'Unknown module format: %s for URL %s', RangeError); E('ERR_UNKNOWN_SIGNAL', 'Unknown signal: %s', TypeError); -E('ERR_UNSUPPORTED_DIR_IMPORT', "Directory import '%s' is not supported " + -'resolving ES modules imported from %s', Error); +E('ERR_UNSUPPORTED_DIR_IMPORT', function(path, base, exactUrl) { + lazyInternalUtil().setOwnProperty(this, 'url', exactUrl); + return `Directory import '${path}' is not supported ` + + `resolving ES modules imported from ${base}`; +}, Error); E('ERR_UNSUPPORTED_ESM_URL_SCHEME', (url, supported) => { let msg = `Only URLs with a scheme in: ${formatList(supported)} are supported by the default ESM loader`; if (isWindows && url.protocol.length === 2) { diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js index 4cf97f2253aa7d..c4409d51b9dac6 100644 --- a/lib/internal/fs/promises.js +++ b/lib/internal/fs/promises.js @@ -59,7 +59,6 @@ const { getStatsFromBinding, getValidatedPath, getValidMode, - nullCheck, preprocessSymlinkDestination, stringToFlags, stringToSymlinkType, @@ -976,10 +975,17 @@ async function realpath(path, options) { async function mkdtemp(prefix, options) { options = getOptions(options); - validateString(prefix, 'prefix'); - nullCheck(prefix); + prefix = getValidatedPath(prefix, 'prefix'); warnOnNonPortableTemplate(prefix); - return binding.mkdtemp(`${prefix}XXXXXX`, options.encoding, kUsePromises); + + let path; + if (typeof prefix === 'string') { + path = `${prefix}XXXXXX`; + } else { + path = Buffer.concat([prefix, Buffer.from('XXXXXX')]); + } + + return binding.mkdtemp(path, options.encoding, kUsePromises); } async function writeFile(path, data, options) { diff --git a/lib/internal/fs/utils.js b/lib/internal/fs/utils.js index 23865845bac59e..00889ffccc4b99 100644 --- a/lib/internal/fs/utils.js +++ b/lib/internal/fs/utils.js @@ -21,6 +21,7 @@ const { StringPrototypeEndsWith, StringPrototypeIncludes, Symbol, + TypedArrayPrototypeAt, TypedArrayPrototypeIncludes, } = primordials; @@ -736,7 +737,9 @@ let nonPortableTemplateWarn = true; function warnOnNonPortableTemplate(template) { // Template strings passed to the mkdtemp() family of functions should not // end with 'X' because they are handled inconsistently across platforms. - if (nonPortableTemplateWarn && StringPrototypeEndsWith(template, 'X')) { + if (nonPortableTemplateWarn && + ((typeof template === 'string' && StringPrototypeEndsWith(template, 'X')) || + (typeof template !== 'string' && TypedArrayPrototypeAt(template, -1) === 0x58))) { process.emitWarning('mkdtemp() templates ending with X are not portable. ' + 'For details see: https://nodejs.org/api/fs.html'); nonPortableTemplateWarn = false; diff --git a/lib/internal/main/check_syntax.js b/lib/internal/main/check_syntax.js index 52c83be33287c0..b6ee64de499c2f 100644 --- a/lib/internal/main/check_syntax.js +++ b/lib/internal/main/check_syntax.js @@ -63,7 +63,8 @@ function loadESMIfNeeded(cb) { async function checkSyntax(source, filename) { let isModule = true; if (filename === '[stdin]' || filename === '[eval]') { - isModule = getOptionValue('--input-type') === 'module'; + isModule = getOptionValue('--input-type') === 'module' || + (getOptionValue('--experimental-default-type') === 'module' && getOptionValue('--input-type') !== 'commonjs'); } else { const { defaultResolve } = require('internal/modules/esm/resolve'); const { defaultGetFormat } = require('internal/modules/esm/get_format'); diff --git a/lib/internal/main/eval_stdin.js b/lib/internal/main/eval_stdin.js index d947af49a6a942..d71751e781b9b5 100644 --- a/lib/internal/main/eval_stdin.js +++ b/lib/internal/main/eval_stdin.js @@ -25,12 +25,14 @@ readStdin((code) => { const print = getOptionValue('--print'); const loadESM = getOptionValue('--import').length > 0; - if (getOptionValue('--input-type') === 'module') + if (getOptionValue('--input-type') === 'module' || + (getOptionValue('--experimental-default-type') === 'module' && getOptionValue('--input-type') !== 'commonjs')) { evalModule(code, print); - else + } else { evalScript('[stdin]', code, getOptionValue('--inspect-brk'), print, loadESM); + } }); diff --git a/lib/internal/main/eval_string.js b/lib/internal/main/eval_string.js index 2b9c99e1944fdc..4e2edf9ce62499 100644 --- a/lib/internal/main/eval_string.js +++ b/lib/internal/main/eval_string.js @@ -22,12 +22,14 @@ markBootstrapComplete(); const source = getOptionValue('--eval'); const print = getOptionValue('--print'); -const loadESM = getOptionValue('--import').length > 0; -if (getOptionValue('--input-type') === 'module') +const loadESM = getOptionValue('--import').length > 0 || getOptionValue('--experimental-loader').length > 0; +if (getOptionValue('--input-type') === 'module' || + (getOptionValue('--experimental-default-type') === 'module' && getOptionValue('--input-type') !== 'commonjs')) { evalModule(source, print); -else +} else { evalScript('[eval]', source, getOptionValue('--inspect-brk'), print, loadESM); +} diff --git a/lib/internal/main/run_main_module.js b/lib/internal/main/run_main_module.js index 51331270a2161f..5d09203b8c27ee 100644 --- a/lib/internal/main/run_main_module.js +++ b/lib/internal/main/run_main_module.js @@ -6,18 +6,24 @@ const { prepareMainThreadExecution, markBootstrapComplete, } = require('internal/process/pre_execution'); +const { getOptionValue } = require('internal/options'); -prepareMainThreadExecution(true); +const mainEntry = prepareMainThreadExecution(true); markBootstrapComplete(); // Necessary to reset RegExp statics before user code runs. RegExpPrototypeExec(/^/, ''); -// Note: this loads the module through the ESM loader if the module is -// determined to be an ES module. This hangs from the CJS module loader -// because we currently allow monkey-patching of the module loaders -// in the preloaded scripts through require('module'). -// runMain here might be monkey-patched by users in --require. -// XXX: the monkey-patchability here should probably be deprecated. -require('internal/modules/cjs/loader').Module.runMain(process.argv[1]); +if (getOptionValue('--experimental-default-type') === 'module') { + require('internal/modules/run_main').executeUserEntryPoint(mainEntry); +} else { + /** + * To support legacy monkey-patching of `Module.runMain`, we call `runMain` here to have the CommonJS loader begin + * the execution of the main entry point, even if the ESM loader immediately takes over because the main entry is an + * ES module or one of the other opt-in conditions (such as the use of `--import`) are met. Users can monkey-patch + * before the main entry point is loaded by doing so via scripts loaded through `--require`. This monkey-patchability + * is undesirable and is removed in `--experimental-default-type=module` mode. + */ + require('internal/modules/cjs/loader').Module.runMain(mainEntry); +} diff --git a/lib/internal/main/worker_thread.js b/lib/internal/main/worker_thread.js index 7fbfb64984c290..b905de0da20a20 100644 --- a/lib/internal/main/worker_thread.js +++ b/lib/internal/main/worker_thread.js @@ -10,6 +10,7 @@ const { ObjectDefineProperty, PromisePrototypeThen, RegExpPrototypeExec, + SafeWeakMap, globalThis: { Atomics, SharedArrayBuffer, @@ -88,23 +89,25 @@ port.on('message', (message) => { const { argv, cwdCounter, - filename, doEval, - workerData, environmentData, - publicPort, + filename, + hasStdin, manifestSrc, manifestURL, - hasStdin, + publicPort, + workerData, } = message; - if (argv !== undefined) { - ArrayPrototypePushApply(process.argv, argv); - } + if (doEval !== 'internal') { + if (argv !== undefined) { + ArrayPrototypePushApply(process.argv, argv); + } - const publicWorker = require('worker_threads'); - publicWorker.parentPort = publicPort; - publicWorker.workerData = workerData; + const publicWorker = require('worker_threads'); + publicWorker.parentPort = publicPort; + publicWorker.workerData = workerData; + } require('internal/worker').assignEnvironmentData(environmentData); @@ -129,7 +132,10 @@ port.on('message', (message) => { if (manifestSrc) { require('internal/process/policy').setup(manifestSrc, manifestURL); } - setupUserModules(); + const isLoaderWorker = + doEval === 'internal' && + filename === require('internal/modules/esm/utils').loaderWorkerId; + setupUserModules(isLoaderWorker); if (!hasStdin) process.stdin.push(null); @@ -137,31 +143,47 @@ port.on('message', (message) => { debug(`[${threadId}] starts worker script ${filename} ` + `(eval = ${doEval}) at cwd = ${process.cwd()}`); port.postMessage({ type: UP_AND_RUNNING }); - if (doEval === 'classic') { - const { evalScript } = require('internal/process/execution'); - const name = '[worker eval]'; - // This is necessary for CJS module compilation. - // TODO: pass this with something really internal. - ObjectDefineProperty(process, '_eval', { - __proto__: null, - configurable: true, - enumerable: true, - value: filename, - }); - ArrayPrototypeSplice(process.argv, 1, 0, name); - evalScript(name, filename); - } else if (doEval === 'module') { - const { evalModule } = require('internal/process/execution'); - PromisePrototypeThen(evalModule(filename), undefined, (e) => { - workerOnGlobalUncaughtException(e, true); - }); - } else { - // script filename - // runMain here might be monkey-patched by users in --require. - // XXX: the monkey-patchability here should probably be deprecated. - ArrayPrototypeSplice(process.argv, 1, 0, filename); - const CJSLoader = require('internal/modules/cjs/loader'); - CJSLoader.Module.runMain(filename); + switch (doEval) { + case 'internal': { + // Create this WeakMap in js-land because V8 has no C++ API for WeakMap. + internalBinding('module_wrap').callbackMap = new SafeWeakMap(); + require(filename)(workerData, publicPort); + break; + } + + case 'classic': { + const { evalScript } = require('internal/process/execution'); + const name = '[worker eval]'; + // This is necessary for CJS module compilation. + // TODO: pass this with something really internal. + ObjectDefineProperty(process, '_eval', { + __proto__: null, + configurable: true, + enumerable: true, + value: filename, + }); + ArrayPrototypeSplice(process.argv, 1, 0, name); + evalScript(name, filename); + break; + } + + case 'module': { + const { evalModule } = require('internal/process/execution'); + PromisePrototypeThen(evalModule(filename), undefined, (e) => { + workerOnGlobalUncaughtException(e, true); + }); + break; + } + + default: { + // script filename + // runMain here might be monkey-patched by users in --require. + // XXX: the monkey-patchability here should probably be deprecated. + ArrayPrototypeSplice(process.argv, 1, 0, filename); + const CJSLoader = require('internal/modules/cjs/loader'); + CJSLoader.Module.runMain(filename); + break; + } } } else if (message.type === STDIO_PAYLOAD) { const { stream, chunks } = message; diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 44fab4de4c0823..316996a8c329a1 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -56,7 +56,6 @@ const { StringPrototypeCharAt, StringPrototypeCharCodeAt, StringPrototypeEndsWith, - StringPrototypeLastIndexOf, StringPrototypeIndexOf, StringPrototypeRepeat, StringPrototypeSlice, @@ -69,7 +68,7 @@ const cjsParseCache = new SafeWeakMap(); // Set first due to cycle with ESM loader functions. module.exports = { - wrapSafe, Module, toRealPath, readPackageScope, cjsParseCache, + wrapSafe, Module, cjsParseCache, get hasLoadedAnyUserCJSModule() { return hasLoadedAnyUserCJSModule; }, initializeCJS, }; @@ -83,16 +82,13 @@ const { pendingDeprecate, emitExperimentalWarning, kEmptyObject, - filterOwnProperties, setOwnProperty, getLazy, } = require('internal/util'); const { internalCompileFunction } = require('internal/vm'); const assert = require('internal/assert'); const fs = require('fs'); -const internalFS = require('internal/fs/utils'); const path = require('path'); -const { sep } = path; const { internalModuleStat } = internalBinding('fs'); const { safeGetenv } = internalBinding('credentials'); const { @@ -108,6 +104,7 @@ const { makeRequireFunction, normalizeReferrerURL, stripBOM, + toRealPath, } = require('internal/modules/helpers'); const packageJsonReader = require('internal/modules/package_json_reader'); const { getOptionValue, getEmbedderOptions } = require('internal/options'); @@ -155,6 +152,11 @@ let requireDepth = 0; let isPreloading = false; let statCache = null; +/** + * Our internal implementation of `require`. + * @param {Module} module Parent module of what is being required + * @param {string} id Specifier of the child module being imported + */ function internalRequire(module, id) { validateString(id, 'id'); if (id === '') { @@ -169,11 +171,15 @@ function internalRequire(module, id) { } } +/** + * Get a path's properties, using an in-memory cache to minimize lookups. + * @param {string} filename Absolute path to the file + */ function stat(filename) { filename = path.toNamespacedPath(filename); if (statCache !== null) { const result = statCache.get(filename); - if (result !== undefined) return result; + if (result !== undefined) { return result; } } const result = internalModuleStat(filename); if (statCache !== null && result >= 0) { @@ -195,25 +201,47 @@ ObjectDefineProperty(Module, '_stat', { configurable: true, }); +/** + * Update the parent's children array with the child module. + * @param {Module} parent Module requiring the children + * @param {Module} child Module being required + * @param {boolean} scan Add the child to the parent's children if not already present + */ function updateChildren(parent, child, scan) { const children = parent?.children; - if (children && !(scan && ArrayPrototypeIncludes(children, child))) + if (children && !(scan && ArrayPrototypeIncludes(children, child))) { ArrayPrototypePush(children, child); + } } +/** + * Tell the watch mode that a module was required. + * @param {string} filename Absolute path of the module + */ function reportModuleToWatchMode(filename) { if (shouldReportRequiredModules() && process.send) { process.send({ 'watch:require': [filename] }); } } +/** + * Tell the watch mode that a module was not found. + * @param {string} basePath The absolute path that errored + * @param {string[]} extensions The extensions that were tried + */ function reportModuleNotFoundToWatchMode(basePath, extensions) { if (shouldReportRequiredModules() && process.send) { process.send({ 'watch:require': ArrayPrototypeMap(extensions, (ext) => path.resolve(`${basePath}${ext}`)) }); } } +/** @type {Map} */ const moduleParentCache = new SafeWeakMap(); +/** + * Create a new module instance. + * @param {string} id + * @param {Module} parent + */ function Module(id = '', parent) { this.id = id; this.path = path.dirname(id); @@ -236,16 +264,24 @@ function Module(id = '', parent) { this[require_private_symbol] = internalRequire; } -Module._cache = ObjectCreate(null); -Module._pathCache = ObjectCreate(null); -Module._extensions = ObjectCreate(null); +/** @type {Record} */ +Module._cache = { __proto__: null }; +/** @type {Record} */ +Module._pathCache = { __proto__: null }; +/** @type {Record void>} */ +Module._extensions = { __proto__: null }; +/** @type {string[]} */ let modulePaths = []; +/** @type {string[]} */ Module.globalPaths = []; let patched = false; -// eslint-disable-next-line func-style -let wrap = function(script) { +/** + * Add the CommonJS wrapper around a module's source code. + * @param {string} script Module source code + */ +let wrap = function(script) { // eslint-disable-line func-style return Module.wrapper[0] + script + Module.wrapper[1]; }; @@ -296,10 +332,17 @@ const isPreloadingDesc = { get() { return isPreloading; } }; ObjectDefineProperty(Module.prototype, 'isPreloading', isPreloadingDesc); ObjectDefineProperty(BuiltinModule.prototype, 'isPreloading', isPreloadingDesc); +/** + * Get the parent of the current module from our cache. + */ function getModuleParent() { return moduleParentCache.get(this); } +/** + * Set the parent of the current module in our cache. + * @param {Module} value + */ function setModuleParent(value) { moduleParentCache.set(this, value); } @@ -326,7 +369,10 @@ ObjectDefineProperty(Module.prototype, 'parent', { Module._debug = pendingDeprecate(debug, 'Module._debug is deprecated.', 'DEP0077'); Module.isBuiltin = BuiltinModule.isBuiltin; -// This function is called during pre-execution, before any user code is run. +/** + * Prepare to run CommonJS code. + * This function is called during pre-execution, before any user code is run. + */ function initializeCJS() { // This need to be done at runtime in case --expose-internals is set. const builtinModules = BuiltinModule.getCanBeRequiredByUsersWithoutSchemeList(); @@ -354,39 +400,7 @@ function initializeCJS() { // -> a. // -> a/index. -const packageJsonCache = new SafeMap(); - -function readPackage(requestPath) { - const jsonPath = path.resolve(requestPath, 'package.json'); - - const existing = packageJsonCache.get(jsonPath); - if (existing !== undefined) return existing; - - const result = packageJsonReader.read(jsonPath); - const json = result.containsKeys === false ? '{}' : result.string; - if (json === undefined) { - packageJsonCache.set(jsonPath, false); - return false; - } - - try { - const filtered = filterOwnProperties(JSONParse(json), [ - 'name', - 'main', - 'exports', - 'imports', - 'type', - ]); - packageJsonCache.set(jsonPath, filtered); - return filtered; - } catch (e) { - e.path = jsonPath; - e.message = 'Error parsing ' + jsonPath + ': ' + e.message; - throw e; - } -} - -let _readPackage = readPackage; +let _readPackage = packageJsonReader.readPackage; ObjectDefineProperty(Module, '_readPackage', { __proto__: null, get() { return _readPackage; }, @@ -398,25 +412,15 @@ ObjectDefineProperty(Module, '_readPackage', { configurable: true, }); -function readPackageScope(checkPath) { - const rootSeparatorIndex = StringPrototypeIndexOf(checkPath, sep); - let separatorIndex; - do { - separatorIndex = StringPrototypeLastIndexOf(checkPath, sep); - checkPath = StringPrototypeSlice(checkPath, 0, separatorIndex); - if (StringPrototypeEndsWith(checkPath, sep + 'node_modules')) - return false; - const pjson = _readPackage(checkPath + sep); - if (pjson) return { - data: pjson, - path: checkPath, - }; - } while (separatorIndex > rootSeparatorIndex); - return false; -} - +/** + * Try to load a specifier as a package. + * @param {string} requestPath The path to what we are trying to load + * @param {string[]} exts File extensions to try appending in order to resolve the file + * @param {boolean} isMain Whether the file is the main entry point of the app + * @param {string} originalPath The specifier passed to `require` + */ function tryPackage(requestPath, exts, isMain, originalPath) { - const pkg = _readPackage(requestPath)?.main; + const pkg = _readPackage(requestPath).main; if (!pkg) { return tryExtensions(path.resolve(requestPath, 'index'), exts, isMain); @@ -452,34 +456,30 @@ function tryPackage(requestPath, exts, isMain, originalPath) { return actual; } -// In order to minimize unnecessary lstat() calls, -// this cache is a list of known-real paths. -// Set to an empty Map to reset. -const realpathCache = new SafeMap(); - -// Check if the file exists and is not a directory -// if using --preserve-symlinks and isMain is false, -// keep symlinks intact, otherwise resolve to the -// absolute realpath. +/** + * Check if the file exists and is not a directory if using `--preserve-symlinks` and `isMain` is false, keep symlinks + * intact, otherwise resolve to the absolute realpath. + * @param {string} requestPath The path to the file to load. + * @param {boolean} isMain Whether the file is the main module. + */ function tryFile(requestPath, isMain) { const rc = _stat(requestPath); - if (rc !== 0) return; + if (rc !== 0) { return; } if (getOptionValue('--preserve-symlinks') && !isMain) { return path.resolve(requestPath); } return toRealPath(requestPath); } -function toRealPath(requestPath) { - return fs.realpathSync(requestPath, { - [internalFS.realpathCacheKey]: realpathCache, - }); -} - -// Given a path, check if the file exists with any of the set extensions -function tryExtensions(p, exts, isMain) { +/** + * Given a path, check if the file exists with any of the set extensions. + * @param {string} basePath The path and filename without extension + * @param {string[]} exts The extensions to try + * @param {boolean} isMain Whether the module is the main module + */ +function tryExtensions(basePath, exts, isMain) { for (let i = 0; i < exts.length; i++) { - const filename = tryFile(p + exts[i], isMain); + const filename = tryFile(basePath + exts[i], isMain); if (filename) { return filename; @@ -488,8 +488,10 @@ function tryExtensions(p, exts, isMain) { return false; } -// Find the longest (possibly multi-dot) extension registered in -// Module._extensions +/** + * Find the longest (possibly multi-dot) extension registered in `Module._extensions`. + * @param {string} filename The filename to find the longest registered extension for. + */ function findLongestRegisteredExtension(filename) { const name = path.basename(filename); let currentExtension; @@ -497,15 +499,19 @@ function findLongestRegisteredExtension(filename) { let startIndex = 0; while ((index = StringPrototypeIndexOf(name, '.', startIndex)) !== -1) { startIndex = index + 1; - if (index === 0) continue; // Skip dotfiles like .gitignore + if (index === 0) { continue; } // Skip dotfiles like .gitignore currentExtension = StringPrototypeSlice(name, index); - if (Module._extensions[currentExtension]) return currentExtension; + if (Module._extensions[currentExtension]) { return currentExtension; } } return '.js'; } +/** + * Tries to get the absolute file path of the parent module. + * @param {Module} parent The parent module object. + */ function trySelfParentPath(parent) { - if (!parent) return false; + if (!parent) { return false; } if (parent.filename) { return parent.filename; @@ -518,12 +524,18 @@ function trySelfParentPath(parent) { } } +/** + * Attempt to resolve a module request using the parent module package metadata. + * @param {string} parentPath The path of the parent module + * @param {string} request The module request to resolve + */ function trySelf(parentPath, request) { - if (!parentPath) return false; + if (!parentPath) { return false; } - const { data: pkg, path: pkgPath } = readPackageScope(parentPath) || {}; - if (!pkg || pkg.exports === undefined) return false; - if (typeof pkg.name !== 'string') return false; + const { data: pkg, path: pkgPath } = packageJsonReader.readPackageScope(parentPath); + if (!pkg || pkg.exports == null || pkg.name === undefined) { + return false; + } let expansion; if (request === pkg.name) { @@ -540,42 +552,52 @@ function trySelf(parentPath, request) { pathToFileURL(pkgPath + '/package.json'), expansion, pkg, pathToFileURL(parentPath), getCjsConditions()), parentPath, pkgPath); } catch (e) { - if (e.code === 'ERR_MODULE_NOT_FOUND') + if (e.code === 'ERR_MODULE_NOT_FOUND') { throw createEsmNotFoundErr(request, pkgPath + '/package.json'); + } throw e; } } -// This only applies to requests of a specific form: -// 1. name/.* -// 2. @scope/name/.* +/** + * This only applies to requests of a specific form: + * 1. `name/.*` + * 2. `@scope/name/.*` + */ const EXPORTS_PATTERN = /^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)?$/; + +/** + * Resolves the exports for a given module path and request. + * @param {string} nmPath The path to the module. + * @param {string} request The request for the module. + */ function resolveExports(nmPath, request) { // The implementation's behavior is meant to mirror resolution in ESM. const { 1: name, 2: expansion = '' } = RegExpPrototypeExec(EXPORTS_PATTERN, request) || kEmptyObject; - if (!name) - return; + if (!name) { return; } const pkgPath = path.resolve(nmPath, name); const pkg = _readPackage(pkgPath); - if (pkg?.exports != null) { + if (pkg.exists && pkg.exports != null) { try { const { packageExportsResolve } = require('internal/modules/esm/resolve'); return finalizeEsmResolution(packageExportsResolve( pathToFileURL(pkgPath + '/package.json'), '.' + expansion, pkg, null, getCjsConditions()), null, pkgPath); } catch (e) { - if (e.code === 'ERR_MODULE_NOT_FOUND') + if (e.code === 'ERR_MODULE_NOT_FOUND') { throw createEsmNotFoundErr(request, pkgPath + '/package.json'); + } throw e; } } } /** - * @param {string} request a relative or absolute file path - * @param {Array} paths file system directories to search as file paths - * @param {boolean} isMain if the request is the main app entry point + * Get the absolute path to a module. + * @param {string} request Relative or absolute file path + * @param {Array} paths Folders to search as file paths + * @param {boolean} isMain Whether the request is the main app entry point * @returns {string | false} */ Module._findPath = function(request, paths, isMain) { @@ -588,8 +610,9 @@ Module._findPath = function(request, paths, isMain) { const cacheKey = request + '\x00' + ArrayPrototypeJoin(paths, '\x00'); const entry = Module._pathCache[cacheKey]; - if (entry) + if (entry) { return entry; + } let exts; const trailingSlash = request.length > 0 && @@ -627,12 +650,15 @@ Module._findPath = function(request, paths, isMain) { for (let i = 0; i < paths.length; i++) { // Don't search further if path doesn't exist and request is inside the path const curPath = paths[i]; - if (insidePath && curPath && _stat(curPath) < 1) continue; + if (insidePath && curPath && _stat(curPath) < 1) { + continue; + } if (!absoluteRequest) { const exportsResolved = resolveExports(curPath, request); - if (exportsResolved) + if (exportsResolved) { return exportsResolved; + } } const basePath = path.resolve(curPath, request); @@ -664,16 +690,18 @@ Module._findPath = function(request, paths, isMain) { if (!filename) { // Try it with each of the extensions - if (exts === undefined) + if (exts === undefined) { exts = ObjectKeys(Module._extensions); + } filename = tryExtensions(basePath, exts, isMain); } } if (!filename && rc === 1) { // Directory. // try it with each of the extensions at "index" - if (exts === undefined) + if (exts === undefined) { exts = ObjectKeys(Module._extensions); + } filename = tryPackage(basePath, exts, isMain, request); } @@ -692,11 +720,14 @@ Module._findPath = function(request, paths, isMain) { return false; }; -// 'node_modules' character codes reversed +/** `node_modules` character codes reversed */ const nmChars = [ 115, 101, 108, 117, 100, 111, 109, 95, 101, 100, 111, 110 ]; const nmLen = nmChars.length; if (isWindows) { - // 'from' is the __dirname of the module. + /** + * Get the paths to the `node_modules` folder for a given path. + * @param {string} from `__dirname` of the module + */ Module._nodeModulePaths = function(from) { // Guarantee that 'from' is absolute. from = path.resolve(from); @@ -709,9 +740,11 @@ if (isWindows) { // path.resolve will make sure from.length >=3 in Windows. if (StringPrototypeCharCodeAt(from, from.length - 1) === CHAR_BACKWARD_SLASH && - StringPrototypeCharCodeAt(from, from.length - 2) === CHAR_COLON) + StringPrototypeCharCodeAt(from, from.length - 2) === CHAR_COLON) { return [from + 'node_modules']; + } + /** @type {string[]} */ const paths = []; for (let i = from.length - 1, p = 0, last = from.length; i >= 0; --i) { const code = StringPrototypeCharCodeAt(from, i); @@ -723,11 +756,12 @@ if (isWindows) { if (code === CHAR_BACKWARD_SLASH || code === CHAR_FORWARD_SLASH || code === CHAR_COLON) { - if (p !== nmLen) + if (p !== nmLen) { ArrayPrototypePush( paths, StringPrototypeSlice(from, 0, last) + '\\node_modules', ); + } last = i; p = 0; } else if (p !== -1) { @@ -742,27 +776,33 @@ if (isWindows) { return paths; }; } else { // posix - // 'from' is the __dirname of the module. + /** + * Get the paths to the `node_modules` folder for a given path. + * @param {string} from `__dirname` of the module + */ Module._nodeModulePaths = function(from) { // Guarantee that 'from' is absolute. from = path.resolve(from); // Return early not only to avoid unnecessary work, but to *avoid* returning // an array of two items for a root: [ '//node_modules', '/node_modules' ] - if (from === '/') + if (from === '/') { return ['/node_modules']; + } // note: this approach *only* works when the path is guaranteed // to be absolute. Doing a fully-edge-case-correct path.split // that works on both Windows and Posix is non-trivial. + /** @type {string[]} */ const paths = []; for (let i = from.length - 1, p = 0, last = from.length; i >= 0; --i) { const code = StringPrototypeCharCodeAt(from, i); if (code === CHAR_FORWARD_SLASH) { - if (p !== nmLen) + if (p !== nmLen) { ArrayPrototypePush( paths, StringPrototypeSlice(from, 0, last) + '/node_modules', ); + } last = i; p = 0; } else if (p !== -1) { @@ -781,6 +821,11 @@ if (isWindows) { }; } +/** + * Get the paths for module resolution. + * @param {string} request + * @param {Module} parent + */ Module._resolveLookupPaths = function(request, parent) { if (BuiltinModule.normalizeRequirableId(request)) { debug('looking for %j in []', request); @@ -794,6 +839,7 @@ Module._resolveLookupPaths = function(request, parent) { StringPrototypeCharAt(request, 1) !== '/' && (!isWindows || StringPrototypeCharAt(request, 1) !== '\\'))) { + /** @type {string[]} */ let paths; if (parent?.paths?.length) { paths = ArrayPrototypeSlice(modulePaths); @@ -823,6 +869,10 @@ Module._resolveLookupPaths = function(request, parent) { return parentDir; }; +/** + * Emits a warning when a non-existent property of module exports is accessed inside a circular dependency. + * @param {string} prop The name of the non-existent property. + */ function emitCircularRequireWarning(prop) { process.emitWarning( `Accessing non-existent property '${String(prop)}' of module exports ` + @@ -839,19 +889,26 @@ const CircularRequirePrototypeWarningProxy = new Proxy({}, { // Allow __esModule access in any case because it is used in the output // of transpiled code to determine whether something comes from an // ES module, and is not used as a regular key of `module.exports`. - if (prop in target || prop === '__esModule') return target[prop]; + if (prop in target || prop === '__esModule') { return target[prop]; } emitCircularRequireWarning(prop); return undefined; }, getOwnPropertyDescriptor(target, prop) { - if (ObjectPrototypeHasOwnProperty(target, prop) || prop === '__esModule') + if (ObjectPrototypeHasOwnProperty(target, prop) || prop === '__esModule') { return ObjectGetOwnPropertyDescriptor(target, prop); + } emitCircularRequireWarning(prop); return undefined; }, }); +/** + * Returns the exports object for a module that has a circular `require`. + * If the exports object is a plain object, it is wrapped in a proxy that warns + * about circular dependencies. + * @param {Module} module The module instance + */ function getExportsForCircularRequire(module) { if (module.exports && !isProxy(module.exports) && @@ -869,13 +926,17 @@ function getExportsForCircularRequire(module) { return module.exports; } -// Check the cache for the requested file. -// 1. If a module already exists in the cache: return its exports object. -// 2. If the module is native: call -// `BuiltinModule.prototype.compileForPublicLoader()` and return the exports. -// 3. Otherwise, create a new module for the file and save it to the cache. -// Then have it load the file contents before returning its exports -// object. +/** + * Load a module from cache if it exists, otherwise create a new module instance. + * 1. If a module already exists in the cache: return its exports object. + * 2. If the module is native: call + * `BuiltinModule.prototype.compileForPublicLoader()` and return the exports. + * 3. Otherwise, create a new module for the file and save it to the cache. + * Then have it load the file contents before returning its exports object. + * @param {string} request Specifier of module to load via `require` + * @param {string} parent Absolute path of the module importing the child + * @param {boolean} isMain Whether the module is the main entry point + */ Module._load = function(request, parent, isMain) { let relResolveCacheIdentifier; if (parent) { @@ -890,8 +951,9 @@ Module._load = function(request, parent, isMain) { const cachedModule = Module._cache[filename]; if (cachedModule !== undefined) { updateChildren(parent, cachedModule, true); - if (!cachedModule.loaded) + if (!cachedModule.loaded) { return getExportsForCircularRequire(cachedModule); + } return cachedModule.exports; } delete relativeResolveCache[relResolveCacheIdentifier]; @@ -916,8 +978,9 @@ Module._load = function(request, parent, isMain) { updateChildren(parent, cachedModule, true); if (!cachedModule.loaded) { const parseCachedModule = cjsParseCache.get(cachedModule); - if (!parseCachedModule || parseCachedModule.loaded) + if (!parseCachedModule || parseCachedModule.loaded) { return getExportsForCircularRequire(cachedModule); + } parseCachedModule.loaded = true; } else { return cachedModule.exports; @@ -973,6 +1036,15 @@ Module._load = function(request, parent, isMain) { return module.exports; }; +/** + * Given a `require` string and its context, get its absolute file path. + * @param {string} request The specifier to resolve + * @param {Module} parent The module containing the `require` call + * @param {boolean} isMain Whether the module is the main entry point + * @param {ResolveFilenameOptions} options Options object + * @typedef {object} ResolveFilenameOptions + * @property {string[]} paths Paths to search for modules in + */ Module._resolveFilename = function(request, parent, isMain, options) { if (BuiltinModule.normalizeRequirableId(request)) { return request; @@ -1000,8 +1072,9 @@ Module._resolveFilename = function(request, parent, isMain, options) { const lookupPaths = Module._resolveLookupPaths(request, fakeParent); for (let j = 0; j < lookupPaths.length; j++) { - if (!ArrayPrototypeIncludes(paths, lookupPaths[j])) + if (!ArrayPrototypeIncludes(paths, lookupPaths[j])) { ArrayPrototypePush(paths, lookupPaths[j]); + } } } } @@ -1016,7 +1089,7 @@ Module._resolveFilename = function(request, parent, isMain, options) { if (request[0] === '#' && (parent?.filename || parent?.id === '')) { const parentPath = parent?.filename ?? process.cwd() + path.sep; - const pkg = readPackageScope(parentPath) || {}; + const pkg = packageJsonReader.readPackageScope(parentPath) || { __proto__: null }; if (pkg.data?.imports != null) { try { const { packageImportsResolve } = require('internal/modules/esm/resolve'); @@ -1025,8 +1098,9 @@ Module._resolveFilename = function(request, parent, isMain, options) { getCjsConditions()), parentPath, pkg.path); } catch (e) { - if (e.code === 'ERR_MODULE_NOT_FOUND') + if (e.code === 'ERR_MODULE_NOT_FOUND') { throw createEsmNotFoundErr(request); + } throw e; } } @@ -1044,7 +1118,7 @@ Module._resolveFilename = function(request, parent, isMain, options) { // Look up the filename first, since that's the cache key. const filename = Module._findPath(request, paths, isMain); - if (filename) return filename; + if (filename) { return filename; } const requireStack = []; for (let cursor = parent; cursor; @@ -1063,31 +1137,50 @@ Module._resolveFilename = function(request, parent, isMain, options) { throw err; }; +/** + * Finishes resolving an ES module specifier into an absolute file path. + * @param {string} resolved The resolved module specifier + * @param {string} parentPath The path of the parent module + * @param {string} pkgPath The path of the package.json file + * @throws {ERR_INVALID_MODULE_SPECIFIER} If the resolved module specifier contains encoded `/` or `\\` characters + * @throws {Error} If the module cannot be found + */ function finalizeEsmResolution(resolved, parentPath, pkgPath) { const { encodedSepRegEx } = require('internal/modules/esm/resolve'); - if (RegExpPrototypeExec(encodedSepRegEx, resolved) !== null) + if (RegExpPrototypeExec(encodedSepRegEx, resolved) !== null) { throw new ERR_INVALID_MODULE_SPECIFIER( resolved, 'must not include encoded "/" or "\\" characters', parentPath); + } const filename = fileURLToPath(resolved); const actual = tryFile(filename); - if (actual) + if (actual) { return actual; + } const err = createEsmNotFoundErr(filename, path.resolve(pkgPath, 'package.json')); throw err; } +/** + * Creates an error object for when a requested ES module cannot be found. + * @param {string} request The name of the requested module + * @param {string} [path] The path to the requested module + */ function createEsmNotFoundErr(request, path) { // eslint-disable-next-line no-restricted-syntax const err = new Error(`Cannot find module '${request}'`); err.code = 'MODULE_NOT_FOUND'; - if (path) + if (path) { err.path = path; + } // TODO(BridgeAR): Add the requireStack as well. return err; } -// Given a file name, pass it to the proper extension handler. +/** + * Given a file name, pass it to the proper extension handler. + * @param {string} filename The `require` specifier + */ Module.prototype.load = function(filename) { debug('load %j for module %j', filename, this.id); @@ -1097,8 +1190,9 @@ Module.prototype.load = function(filename) { const extension = findLongestRegisteredExtension(filename); // allow .mjs to be overridden - if (StringPrototypeEndsWith(filename, '.mjs') && !Module._extensions['.mjs']) + if (StringPrototypeEndsWith(filename, '.mjs') && !Module._extensions['.mjs']) { throw new ERR_REQUIRE_ESM(filename, true); + } Module._extensions[extension](this, filename); this.loaded = true; @@ -1109,13 +1203,17 @@ Module.prototype.load = function(filename) { // Preemptively cache if ((module?.module === undefined || module.module.getStatus() < kEvaluated) && - !cascadedLoader.cjsCache.has(this)) + !cascadedLoader.cjsCache.has(this)) { cascadedLoader.cjsCache.set(this, exports); + } }; -// Loads a module at the given file path. Returns that module's -// `exports` property. -// Note: when using the experimental policy mechanism this function is overridden +/** + * Loads a module at the given file path. Returns that module's `exports` property. + * Note: when using the experimental policy mechanism this function is overridden. + * @param {string} id + * @throws {ERR_INVALID_ARG_TYPE} When `id` is not a string + */ Module.prototype.require = function(id) { validateString(id, 'id'); if (id === '') { @@ -1130,11 +1228,22 @@ Module.prototype.require = function(id) { } }; -// Resolved path to process.argv[1] will be lazily placed here -// (needed for setting breakpoint when called with --inspect-brk) +/** + * Resolved path to `process.argv[1]` will be lazily placed here + * (needed for setting breakpoint when called with `--inspect-brk`). + * @type {string | undefined} + */ let resolvedArgv; let hasPausedEntry = false; +/** @type {import('vm').Script} */ let Script; + +/** + * Wraps the given content in a script and runs it in a new context. + * @param {string} filename The name of the file being loaded + * @param {string} content The content of the file being loaded + * @param {Module} cjsModuleInstance The CommonJS loader instance + */ function wrapSafe(filename, content, cjsModuleInstance) { if (patched) { const wrapper = Module.wrap(content); @@ -1144,10 +1253,10 @@ function wrapSafe(filename, content, cjsModuleInstance) { const script = new Script(wrapper, { filename, lineOffset: 0, - importModuleDynamically: async (specifier, _, importAssertions) => { + importModuleDynamically: async (specifier, _, importAttributes) => { const cascadedLoader = getCascadedLoader(); return cascadedLoader.import(specifier, normalizeReferrerURL(filename), - importAssertions); + importAttributes); }, }); @@ -1170,10 +1279,10 @@ function wrapSafe(filename, content, cjsModuleInstance) { '__dirname', ], { filename, - importModuleDynamically(specifier, _, importAssertions) { + importModuleDynamically(specifier, _, importAttributes) { const cascadedLoader = getCascadedLoader(); return cascadedLoader.import(specifier, normalizeReferrerURL(filename), - importAssertions); + importAttributes); }, }); @@ -1192,10 +1301,12 @@ function wrapSafe(filename, content, cjsModuleInstance) { } } -// Run the file contents in the correct scope or sandbox. Expose -// the correct helper variables (require, module, exports) to -// the file. -// Returns exception, if any. +/** + * Run the file contents in the correct scope or sandbox. Expose the correct helper variables (`require`, `module`, + * `exports`) to the file. Returns exception, if any. + * @param {string} content The source code of the module + * @param {string} filename The file path of the module + */ Module.prototype._compile = function(content, filename) { let moduleURL; let redirects; @@ -1237,7 +1348,7 @@ Module.prototype._compile = function(content, filename) { const exports = this.exports; const thisValue = exports; const module = this; - if (requireDepth === 0) statCache = new SafeMap(); + if (requireDepth === 0) { statCache = new SafeMap(); } if (inspectorWrapper) { result = inspectorWrapper(compiledWrapper, thisValue, exports, require, module, filename, dirname); @@ -1246,11 +1357,15 @@ Module.prototype._compile = function(content, filename) { [exports, require, module, filename, dirname]); } hasLoadedAnyUserCJSModule = true; - if (requireDepth === 0) statCache = null; + if (requireDepth === 0) { statCache = null; } return result; }; -// Native extension for .js +/** + * Native handler for `.js` files. + * @param {Module} module The module to compile + * @param {string} filename The file path of the module + */ Module._extensions['.js'] = function(module, filename) { // If already analyzed the source, then it will be cached. const cached = cjsParseCache.get(module); @@ -1262,9 +1377,9 @@ Module._extensions['.js'] = function(module, filename) { content = fs.readFileSync(filename, 'utf8'); } if (StringPrototypeEndsWith(filename, '.js')) { - const pkg = readPackageScope(filename); + const pkg = packageJsonReader.readPackageScope(filename) || { __proto__: null }; // Function require shouldn't be used in ES modules. - if (pkg?.data?.type === 'module') { + if (pkg.data?.type === 'module') { const parent = moduleParentCache.get(module); const parentPath = parent?.filename; const packageJsonPath = path.resolve(pkg.path, 'package.json'); @@ -1299,8 +1414,11 @@ Module._extensions['.js'] = function(module, filename) { module._compile(content, filename); }; - -// Native extension for .json +/** + * Native handler for `.json` files. + * @param {Module} module The module to compile + * @param {string} filename The file path of the module + */ Module._extensions['.json'] = function(module, filename) { const content = fs.readFileSync(filename, 'utf8'); @@ -1318,8 +1436,11 @@ Module._extensions['.json'] = function(module, filename) { } }; - -// Native extension for .node +/** + * Native handler for `.node` files. + * @param {Module} module The module to compile + * @param {string} filename The file path of the module + */ Module._extensions['.node'] = function(module, filename) { const manifest = policy()?.manifest; if (manifest) { @@ -1331,6 +1452,10 @@ Module._extensions['.node'] = function(module, filename) { return process.dlopen(module, path.toNamespacedPath(filename)); }; +/** + * Creates a `require` function that can be used to load modules from the specified path. + * @param {string} filename The path to the module + */ function createRequireFromPath(filename) { // Allow a directory to be passed as the filename const trailingSlash = @@ -1351,6 +1476,12 @@ function createRequireFromPath(filename) { const createRequireError = 'must be a file URL object, file URL string, or ' + 'absolute path string'; +/** + * Creates a new `require` function that can be used to load modules. + * @param {string | URL} filename The path or URL to the module context for this `require` + * @throws {ERR_INVALID_ARG_VALUE} If `filename` is not a string or URL, or if it is a relative path that cannot be + * resolved to an absolute path. + */ function createRequire(filename) { let filepath; @@ -1372,6 +1503,9 @@ function createRequire(filename) { Module.createRequire = createRequire; +/** + * Define the paths to use for resolving a module. + */ Module._initPaths = function() { const homeDir = isWindows ? process.env.USERPROFILE : safeGetenv('HOME'); const nodePath = isWindows ? process.env.NODE_PATH : safeGetenv('NODE_PATH'); @@ -1402,9 +1536,12 @@ Module._initPaths = function() { Module.globalPaths = ArrayPrototypeSlice(modulePaths); }; +/** + * Handle modules loaded via `--require`. + * @param {string[]} requests The values of `--require` + */ Module._preloadModules = function(requests) { - if (!ArrayIsArray(requests)) - return; + if (!ArrayIsArray(requests)) { return; } isPreloading = true; @@ -1420,11 +1557,16 @@ Module._preloadModules = function(requests) { throw e; } } - for (let n = 0; n < requests.length; n++) + for (let n = 0; n < requests.length; n++) { internalRequire(parent, requests[n]); + } isPreloading = false; }; +/** + * If the user has overridden an export from a builtin module, this function can ensure that the override is used in + * both CommonJS and ES module contexts. + */ Module.syncBuiltinESMExports = function syncBuiltinESMExports() { for (const mod of BuiltinModule.map.values()) { if (BuiltinModule.canBeRequiredWithoutScheme(mod.id)) { diff --git a/lib/internal/modules/esm/assert.js b/lib/internal/modules/esm/assert.js index b1267a10a7a6b2..ce3280de84bf4d 100644 --- a/lib/internal/modules/esm/assert.js +++ b/lib/internal/modules/esm/assert.js @@ -3,7 +3,6 @@ const { ArrayPrototypeFilter, ArrayPrototypeIncludes, - ObjectCreate, ObjectKeys, ObjectValues, ObjectPrototypeHasOwnProperty, @@ -14,16 +13,15 @@ const { ERR_IMPORT_ASSERTION_TYPE_FAILED, ERR_IMPORT_ASSERTION_TYPE_MISSING, ERR_IMPORT_ASSERTION_TYPE_UNSUPPORTED, + ERR_IMPORT_ATTRIBUTE_UNSUPPORTED, } = require('internal/errors').codes; // The HTML spec has an implied default type of `'javascript'`. const kImplicitAssertType = 'javascript'; -let alreadyWarned = false; - /** - * Define a map of module formats to import assertion types (the value of - * `type` in `assert { type: 'json' }`). + * Define a map of module formats to import attributes types (the value of + * `type` in `with { type: 'json' }`). * @type {Map} */ const formatTypeMap = { @@ -32,13 +30,13 @@ const formatTypeMap = { 'commonjs': kImplicitAssertType, 'json': 'json', 'module': kImplicitAssertType, - 'wasm': kImplicitAssertType, // It's unclear whether the HTML spec will require an assertion type or not for Wasm; see https://github.com/WebAssembly/esm-integration/issues/42 + 'wasm': kImplicitAssertType, // It's unclear whether the HTML spec will require an attribute type or not for Wasm; see https://github.com/WebAssembly/esm-integration/issues/42 }; /** * The HTML spec disallows the default type to be explicitly specified * (for now); so `import './file.js'` is okay but - * `import './file.js' assert { type: 'javascript' }` throws. + * `import './file.js' with { type: 'javascript' }` throws. * @type {Array} */ const supportedAssertionTypes = ArrayPrototypeFilter( @@ -47,54 +45,50 @@ const supportedAssertionTypes = ArrayPrototypeFilter( /** - * Test a module's import assertions. + * Test a module's import attributes. * @param {string} url The URL of the imported module, for error reporting. * @param {string} format One of Node's supported translators - * @param {Record} importAssertions Validations for the + * @param {Record} importAttributes Validations for the * module import. * @returns {true} * @throws {TypeError} If the format and assertion type are incompatible. */ -function validateAssertions(url, format, - importAssertions = ObjectCreate(null)) { - const validType = formatTypeMap[format]; - - if (!alreadyWarned && ObjectKeys(importAssertions).length !== 0) { - alreadyWarned = true; - process.emitWarning( - 'Import assertions are not a stable feature of the JavaScript language. ' + - 'Avoid relying on their current behavior and syntax as those might change ' + - 'in a future version of Node.js.', - 'ExperimentalWarning', - ); +function validateAttributes(url, format, + importAttributes = { __proto__: null }) { + const keys = ObjectKeys(importAttributes); + for (let i = 0; i < keys.length; i++) { + if (keys[i] !== 'type') { + throw new ERR_IMPORT_ATTRIBUTE_UNSUPPORTED(keys[i], importAttributes[keys[i]]); + } } + const validType = formatTypeMap[format]; switch (validType) { case undefined: - // Ignore assertions for module formats we don't recognize, to allow new + // Ignore attributes for module formats we don't recognize, to allow new // formats in the future. return true; case kImplicitAssertType: // This format doesn't allow an import assertion type, so the property - // must not be set on the import assertions object. - if (!ObjectPrototypeHasOwnProperty(importAssertions, 'type')) { + // must not be set on the import attributes object. + if (!ObjectPrototypeHasOwnProperty(importAttributes, 'type')) { return true; } - return handleInvalidType(url, importAssertions.type); + return handleInvalidType(url, importAttributes.type); - case importAssertions.type: + case importAttributes.type: // The asserted type is the valid type for this format. return true; default: // There is an expected type for this format, but the value of - // `importAssertions.type` might not have been it. - if (!ObjectPrototypeHasOwnProperty(importAssertions, 'type')) { + // `importAttributes.type` might not have been it. + if (!ObjectPrototypeHasOwnProperty(importAttributes, 'type')) { // `type` wasn't specified at all. throw new ERR_IMPORT_ASSERTION_TYPE_MISSING(url, validType); } - return handleInvalidType(url, importAssertions.type); + return handleInvalidType(url, importAttributes.type); } } @@ -119,5 +113,5 @@ function handleInvalidType(url, type) { module.exports = { kImplicitAssertType, - validateAssertions, + validateAttributes, }; diff --git a/lib/internal/modules/esm/create_dynamic_module.js b/lib/internal/modules/esm/create_dynamic_module.js index 32f1c82a7a20c2..c0060c47e93b5a 100644 --- a/lib/internal/modules/esm/create_dynamic_module.js +++ b/lib/internal/modules/esm/create_dynamic_module.js @@ -12,12 +12,22 @@ let debug = require('internal/util/debuglog').debuglog('esm', (fn) => { debug = fn; }); +/** + * Creates an import statement for a given module path and index. + * @param {string} impt - The module path to import. + * @param {number} index - The index of the import statement. + */ function createImport(impt, index) { const imptPath = JSONStringify(impt); return `import * as $import_${index} from ${imptPath}; import.meta.imports[${imptPath}] = $import_${index};`; } +/** + * Creates an export for a given module. + * @param {string} expt - The name of the export. + * @param {number} index - The index of the export statement. + */ function createExport(expt, index) { const nameStringLit = JSONStringify(expt); return `let $export_${index}; @@ -28,6 +38,17 @@ import.meta.exports[${nameStringLit}] = { };`; } +/** + * Creates a dynamic module with the given imports, exports, URL, and evaluate function. + * @param {string[]} imports - An array of imports. + * @param {string[]} exports - An array of exports. + * @param {string} [url=''] - The URL of the module. + * @param {(reflect: DynamicModuleReflect) => void} evaluate - The function to evaluate the module. + * @typedef {object} DynamicModuleReflect + * @property {string[]} imports - The imports of the module. + * @property {string[]} exports - The exports of the module. + * @property {(cb: (reflect: DynamicModuleReflect) => void) => void} onReady - Callback to evaluate the module. + */ const createDynamicModule = (imports, exports, url = '', evaluate) => { debug('creating ESM facade for %s with exports: %j', url, exports); const source = ` @@ -39,19 +60,22 @@ import.meta.done(); const m = new ModuleWrap(`${url}`, undefined, source, 0, 0); const readyfns = new SafeSet(); + /** @type {DynamicModuleReflect} */ const reflect = { exports: ObjectCreate(null), onReady: (cb) => { readyfns.add(cb); }, }; - if (imports.length) - reflect.imports = ObjectCreate(null); + if (imports.length) { + reflect.imports = { __proto__: null }; + } const { setCallbackForWrap } = require('internal/modules/esm/utils'); setCallbackForWrap(m, { initializeImportMeta: (meta, wrap) => { meta.exports = reflect.exports; - if (reflect.imports) + if (reflect.imports) { meta.imports = reflect.imports; + } meta.done = () => { evaluate(reflect); reflect.onReady = (cb) => cb(reflect); diff --git a/lib/internal/modules/esm/fetch_module.js b/lib/internal/modules/esm/fetch_module.js index 74d2d2599dbd45..21b7456899604f 100644 --- a/lib/internal/modules/esm/fetch_module.js +++ b/lib/internal/modules/esm/fetch_module.js @@ -44,37 +44,56 @@ const cacheForGET = new SafeMap(); // [2] Creating a new agent instead of using the gloabl agent improves // performance and precludes the agent becoming tainted. +/** @type {import('https').Agent} The Cached HTTP Agent for **secure** HTTP requests. */ let HTTPSAgent; -function HTTPSGet(url, opts) { +/** + * Make a HTTPs GET request (handling agent setup if needed, caching the agent to avoid + * redudant instantiations). + * @param {Parameters[0]} input - The URI to fetch. + * @param {Parameters[1]} options - See https.get() options. + */ +function HTTPSGet(input, options) { const https = require('https'); // [1] HTTPSAgent ??= new https.Agent({ // [2] keepAlive: true, }); - return https.get(url, { + return https.get(input, { agent: HTTPSAgent, - ...opts, + ...options, }); } +/** @type {import('https').Agent} The Cached HTTP Agent for **insecure** HTTP requests. */ let HTTPAgent; -function HTTPGet(url, opts) { +/** + * Make a HTTP GET request (handling agent setup if needed, caching the agent to avoid + * redudant instantiations). + * @param {Parameters[0]} input - The URI to fetch. + * @param {Parameters[1]} options - See http.get() options. + */ +function HTTPGet(input, options) { const http = require('http'); // [1] HTTPAgent ??= new http.Agent({ // [2] keepAlive: true, }); - return http.get(url, { + return http.get(input, { agent: HTTPAgent, - ...opts, + ...options, }); } -function dnsLookup(name, opts) { +/** @type {import('../../dns/promises.js').lookup} */ +function dnsLookup(hostname, options) { // eslint-disable-next-line no-func-assign dnsLookup = require('dns/promises').lookup; - return dnsLookup(name, opts); + return dnsLookup(hostname, options); } let zlib; +/** + * Create a decompressor for the Brotli format. + * @returns {import('zlib').BrotliDecompress} + */ function createBrotliDecompress() { zlib ??= require('zlib'); // [1] // eslint-disable-next-line no-func-assign @@ -82,6 +101,10 @@ function createBrotliDecompress() { return createBrotliDecompress(); } +/** + * Create an unzip handler. + * @returns {import('zlib').Unzip} + */ function createUnzip() { zlib ??= require('zlib'); // [1] // eslint-disable-next-line no-func-assign @@ -144,7 +167,7 @@ function fetchWithRedirects(parsed) { return entry; } if (res.statusCode === 404) { - const err = new ERR_MODULE_NOT_FOUND(parsed.href, null); + const err = new ERR_MODULE_NOT_FOUND(parsed.href, null, parsed); err.message = `Cannot find module '${parsed.href}', HTTP 404`; throw err; } diff --git a/lib/internal/modules/esm/formats.js b/lib/internal/modules/esm/formats.js index b52a31ff54080e..b081cbe8dd54d5 100644 --- a/lib/internal/modules/esm/formats.js +++ b/lib/internal/modules/esm/formats.js @@ -2,9 +2,11 @@ const { RegExpPrototypeExec, + Uint8Array, } = primordials; const { getOptionValue } = require('internal/options'); +const { closeSync, openSync, readSync } = require('fs'); const experimentalWasmModules = getOptionValue('--experimental-wasm-modules'); @@ -39,9 +41,9 @@ function mimeToFormat(mime) { /\s*(text|application)\/javascript\s*(;\s*charset=utf-?8\s*)?/i, mime, ) !== null - ) return 'module'; - if (mime === 'application/json') return 'json'; - if (experimentalWasmModules && mime === 'application/wasm') return 'wasm'; + ) { return 'module'; } + if (mime === 'application/json') { return 'json'; } + if (experimentalWasmModules && mime === 'application/wasm') { return 'wasm'; } return null; } @@ -49,8 +51,34 @@ function getLegacyExtensionFormat(ext) { return legacyExtensionFormatMap[ext]; } +/** + * For extensionless files in a `module` package scope, or a default `module` scope enabled by the + * `--experimental-default-type` flag, we check the file contents to disambiguate between ES module JavaScript and Wasm. + * We do this by taking advantage of the fact that all Wasm files start with the header `0x00 0x61 0x73 0x6d` (`_asm`). + * @param {URL} url + */ +function getFormatOfExtensionlessFile(url) { + if (!experimentalWasmModules) { return 'module'; } + + const magic = new Uint8Array(4); + let fd; + try { + // TODO(@anonrig): Optimize the following by having a single C++ call + fd = openSync(url); + readSync(fd, magic, 0, 4); // Only read the first four bytes + if (magic[0] === 0x00 && magic[1] === 0x61 && magic[2] === 0x73 && magic[3] === 0x6d) { + return 'wasm'; + } + } finally { + if (fd !== undefined) { closeSync(fd); } + } + + return 'module'; +} + module.exports = { extensionFormatMap, + getFormatOfExtensionlessFile, getLegacyExtensionFormat, legacyExtensionFormatMap, mimeToFormat, diff --git a/lib/internal/modules/esm/get_format.js b/lib/internal/modules/esm/get_format.js index 0f600b9cdcfc68..9ad0110b0c3716 100644 --- a/lib/internal/modules/esm/get_format.js +++ b/lib/internal/modules/esm/get_format.js @@ -1,16 +1,18 @@ 'use strict'; + const { RegExpPrototypeExec, ObjectPrototypeHasOwnProperty, PromisePrototypeThen, PromiseResolve, + StringPrototypeIncludes, StringPrototypeCharCodeAt, StringPrototypeSlice, } = primordials; -const { basename, relative } = require('path'); const { getOptionValue } = require('internal/options'); const { extensionFormatMap, + getFormatOfExtensionlessFile, getLegacyExtensionFormat, mimeToFormat, } = require('internal/modules/esm/formats'); @@ -19,7 +21,10 @@ const experimentalNetworkImports = getOptionValue('--experimental-network-imports'); const experimentalSpecifierResolution = getOptionValue('--experimental-specifier-resolution'); -const { getPackageType, getPackageScopeConfig } = require('internal/modules/esm/resolve'); +const defaultTypeFlag = getOptionValue('--experimental-default-type'); +// The next line is where we flip the default to ES modules someday. +const defaultType = defaultTypeFlag === 'module' ? 'module' : 'commonjs'; +const { getPackageType } = require('internal/modules/esm/resolve'); const { fileURLToPath } = require('internal/url'); const { ERR_UNKNOWN_FILE_EXTENSION } = require('internal/errors').codes; @@ -69,6 +74,18 @@ function extname(url) { return ''; } +/** + * Determine whether the given file URL is under a `node_modules` folder. + * This function assumes that the input has already been verified to be a `file:` URL, + * and is a file rather than a folder. + * @param {URL} url + */ +function underNodeModules(url) { + if (url.protocol !== 'file:') { return false; } // We determine module types for other protocols based on MIME header + + return StringPrototypeIncludes(url.pathname, '/node_modules/'); +} + /** * @param {URL} url * @param {{parentURL: string}} context @@ -77,30 +94,46 @@ function extname(url) { */ function getFileProtocolModuleFormat(url, context, ignoreErrors) { const ext = extname(url); + if (ext === '.js') { - return getPackageType(url) === 'module' ? 'module' : 'commonjs'; + const packageType = getPackageType(url); + if (packageType !== 'none') { + return packageType; + } + // The controlling `package.json` file has no `type` field. + if (defaultType === 'module') { + // An exception to the type flag making ESM the default everywhere is that package scopes under `node_modules` + // should retain the assumption that a lack of a `type` field means CommonJS. + return underNodeModules(url) ? 'commonjs' : 'module'; + } + return 'commonjs'; + } + + if (ext === '') { + const packageType = getPackageType(url); + if (defaultType === 'commonjs') { // Legacy behavior + if (packageType === 'none' || packageType === 'commonjs') { + return 'commonjs'; + } // Else packageType === 'module' + return getFormatOfExtensionlessFile(url); + } // Else defaultType === 'module' + if (underNodeModules(url)) { // Exception for package scopes under `node_modules` + return packageType === 'module' ? getFormatOfExtensionlessFile(url) : 'commonjs'; + } + if (packageType === 'none' || packageType === 'module') { + return getFormatOfExtensionlessFile(url); + } // Else packageType === 'commonjs' + return 'commonjs'; } const format = extensionFormatMap[ext]; - if (format) return format; + if (format) { return format; } if (experimentalSpecifierResolution !== 'node') { // Explicit undefined return indicates load hook should rerun format check - if (ignoreErrors) return undefined; + if (ignoreErrors) { return undefined; } const filepath = fileURLToPath(url); - let suggestion = ''; - if (getPackageType(url) === 'module' && ext === '') { - const config = getPackageScopeConfig(url); - const fileBasename = basename(filepath); - const relativePath = StringPrototypeSlice(relative(config.pjsonPath, filepath), 1); - suggestion = 'Loading extensionless files is not supported inside of ' + - '"type":"module" package.json contexts. The package.json file ' + - `${config.pjsonPath} caused this "type":"module" context. Try ` + - `changing ${filepath} to have a file extension. Note the "bin" ` + - 'field of package.json can point to a file with an extension, for example ' + - `{"type":"module","bin":{"${fileBasename}":"${relativePath}.js"}}`; - } - throw new ERR_UNKNOWN_FILE_EXTENSION(ext, filepath, suggestion); + throw new ERR_UNKNOWN_FILE_EXTENSION(ext, filepath); } return getLegacyExtensionFormat(ext) ?? null; diff --git a/lib/internal/modules/esm/handle_process_exit.js b/lib/internal/modules/esm/handle_process_exit.js index db830900bd3154..4febbcce54dd94 100644 --- a/lib/internal/modules/esm/handle_process_exit.js +++ b/lib/internal/modules/esm/handle_process_exit.js @@ -1,8 +1,10 @@ 'use strict'; -// Handle a Promise from running code that potentially does Top-Level Await. -// In that case, it makes sense to set the exit code to a specific non-zero -// value if the main code never finishes running. +/** + * Handle a Promise from running code that potentially does Top-Level Await. + * In that case, it makes sense to set the exit code to a specific non-zero value + * if the main code never finishes running. + */ function handleProcessExit() { process.exitCode ??= 13; } diff --git a/lib/internal/modules/esm/hooks.js b/lib/internal/modules/esm/hooks.js index 11e85326324f2e..d6f7e04923bff2 100644 --- a/lib/internal/modules/esm/hooks.js +++ b/lib/internal/modules/esm/hooks.js @@ -1,27 +1,45 @@ 'use strict'; const { - ArrayPrototypeJoin, ArrayPrototypePush, + ArrayPrototypePushApply, FunctionPrototypeCall, + Int32Array, ObjectAssign, ObjectDefineProperty, ObjectSetPrototypeOf, + Promise, + ReflectSet, SafeSet, StringPrototypeSlice, + StringPrototypeStartsWith, StringPrototypeToUpperCase, globalThis, } = primordials; const { - ERR_LOADER_CHAIN_INCOMPLETE, + Atomics: { + load: AtomicsLoad, + wait: AtomicsWait, + waitAsync: AtomicsWaitAsync, + }, + SharedArrayBuffer, +} = globalThis; + +const { ERR_INTERNAL_ASSERTION, ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, ERR_INVALID_RETURN_PROPERTY_VALUE, ERR_INVALID_RETURN_VALUE, + ERR_LOADER_CHAIN_INCOMPLETE, + ERR_METHOD_NOT_IMPLEMENTED, + ERR_UNKNOWN_BUILTIN_MODULE, + ERR_WORKER_UNSERIALIZABLE_ERROR, } = require('internal/errors').codes; -const { isURL, URL } = require('internal/url'); +const { URL } = require('internal/url'); +const { canParse: URLCanParse } = internalBinding('url'); +const { receiveMessageOnPort } = require('worker_threads'); const { isAnyArrayBuffer, isArrayBufferView, @@ -30,14 +48,60 @@ const { validateObject, validateString, } = require('internal/validators'); +const { + emitExperimentalWarning, + kEmptyObject, +} = require('internal/util'); const { defaultResolve, + throwIfInvalidParentURL, } = require('internal/modules/esm/resolve'); const { getDefaultConditions, + loaderWorkerId, } = require('internal/modules/esm/utils'); +const { deserializeError } = require('internal/error_serdes'); +const { + SHARED_MEMORY_BYTE_LENGTH, + WORKER_TO_MAIN_THREAD_NOTIFICATION, +} = require('internal/modules/esm/shared_constants'); +let debug = require('internal/util/debuglog').debuglog('esm', (fn) => { + debug = fn; +}); +let importMetaInitializer; + +let importAssertionAlreadyWarned = false; + +function emitImportAssertionWarning() { + if (!importAssertionAlreadyWarned) { + importAssertionAlreadyWarned = true; + process.emitWarning('Use `importAttributes` instead of `importAssertions`', 'ExperimentalWarning'); + } +} + +function defineImportAssertionAlias(context) { + return ObjectDefineProperty(context, 'importAssertions', { + __proto__: null, + configurable: true, + get() { + emitImportAssertionWarning(); + return this.importAttributes; + }, + set(value) { + emitImportAssertionWarning(); + return ReflectSet(this, 'importAttributes', value); + }, + }); +} +/** + * @typedef {object} ExportedHooks + * @property {Function} initialize Customizations setup hook. + * @property {Function} globalPreload Global preload hook. + * @property {Function} resolve Resolve hook. + * @property {Function} load Load hook. + */ /** * @typedef {object} KeyedHook @@ -47,9 +111,8 @@ const { // [2] `validate...()`s throw the wrong error - class Hooks { - #hooks = { + #chains = { /** * Prior to ESM loading. These are called once before any modules are started. * @private @@ -83,75 +146,68 @@ class Hooks { ], }; - // Enable an optimization in ESMLoader.getModuleJob - hasCustomResolveOrLoadHooks = false; - // Cache URLs we've already validated to avoid repeated validation #validatedUrls = new SafeSet(); - #importMetaInitializer = require('internal/modules/esm/initialize_import_meta').initializeImportMeta; + allowImportMetaResolve = false; - constructor(userLoaders) { - this.#addCustomLoaders(userLoaders); + /** + * Import and register custom/user-defined module loader hook(s). + * @param {string} urlOrSpecifier + * @param {string} parentURL + * @param {any} [data] Arbitrary data to be passed from the custom + * loader (user-land) to the worker. + */ + async register(urlOrSpecifier, parentURL, data) { + const moduleLoader = require('internal/process/esm_loader').esmLoader; + const keyedExports = await moduleLoader.import( + urlOrSpecifier, + parentURL, + kEmptyObject, + ); + await this.addCustomLoader(urlOrSpecifier, keyedExports, data); } /** * Collect custom/user-defined module loader hook(s). * After all hooks have been collected, the global preload hook(s) must be initialized. - * @param {import('./loader.js).KeyedExports} customLoaders Exports from user-defined loaders - * (as returned by `ESMLoader.import()`). + * @param {string} url Custom loader specifier + * @param {Record} exports + * @param {any} [data] Arbitrary data to be passed from the custom loader (user-land) + * to the worker. + * @returns {any | Promise} User data, ignored unless it's a promise, in which case it will be awaited. */ - #addCustomLoaders( - customLoaders = [], - ) { - for (let i = 0; i < customLoaders.length; i++) { - const { - exports, - url, - } = customLoaders[i]; - const { - globalPreload, - resolve, - load, - } = pluckHooks(exports); - - if (globalPreload) { - ArrayPrototypePush( - this.#hooks.globalPreload, - { - fn: globalPreload, - url, - }, - ); - } - if (resolve) { - this.hasCustomResolveOrLoadHooks = true; - ArrayPrototypePush( - this.#hooks.resolve, - { - fn: resolve, - url, - }, - ); - } - if (load) { - this.hasCustomResolveOrLoadHooks = true; - ArrayPrototypePush( - this.#hooks.load, - { - fn: load, - url, - }, - ); - } + addCustomLoader(url, exports, data) { + const { + globalPreload, + initialize, + resolve, + load, + } = pluckHooks(exports); + + if (globalPreload && !initialize) { + emitExperimentalWarning( + '`globalPreload` is planned for removal in favor of `initialize`. `globalPreload`', + ); + ArrayPrototypePush(this.#chains.globalPreload, { __proto__: null, fn: globalPreload, url }); } + if (resolve) { + const next = this.#chains.resolve[this.#chains.resolve.length - 1]; + ArrayPrototypePush(this.#chains.resolve, { __proto__: null, fn: resolve, url, next }); + } + if (load) { + const next = this.#chains.load[this.#chains.load.length - 1]; + ArrayPrototypePush(this.#chains.load, { __proto__: null, fn: load, url, next }); + } + return initialize?.(data); } /** * Initialize `globalPreload` hooks. */ - preload() { - for (let i = this.#hooks.globalPreload.length - 1; i >= 0; i--) { + initializeGlobalPreload() { + const preloadScripts = []; + for (let i = this.#chains.globalPreload.length - 1; i >= 0; i--) { const { MessageChannel } = require('internal/worker/io'); const channel = new MessageChannel(); const { @@ -165,77 +221,28 @@ class Hooks { const { fn: preload, url: specifier, - } = this.#hooks.globalPreload[i]; + } = this.#chains.globalPreload[i]; const preloaded = preload({ port: insideLoader, }); - if (preloaded == null) { return; } - - const hookErrIdentifier = `${specifier} globalPreload`; + if (preloaded == null) { continue; } if (typeof preloaded !== 'string') { // [2] throw new ERR_INVALID_RETURN_VALUE( 'a string', - hookErrIdentifier, + `${specifier} globalPreload`, preload, ); } - const { compileFunction } = require('vm'); - const preloadInit = compileFunction( - preloaded, - ['getBuiltin', 'port', 'setImportMetaCallback'], - { - filename: '', - }, - ); - const { BuiltinModule } = require('internal/bootstrap/realm'); - // We only allow replacing the importMetaInitializer during preload; - // after preload is finished, we disable the ability to replace it. - // - // This exposes accidentally setting the initializer too late by throwing an error. - let finished = false; - let replacedImportMetaInitializer = false; - let next = this.#importMetaInitializer; - try { - // Calls the compiled preload source text gotten from the hook - // Since the parameters are named we use positional parameters - // see compileFunction above to cross reference the names - FunctionPrototypeCall( - preloadInit, - globalThis, - // Param getBuiltin - (builtinName) => { - if (BuiltinModule.canBeRequiredWithoutScheme(builtinName)) { - return require(builtinName); - } - throw new ERR_INVALID_ARG_VALUE('builtinName', builtinName); - }, - // Param port - insidePreload, - // Param setImportMetaCallback - (fn) => { - if (finished || typeof fn !== 'function') { - throw new ERR_INVALID_ARG_TYPE('fn', fn); - } - replacedImportMetaInitializer = true; - const parent = next; - next = (meta, context) => { - return fn(meta, context, parent); - }; - }); - } finally { - finished = true; - if (replacedImportMetaInitializer) { - this.#importMetaInitializer = next; - } - } - } - } - importMetaInitialize(meta, context) { - this.#importMetaInitializer(meta, context); + ArrayPrototypePush(preloadScripts, { + code: preloaded, + port: insidePreload, + }); + } + return preloadScripts; } /** @@ -247,39 +254,27 @@ class Hooks { * @param {string} originalSpecifier The specified URL path of the module to * be resolved. * @param {string} [parentURL] The URL path of the module's parent. - * @param {ImportAssertions} [importAssertions] Assertions from the import + * @param {ImportAttributes} [importAttributes] Attributes from the import * statement or expression. * @returns {Promise<{ format: string, url: URL['href'] }>} */ async resolve( originalSpecifier, parentURL, - importAssertions = { __proto__: null }, + importAttributes = { __proto__: null }, ) { - const isMain = parentURL === undefined; + throwIfInvalidParentURL(parentURL); - if ( - !isMain && - typeof parentURL !== 'string' && - !isURL(parentURL) - ) { - throw new ERR_INVALID_ARG_TYPE( - 'parentURL', - ['string', 'URL'], - parentURL, - ); - } - const chain = this.#hooks.resolve; + const chain = this.#chains.resolve; const context = { conditions: getDefaultConditions(), - importAssertions, + importAttributes, parentURL, }; const meta = { chainFinished: null, context, hookErrIdentifier: '', - hookIndex: chain.length - 1, hookName: 'resolve', shortCircuited: false, }; @@ -290,7 +285,7 @@ class Hooks { `${hookErrIdentifier} specifier`, ); // non-strings can be coerced to a URL string - if (ctx) validateObject(ctx, `${hookErrIdentifier} context`); + if (ctx) { validateObject(ctx, `${hookErrIdentifier} context`); } }; const validateOutput = (hookErrIdentifier, output) => { if (typeof output !== 'object' || output === null) { // [2] @@ -302,7 +297,7 @@ class Hooks { } }; - const nextResolve = nextHookFactory(chain, meta, { validateArgs, validateOutput }); + const nextResolve = nextHookFactory(chain[chain.length - 1], meta, { validateArgs, validateOutput }); const resolution = await nextResolve(originalSpecifier, context); const { hookErrIdentifier } = meta; // Retrieve the value after all settled @@ -315,9 +310,9 @@ class Hooks { throw new ERR_LOADER_CHAIN_INCOMPLETE(hookErrIdentifier); } + let resolvedImportAttributes; const { format, - importAssertions: resolvedImportAssertions, url, } = resolution; @@ -334,10 +329,8 @@ class Hooks { // Avoid expensive URL instantiation for known-good URLs if (!this.#validatedUrls.has(url)) { - try { - new URL(url); - this.#validatedUrls.add(url); - } catch { + // No need to convert to string, since the type is already validated + if (!URLCanParse(url)) { throw new ERR_INVALID_RETURN_PROPERTY_VALUE( 'a URL string', hookErrIdentifier, @@ -345,17 +338,26 @@ class Hooks { url, ); } + + this.#validatedUrls.add(url); + } + + if (!('importAttributes' in resolution) && ('importAssertions' in resolution)) { + emitImportAssertionWarning(); + resolvedImportAttributes = resolution.importAssertions; + } else { + resolvedImportAttributes = resolution.importAttributes; } if ( - resolvedImportAssertions != null && - typeof resolvedImportAssertions !== 'object' + resolvedImportAttributes != null && + typeof resolvedImportAttributes !== 'object' ) { throw new ERR_INVALID_RETURN_PROPERTY_VALUE( 'an object', hookErrIdentifier, - 'importAssertions', - resolvedImportAssertions, + 'importAttributes', + resolvedImportAttributes, ); } @@ -374,11 +376,15 @@ class Hooks { return { __proto__: null, format, - importAssertions: resolvedImportAssertions, + importAttributes: resolvedImportAttributes, url, }; } + resolveSync(_originalSpecifier, _parentURL, _importAttributes) { + throw new ERR_METHOD_NOT_IMPLEMENTED('resolveSync()'); + } + /** * Provide source that is understood by one of Node's translators. * @@ -390,12 +396,11 @@ class Hooks { * @returns {Promise<{ format: ModuleFormat, source: ModuleSource }>} */ async load(url, context = {}) { - const chain = this.#hooks.load; + const chain = this.#chains.load; const meta = { chainFinished: null, context, hookErrIdentifier: '', - hookIndex: chain.length - 1, hookName: 'load', shortCircuited: false, }; @@ -413,16 +418,16 @@ class Hooks { // Avoid expensive URL instantiation for known-good URLs if (!this.#validatedUrls.has(nextUrl)) { - try { - new URL(nextUrl); - this.#validatedUrls.add(nextUrl); - } catch { + // No need to convert to string, since the type is already validated + if (!URLCanParse(nextUrl)) { throw new ERR_INVALID_ARG_VALUE( `${hookErrIdentifier} url`, nextUrl, 'should be a URL string', ); } + + this.#validatedUrls.add(nextUrl); } if (ctx) { validateObject(ctx, `${hookErrIdentifier} context`); } @@ -437,9 +442,9 @@ class Hooks { } }; - const nextLoad = nextHookFactory(chain, meta, { validateArgs, validateOutput }); + const nextLoad = nextHookFactory(chain[chain.length - 1], meta, { validateArgs, validateOutput }); - const loaded = await nextLoad(url, context); + const loaded = await nextLoad(url, defineImportAssertionAlias(context)); const { hookErrIdentifier } = meta; // Retrieve the value after all settled validateOutput(hookErrIdentifier, loaded); @@ -512,58 +517,269 @@ class Hooks { source, }; } -} -ObjectSetPrototypeOf(Hooks.prototype, null); + forceLoadHooks() { + // No-op + } + importMetaInitialize(meta, context, loader) { + importMetaInitializer ??= require('internal/modules/esm/initialize_import_meta').initializeImportMeta; + meta = importMetaInitializer(meta, context, loader); + return meta; + } +} +ObjectSetPrototypeOf(Hooks.prototype, null); /** - * A utility function to pluck the hooks from a user-defined loader. - * @param {import('./loader.js).ModuleExports} exports - * @returns {import('./loader.js).ExportedHooks} + * There may be multiple instances of Hooks/HooksProxy, but there is only 1 Internal worker, so + * there is only 1 MessageChannel. */ -function pluckHooks({ - globalPreload, - resolve, - load, - // obsolete hooks: - dynamicInstantiate, - getFormat, - getGlobalPreloadCode, - getSource, - transformSource, -}) { - const obsoleteHooks = []; - const acceptedHooks = { __proto__: null }; +let MessageChannel; +class HooksProxy { + /** + * Shared memory. Always use Atomics method to read or write to it. + * @type {Int32Array} + */ + #lock; + /** + * The InternalWorker instance, which lets us communicate with the loader thread. + */ + #worker; - if (getGlobalPreloadCode) { - globalPreload ??= getGlobalPreloadCode; + /** + * The last notification ID received from the worker. This is used to detect + * if the worker has already sent a notification before putting the main + * thread to sleep, to avoid a race condition. + * @type {number} + */ + #workerNotificationLastId = 0; - process.emitWarning( - 'Loader hook "getGlobalPreloadCode" has been renamed to "globalPreload"', - ); + /** + * Track how many async responses the main thread should expect. + * @type {number} + */ + #numberOfPendingAsyncResponses = 0; + + #isReady = false; + + constructor() { + const { InternalWorker } = require('internal/worker'); + MessageChannel ??= require('internal/worker/io').MessageChannel; + + const lock = new SharedArrayBuffer(SHARED_MEMORY_BYTE_LENGTH); + this.#lock = new Int32Array(lock); + + this.#worker = new InternalWorker(loaderWorkerId, { + stderr: false, + stdin: false, + stdout: false, + trackUnmanagedFds: false, + workerData: { + lock, + }, + }); + this.#worker.unref(); // ! Allows the process to eventually exit. + this.#worker.on('exit', process.exit); } - if (dynamicInstantiate) { - ArrayPrototypePush(obsoleteHooks, 'dynamicInstantiate'); + + waitForWorker() { + if (!this.#isReady) { + const { kIsOnline } = require('internal/worker'); + if (!this.#worker[kIsOnline]) { + debug('wait for signal from worker'); + AtomicsWait(this.#lock, WORKER_TO_MAIN_THREAD_NOTIFICATION, 0); + const response = this.#worker.receiveMessageSync(); + if (response == null || response.message.status === 'exit') { return; } + const { preloadScripts } = this.#unwrapMessage(response); + this.#executePreloadScripts(preloadScripts); + } + + this.#isReady = true; + } + } + + /** + * Invoke a remote method asynchronously. + * @param {string} method Method to invoke + * @param {any[]} [transferList] Objects in `args` to be transferred + * @param {any[]} args Arguments to pass to `method` + * @returns {Promise} + */ + async makeAsyncRequest(method, transferList, ...args) { + this.waitForWorker(); + + MessageChannel ??= require('internal/worker/io').MessageChannel; + const asyncCommChannel = new MessageChannel(); + + // Pass work to the worker. + debug('post async message to worker', { method, args, transferList }); + const finalTransferList = [asyncCommChannel.port2]; + if (transferList) { + ArrayPrototypePushApply(finalTransferList, transferList); + } + this.#worker.postMessage({ + __proto__: null, + method, args, + port: asyncCommChannel.port2, + }, finalTransferList); + + if (this.#numberOfPendingAsyncResponses++ === 0) { + // On the next lines, the main thread will await a response from the worker thread that might + // come AFTER the last task in the event loop has run its course and there would be nothing + // left keeping the thread alive (and once the main thread dies, the whole process stops). + // However we want to keep the process alive until the worker thread responds (or until the + // event loop of the worker thread is also empty), so we ref the worker until we get all the + // responses back. + this.#worker.ref(); + } + + let response; + do { + debug('wait for async response from worker', { method, args }); + await AtomicsWaitAsync(this.#lock, WORKER_TO_MAIN_THREAD_NOTIFICATION, this.#workerNotificationLastId).value; + this.#workerNotificationLastId = AtomicsLoad(this.#lock, WORKER_TO_MAIN_THREAD_NOTIFICATION); + + response = receiveMessageOnPort(asyncCommChannel.port1); + } while (response == null); + debug('got async response from worker', { method, args }, this.#lock); + + if (--this.#numberOfPendingAsyncResponses === 0) { + // We got all the responses from the worker, its job is done (until next time). + this.#worker.unref(); + } + + const body = this.#unwrapMessage(response); + asyncCommChannel.port1.close(); + return body; } - if (getFormat) { - ArrayPrototypePush(obsoleteHooks, 'getFormat'); + + /** + * Invoke a remote method synchronously. + * @param {string} method Method to invoke + * @param {any[]} [transferList] Objects in `args` to be transferred + * @param {any[]} args Arguments to pass to `method` + * @returns {any} + */ + makeSyncRequest(method, transferList, ...args) { + this.waitForWorker(); + + // Pass work to the worker. + debug('post sync message to worker', { method, args, transferList }); + this.#worker.postMessage({ __proto__: null, method, args }, transferList); + + let response; + do { + debug('wait for sync response from worker', { method, args }); + // Sleep until worker responds. + AtomicsWait(this.#lock, WORKER_TO_MAIN_THREAD_NOTIFICATION, this.#workerNotificationLastId); + this.#workerNotificationLastId = AtomicsLoad(this.#lock, WORKER_TO_MAIN_THREAD_NOTIFICATION); + + response = this.#worker.receiveMessageSync(); + } while (response == null); + debug('got sync response from worker', { method, args }); + if (response.message.status === 'never-settle') { + process.exit(13); + } else if (response.message.status === 'exit') { + process.exit(response.message.body); + } + return this.#unwrapMessage(response); } - if (getSource) { - ArrayPrototypePush(obsoleteHooks, 'getSource'); + + #unwrapMessage(response) { + if (response.message.status === 'never-settle') { + return new Promise(() => {}); + } + const { status, body } = response.message; + if (status === 'error') { + if (body == null || typeof body !== 'object') { throw body; } + if (body.serializationFailed || body.serialized == null) { + throw ERR_WORKER_UNSERIALIZABLE_ERROR(); + } + + // eslint-disable-next-line no-restricted-syntax + throw deserializeError(body.serialized); + } else { + return body; + } } - if (transformSource) { - ArrayPrototypePush(obsoleteHooks, 'transformSource'); + + #importMetaInitializer = require('internal/modules/esm/initialize_import_meta').initializeImportMeta; + + importMetaInitialize(meta, context, loader) { + this.#importMetaInitializer(meta, context, loader); } - if (obsoleteHooks.length) { - process.emitWarning( - `Obsolete loader hook(s) supplied and will be ignored: ${ - ArrayPrototypeJoin(obsoleteHooks, ', ') - }`, - 'DeprecationWarning', - ); + #executePreloadScripts(preloadScripts) { + for (let i = 0; i < preloadScripts.length; i++) { + const { code, port } = preloadScripts[i]; + const { compileFunction } = require('vm'); + const preloadInit = compileFunction( + code, + ['getBuiltin', 'port', 'setImportMetaCallback'], + { + filename: '', + }, + ); + let finished = false; + let replacedImportMetaInitializer = false; + let next = this.#importMetaInitializer; + const { BuiltinModule } = require('internal/bootstrap/realm'); + // Calls the compiled preload source text gotten from the hook + // Since the parameters are named we use positional parameters + // see compileFunction above to cross reference the names + try { + FunctionPrototypeCall( + preloadInit, + globalThis, + // Param getBuiltin + (builtinName) => { + if (StringPrototypeStartsWith(builtinName, 'node:')) { + builtinName = StringPrototypeSlice(builtinName, 5); + } else if (!BuiltinModule.canBeRequiredWithoutScheme(builtinName)) { + throw new ERR_UNKNOWN_BUILTIN_MODULE(builtinName); + } + if (BuiltinModule.canBeRequiredByUsers(builtinName)) { + return require(builtinName); + } + throw new ERR_UNKNOWN_BUILTIN_MODULE(builtinName); + }, + // Param port + port, + // setImportMetaCallback + (fn) => { + if (finished || typeof fn !== 'function') { + throw new ERR_INVALID_ARG_TYPE('fn', fn); + } + replacedImportMetaInitializer = true; + const parent = next; + next = (meta, context) => { + return fn(meta, context, parent); + }; + }, + ); + } finally { + finished = true; + if (replacedImportMetaInitializer) { + this.#importMetaInitializer = next; + } + } + } } +} +ObjectSetPrototypeOf(HooksProxy.prototype, null); + +/** + * A utility function to pluck the hooks from a user-defined loader. + * @param {import('./loader.js).ModuleExports} exports + * @returns {ExportedHooks} + */ +function pluckHooks({ + globalPreload, + initialize, + resolve, + load, +}) { + const acceptedHooks = { __proto__: null }; if (globalPreload) { acceptedHooks.globalPreload = globalPreload; @@ -575,6 +791,10 @@ function pluckHooks({ acceptedHooks.load = load; } + if (initialize) { + acceptedHooks.initialize = initialize; + } + return acceptedHooks; } @@ -583,15 +803,14 @@ function pluckHooks({ * A utility function to iterate through a hook chain, track advancement in the * chain, and generate and supply the `next` argument to the custom * hook. - * @param {KeyedHook[]} chain The whole hook chain. + * @param {Hook} current The (currently) first hook in the chain (this shifts + * on every call). * @param {object} meta Properties that change as the current hook advances * along the chain. * @param {boolean} meta.chainFinished Whether the end of the chain has been * reached AND invoked. * @param {string} meta.hookErrIdentifier A user-facing identifier to help * pinpoint where an error occurred. Ex "file:///foo.mjs 'resolve'". - * @param {number} meta.hookIndex A non-negative integer tracking the current - * position in the hook chain. * @param {string} meta.hookName The kind of hook the chain is (ex 'resolve') * @param {boolean} meta.shortCircuited Whether a hook signaled a short-circuit. * @param {(hookErrIdentifier, hookArgs) => void} validate A wrapper function @@ -599,13 +818,14 @@ function pluckHooks({ * validation within MUST throw. * @returns {function next(...hookArgs)} The next hook in the chain. */ -function nextHookFactory(chain, meta, { validateArgs, validateOutput }) { +function nextHookFactory(current, meta, { validateArgs, validateOutput }) { // First, prepare the current const { hookName } = meta; const { fn: hook, url: hookFilePath, - } = chain[meta.hookIndex]; + next, + } = current; // ex 'nextResolve' const nextHookName = `next${ @@ -613,16 +833,9 @@ function nextHookFactory(chain, meta, { validateArgs, validateOutput }) { StringPrototypeSlice(hookName, 1) }`; - // When hookIndex is 0, it's reached the default, which does not call next() - // so feed it a noop that blows up if called, so the problem is obvious. - const generatedHookIndex = meta.hookIndex; let nextNextHook; - if (meta.hookIndex > 0) { - // Now, prepare the next: decrement the pointer so the next call to the - // factory generates the next link in the chain. - meta.hookIndex--; - - nextNextHook = nextHookFactory(chain, meta, { validateArgs, validateOutput }); + if (next) { + nextNextHook = nextHookFactory(next, meta, { validateArgs, validateOutput }); } else { // eslint-disable-next-line func-name-matching nextNextHook = function chainAdvancedTooFar() { @@ -639,17 +852,16 @@ function nextHookFactory(chain, meta, { validateArgs, validateOutput }) { validateArgs(`${meta.hookErrIdentifier} hook's ${nextHookName}()`, arg0, context); - const outputErrIdentifier = `${chain[generatedHookIndex].url} '${hookName}' hook's ${nextHookName}()`; + const outputErrIdentifier = `${hookFilePath} '${hookName}' hook's ${nextHookName}()`; // Set when next is actually called, not just generated. - if (generatedHookIndex === 0) { meta.chainFinished = true; } + if (!next) { meta.chainFinished = true; } if (context) { // `context` has already been validated, so no fancy check needed. ObjectAssign(meta.context, context); } const output = await hook(arg0, meta.context, nextNextHook); - validateOutput(outputErrIdentifier, output); if (output?.shortCircuit === true) { meta.shortCircuited = true; } @@ -663,3 +875,4 @@ function nextHookFactory(chain, meta, { validateArgs, validateOutput }) { exports.Hooks = Hooks; +exports.HooksProxy = HooksProxy; diff --git a/lib/internal/modules/esm/initialize_import_meta.js b/lib/internal/modules/esm/initialize_import_meta.js index fe5ba4a3cc1248..f55f60a5b7647a 100644 --- a/lib/internal/modules/esm/initialize_import_meta.js +++ b/lib/internal/modules/esm/initialize_import_meta.js @@ -1,39 +1,63 @@ 'use strict'; const { getOptionValue } = require('internal/options'); -const experimentalImportMetaResolve = - getOptionValue('--experimental-import-meta-resolve'); -const { - PromisePrototypeThen, - PromiseReject, -} = primordials; -const asyncESM = require('internal/process/esm_loader'); - -function createImportMetaResolve(defaultParentUrl) { - return async function resolve(specifier, parentUrl = defaultParentUrl) { - return PromisePrototypeThen( - asyncESM.esmLoader.resolve(specifier, parentUrl), - ({ url }) => url, - (error) => ( - error.code === 'ERR_UNSUPPORTED_DIR_IMPORT' ? - error.url : PromiseReject(error)), - ); +const experimentalImportMetaResolve = getOptionValue('--experimental-import-meta-resolve'); + +/** + * Generate a function to be used as import.meta.resolve for a particular module. + * @param {string} defaultParentURL The default base to use for resolution + * @param {typeof import('./loader.js').ModuleLoader} loader Reference to the current module loader + * @param {bool} allowParentURL Whether to permit parentURL second argument for contextual resolution + * @returns {(specifier: string) => string} Function to assign to import.meta.resolve + */ +function createImportMetaResolve(defaultParentURL, loader, allowParentURL) { + /** + * @param {string} specifier + * @param {URL['href']} [parentURL] When `--experimental-import-meta-resolve` is specified, a + * second argument can be provided. + */ + return function resolve(specifier, parentURL = defaultParentURL) { + let url; + + if (!allowParentURL) { + parentURL = defaultParentURL; + } + + try { + ({ url } = loader.resolveSync(specifier, parentURL)); + return url; + } catch (error) { + switch (error?.code) { + case 'ERR_UNSUPPORTED_DIR_IMPORT': + case 'ERR_MODULE_NOT_FOUND': + ({ url } = error); + if (url) { + return url; + } + } + throw error; + } }; } /** + * Create the `import.meta` object for a module. * @param {object} meta * @param {{url: string}} context + * @param {typeof import('./loader.js').ModuleLoader} loader Reference to the current module loader + * @returns {{url: string, resolve?: Function}} */ -function initializeImportMeta(meta, context) { +function initializeImportMeta(meta, context, loader) { const { url } = context; // Alphabetical - if (experimentalImportMetaResolve) { - meta.resolve = createImportMetaResolve(url); + if (!loader || loader.allowImportMetaResolve) { + meta.resolve = createImportMetaResolve(url, loader, experimentalImportMetaResolve); } meta.url = url; + + return meta; } module.exports = { diff --git a/lib/internal/modules/esm/load.js b/lib/internal/modules/esm/load.js index 29135cd08103f2..4b5ff362ed0c4a 100644 --- a/lib/internal/modules/esm/load.js +++ b/lib/internal/modules/esm/load.js @@ -8,7 +8,7 @@ const { const { kEmptyObject } = require('internal/util'); const { defaultGetFormat } = require('internal/modules/esm/get_format'); -const { validateAssertions } = require('internal/modules/esm/assert'); +const { validateAttributes, emitImportAssertionWarning } = require('internal/modules/esm/assert'); const { getOptionValue } = require('internal/options'); // Do not eagerly grab .manifest, it may be in TDZ @@ -78,19 +78,29 @@ async function getSource(url, context) { */ async function defaultLoad(url, context = kEmptyObject) { let responseURL = url; - const { importAssertions } = context; let { + importAttributes, format, source, } = context; + if (importAttributes == null && !('importAttributes' in context) && 'importAssertions' in context) { + emitImportAssertionWarning(); + importAttributes = context.importAssertions; + // Alias `importAssertions` to `importAttributes` + context = { + ...context, + importAttributes, + }; + } + const urlInstance = new URL(url); throwIfUnsupportedURLScheme(urlInstance, experimentalNetworkImports); format ??= await defaultGetFormat(urlInstance, context); - validateAssertions(url, format, importAssertions); + validateAttributes(url, format, importAttributes); if ( format === 'builtin' || diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 125cebd19866d1..6e42f1a5db5a8a 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -4,53 +4,68 @@ require('internal/modules/cjs/loader'); const { - Array, - ArrayIsArray, + ArrayPrototypeJoin, + ArrayPrototypeMap, + ArrayPrototypeReduce, FunctionPrototypeCall, - ObjectCreate, + JSONStringify, ObjectSetPrototypeOf, - SafePromiseAllReturnArrayLike, + RegExpPrototypeSymbolReplace, SafeWeakMap, + encodeURIComponent, + hardenRegExp, } = primordials; const { ERR_UNKNOWN_MODULE_FORMAT, } = require('internal/errors').codes; const { getOptionValue } = require('internal/options'); -const { pathToFileURL } = require('internal/url'); +const { pathToFileURL, isURL } = require('internal/url'); const { emitExperimentalWarning } = require('internal/util'); - const { getDefaultConditions, } = require('internal/modules/esm/utils'); +let defaultResolve, defaultLoad, importMetaInitializer; + +/** + * Lazy loads the module_map module and returns a new instance of ResolveCache. + * @returns {import('./module_map.js').ResolveCache')} + */ +function newResolveCache() { + const { ResolveCache } = require('internal/modules/esm/module_map'); + return new ResolveCache(); +} -function newModuleMap() { - const ModuleMap = require('internal/modules/esm/module_map'); - return new ModuleMap(); +/** + * Generate a load cache (to store the final result of a load-chain for a particular module). + * @returns {import('./module_map.js').LoadCache')} + */ +function newLoadCache() { + const { LoadCache } = require('internal/modules/esm/module_map'); + return new LoadCache(); } +/** + * Lazy-load translators to avoid potentially unnecessary work at startup (ex if ESM is not used). + * @returns {import('./translators.js').Translators} + */ function getTranslators() { const { translators } = require('internal/modules/esm/translators'); return translators; } /** - * @typedef {object} ExportedHooks - * @property {Function} globalPreload Global preload hook. - * @property {Function} resolve Resolve hook. - * @property {Function} load Load hook. + * @type {HooksProxy} + * Multiple loader instances exist for various, specific reasons (see code comments at site). + * In order to maintain consistency, we use a single worker (sandbox), which must sit apart of an + * individual loader instance. */ +let hooksProxy; /** * @typedef {Record} ModuleExports */ -/** - * @typedef {object} KeyedExports - * @property {ModuleExports} exports The contents of the module. - * @property {URL['href']} url The URL of the module. - */ - /** * @typedef {'builtin'|'commonjs'|'json'|'module'|'wasm'} ModuleFormat */ @@ -62,17 +77,11 @@ function getTranslators() { let emittedSpecifierResolutionWarning = false; /** - * An ESMLoader instance is used as the main entry point for loading ES modules. - * Currently, this is a singleton -- there is only one used for loading - * the main module and everything in its dependency graph. + * This class covers the base machinery of module loading. To add custom + * behavior you can pass a customizations object and this object will be + * used to do the loading/resolving/registration process. */ - -class ESMLoader { - #hooks; - #defaultResolve; - #defaultLoad; - #importMetaInitializer; - +class ModuleLoader { /** * The conditions for resolving packages if `--conditions` is not used. */ @@ -88,20 +97,40 @@ class ESMLoader { */ evalIndex = 0; + /** + * Registry of resolved specifiers + */ + #resolveCache = newResolveCache(); + /** * Registry of loaded modules, akin to `require.cache` */ - moduleMap = newModuleMap(); + loadCache = newLoadCache(); /** * Methods which translate input code or other information into ES modules */ translators = getTranslators(); - constructor() { - if (getOptionValue('--experimental-loader').length > 0) { - emitExperimentalWarning('Custom ESM Loaders'); - } + /** + * Truthy to allow the use of `import.meta.resolve`. This is needed + * currently because the `Hooks` class does not have `resolveSync` + * implemented and `import.meta.resolve` requires it. + */ + allowImportMetaResolve; + + /** + * Customizations to pass requests to. + * + * Note that this value _MUST_ be set with `setCustomizations` + * because it needs to copy `customizations.allowImportMetaResolve` + * to this property and failure to do so will cause undefined + * behavior when invoking `import.meta.resolve`. + * @see {ModuleLoader.setCustomizations} + */ + #customizations; + + constructor(customizations) { if (getOptionValue('--experimental-network-imports')) { emitExperimentalWarning('Network Imports'); } @@ -115,15 +144,64 @@ class ESMLoader { ); emittedSpecifierResolutionWarning = true; } + this.setCustomizations(customizations); } - addCustomLoaders(userLoaders) { - const { Hooks } = require('internal/modules/esm/hooks'); - this.#hooks = new Hooks(userLoaders); - } - - preload() { - this.#hooks?.preload(); + /** + * Change the currently activate customizations for this module + * loader to be the provided `customizations`. + * + * If present, this class customizes its core functionality to the + * `customizations` object, including registration, loading, and resolving. + * There are some responsibilities that this class _always_ takes + * care of, like validating outputs, so that the customizations object + * does not have to do so. + * + * The customizations object has the shape: + * + * ```ts + * interface LoadResult { + * format: ModuleFormat; + * source: ModuleSource; + * } + * + * interface ResolveResult { + * format: string; + * url: URL['href']; + * } + * + * interface Customizations { + * allowImportMetaResolve: boolean; + * load(url: string, context: object): Promise + * resolve( + * originalSpecifier: + * string, parentURL: string, + * importAttributes: Record + * ): Promise + * resolveSync( + * originalSpecifier: + * string, parentURL: string, + * importAttributes: Record + * ) ResolveResult; + * register(specifier: string, parentURL: string): any; + * forceLoadHooks(): void; + * } + * ``` + * + * Note that this class _also_ implements the `Customizations` + * interface, as does `CustomizedModuleLoader` and `Hooks`. + * + * Calling this function alters how modules are loaded and should be + * invoked with care. + * @param {object} customizations + */ + setCustomizations(customizations) { + this.#customizations = customizations; + if (customizations) { + this.allowImportMetaResolve = customizations.allowImportMetaResolve; + } else { + this.allowImportMetaResolve = true; + } } async eval( @@ -135,8 +213,9 @@ class ESMLoader { const { setCallbackForWrap } = require('internal/modules/esm/utils'); const module = new ModuleWrap(url, undefined, source, 0, 0); setCallbackForWrap(module, { - importModuleDynamically: (specifier, { url }, importAssertions) => { - return this.import(specifier, url, importAssertions); + initializeImportMeta: (meta, wrap) => this.importMetaInitialize(meta, { url }), + importModuleDynamically: (specifier, { url }, importAttributes) => { + return this.import(specifier, url, importAttributes); }, }); @@ -145,7 +224,7 @@ class ESMLoader { const ModuleJob = require('internal/modules/esm/module_job'); const job = new ModuleJob( this, url, undefined, evalInstance, false, false); - this.moduleMap.set(url, undefined, job); + this.loadCache.set(url, undefined, job); const { module } = await job.run(); return { @@ -162,37 +241,27 @@ class ESMLoader { * @param {string | undefined} parentURL The URL of the module importing this * one, unless this is the Node.js entry * point. - * @param {Record} importAssertions Validations for the + * @param {Record} importAttributes Validations for the * module import. * @returns {Promise} The (possibly pending) module job */ - async getModuleJob(specifier, parentURL, importAssertions) { - let importAssertionsForResolve; - - // We can skip cloning if there are no user-provided loaders because - // the Node.js default resolve hook does not use import assertions. - if (this.#hooks?.hasCustomResolveOrLoadHooks) { - // This method of cloning only works so long as import assertions cannot contain objects as values, - // which they currently cannot per spec. - importAssertionsForResolve = { - __proto__: null, - ...importAssertions, - }; - } + async getModuleJob(specifier, parentURL, importAttributes) { + const resolveResult = await this.resolve(specifier, parentURL, importAttributes); + return this.getJobFromResolveResult(resolveResult, parentURL, importAttributes); + } - const resolveResult = await this.resolve(specifier, parentURL, importAssertionsForResolve); + getJobFromResolveResult(resolveResult, parentURL, importAttributes) { const { url, format } = resolveResult; - const resolvedImportAssertions = resolveResult.importAssertions ?? importAssertions; - - let job = this.moduleMap.get(url, resolvedImportAssertions.type); + const resolvedImportAttributes = resolveResult.importAttributes ?? importAttributes; + let job = this.loadCache.get(url, resolvedImportAttributes.type); // CommonJS will set functions for lazy job evaluation. if (typeof job === 'function') { - this.moduleMap.set(url, undefined, job = job()); + this.loadCache.set(url, undefined, job = job()); } if (job === undefined) { - job = this.#createModuleJob(url, resolvedImportAssertions, parentURL, format); + job = this.#createModuleJob(url, resolvedImportAttributes, parentURL, format); } return job; @@ -201,7 +270,7 @@ class ESMLoader { /** * Create and cache an object representing a loaded module. * @param {string} url The absolute URL that was resolved for this module - * @param {Record} importAssertions Validations for the + * @param {Record} importAttributes Validations for the * module import. * @param {string} [parentURL] The absolute URL of the module importing this * one, unless this is the Node.js entry point @@ -209,7 +278,7 @@ class ESMLoader { * `resolve` hook * @returns {Promise} The (possibly pending) module job */ - #createModuleJob(url, importAssertions, parentURL, format) { + #createModuleJob(url, importAttributes, parentURL, format) { const moduleProvider = async (url, isMain) => { const { format: finalFormat, @@ -217,7 +286,7 @@ class ESMLoader { source, } = await this.load(url, { format, - importAssertions, + importAttributes, }); const translator = getTranslators().get(finalFormat); @@ -242,65 +311,44 @@ class ESMLoader { const job = new ModuleJob( this, url, - importAssertions, + importAttributes, moduleProvider, parentURL === undefined, inspectBrk, ); - this.moduleMap.set(url, importAssertions.type, job); + this.loadCache.set(url, importAttributes.type, job); return job; } /** * This method is usually called indirectly as part of the loading processes. - * Internally, it is used directly to add loaders. Use directly with caution. - * - * This method must NOT be renamed: it functions as a dynamic import on a - * loader module. - * @param {string | string[]} specifiers Path(s) to the module. + * Use directly with caution. + * @param {string} specifier The first parameter of an `import()` expression. * @param {string} parentURL Path of the parent importing the module. - * @param {Record} importAssertions Validations for the + * @param {Record} importAttributes Validations for the * module import. - * @returns {Promise} - * A collection of module export(s) or a list of collections of module - * export(s). + * @returns {Promise} */ - async import(specifiers, parentURL, importAssertions) { - // For loaders, `import` is passed multiple things to process, it returns a - // list pairing the url and exports collected. This is especially useful for - // error messaging, to identity from where an export came. But, in most - // cases, only a single url is being "imported" (ex `import()`), so there is - // only 1 possible url from which the exports were collected and it is - // already known to the caller. Nesting that in a list would only ever - // create redundant work for the caller, so it is later popped off the - // internal list. - const wasArr = ArrayIsArray(specifiers); - if (!wasArr) { specifiers = [specifiers]; } - - const count = specifiers.length; - const jobs = new Array(count); - - for (let i = 0; i < count; i++) { - jobs[i] = this.getModuleJob(specifiers[i], parentURL, importAssertions) - .then((job) => job.run()) - .then(({ module }) => module.getNamespace()); - } - - const namespaces = await SafePromiseAllReturnArrayLike(jobs); - - if (!wasArr) { return namespaces[0]; } // We can skip the pairing below + async import(specifier, parentURL, importAttributes) { + const moduleJob = await this.getModuleJob(specifier, parentURL, importAttributes); + const { module } = await moduleJob.run(); + return module.getNamespace(); + } - for (let i = 0; i < count; i++) { - namespaces[i] = { - __proto__: null, - url: specifiers[i], - exports: namespaces[i], - }; + /** + * @see {@link CustomizedModuleLoader.register} + */ + register(specifier, parentURL, data, transferList) { + if (!this.#customizations) { + // `CustomizedModuleLoader` is defined at the bottom of this file and + // available well before this line is ever invoked. This is here in + // order to preserve the git diff instead of moving the class. + // eslint-disable-next-line no-use-before-define + this.setCustomizations(new CustomizedModuleLoader()); } - - return namespaces; + return this.#customizations.register(`${specifier}`, `${parentURL}`, data, transferList); } /** @@ -308,29 +356,51 @@ class ESMLoader { * @param {string} originalSpecifier The specified URL path of the module to * be resolved. * @param {string} [parentURL] The URL path of the module's parent. - * @param {ImportAssertions} importAssertions Assertions from the import - * statement or expression. - * @returns {Promise<{ format: string, url: URL['href'] }>} + * @param {ImportAttributes} importAttributes Attributes from the import + * statement or expression. + * @returns {{ format: string, url: URL['href'] }} */ - async resolve( - originalSpecifier, - parentURL, - importAssertions = ObjectCreate(null), - ) { - if (this.#hooks) { - return this.#hooks.resolve(originalSpecifier, parentURL, importAssertions); + resolve(originalSpecifier, parentURL, importAttributes) { + if (this.#customizations) { + return this.#customizations.resolve(originalSpecifier, parentURL, importAttributes); } - if (!this.#defaultResolve) { - this.#defaultResolve = require('internal/modules/esm/resolve').defaultResolve; + const requestKey = this.#resolveCache.serializeKey(originalSpecifier, importAttributes); + const cachedResult = this.#resolveCache.get(requestKey, parentURL); + if (cachedResult != null) { + return cachedResult; } + const result = this.defaultResolve(originalSpecifier, parentURL, importAttributes); + this.#resolveCache.set(requestKey, parentURL, result); + return result; + } + + /** + * Just like `resolve` except synchronous. This is here specifically to support + * `import.meta.resolve` which must happen synchronously. + */ + resolveSync(originalSpecifier, parentURL, importAttributes) { + if (this.#customizations) { + return this.#customizations.resolveSync(originalSpecifier, parentURL, importAttributes); + } + return this.defaultResolve(originalSpecifier, parentURL, importAttributes); + } + + /** + * Our `defaultResolve` is synchronous and can be used in both + * `resolve` and `resolveSync`. This function is here just to avoid + * repeating the same code block twice in those functions. + */ + defaultResolve(originalSpecifier, parentURL, importAttributes) { + defaultResolve ??= require('internal/modules/esm/resolve').defaultResolve; + const context = { __proto__: null, conditions: this.#defaultConditions, - importAssertions, + importAttributes, parentURL, }; - return this.#defaultResolve(originalSpecifier, context); + return defaultResolve(originalSpecifier, context); } /** @@ -340,36 +410,202 @@ class ESMLoader { * @returns {Promise<{ format: ModuleFormat, source: ModuleSource }>} */ async load(url, context) { - let loadResult; - if (this.#hooks) { - loadResult = await this.#hooks.load(url, context); - } else { - if (!this.#defaultLoad) { - this.#defaultLoad = require('internal/modules/esm/load').defaultLoad; - } - loadResult = await this.#defaultLoad(url, context); - } + defaultLoad ??= require('internal/modules/esm/load').defaultLoad; + const result = this.#customizations ? + await this.#customizations.load(url, context) : + await defaultLoad(url, context); + this.validateLoadResult(url, result?.format); + return result; + } - const { format } = loadResult; + validateLoadResult(url, format) { if (format == null) { require('internal/modules/esm/load').throwUnknownModuleFormat(url, format); } - - return loadResult; } importMetaInitialize(meta, context) { - if (this.#hooks) { - this.#hooks.importMetaInitialize(meta, context); - } else { - if (!this.#importMetaInitializer) { - this.#importMetaInitializer = require('internal/modules/esm/initialize_import_meta').initializeImportMeta; + if (this.#customizations) { + return this.#customizations.importMetaInitialize(meta, context, this); + } + importMetaInitializer ??= require('internal/modules/esm/initialize_import_meta').initializeImportMeta; + meta = importMetaInitializer(meta, context, this); + return meta; + } + + /** + * No-op when no hooks have been supplied. + */ + forceLoadHooks() { + this.#customizations?.forceLoadHooks(); + } +} +ObjectSetPrototypeOf(ModuleLoader.prototype, null); + +class CustomizedModuleLoader { + + allowImportMetaResolve = true; + + /** + * Instantiate a module loader that uses user-provided custom loader hooks. + */ + constructor() { + getHooksProxy(); + } + + /** + * Register some loader specifier. + * @param {string} originalSpecifier The specified URL path of the loader to + * be registered. + * @param {string} parentURL The parent URL from where the loader will be + * registered if using it package name as specifier + * @param {any} [data] Arbitrary data to be passed from the custom loader + * (user-land) to the worker. + * @param {any[]} [transferList] Objects in `data` that are changing ownership + * @returns {{ format: string, url: URL['href'] }} + */ + register(originalSpecifier, parentURL, data, transferList) { + return hooksProxy.makeSyncRequest('register', transferList, originalSpecifier, parentURL, data); + } + + /** + * Resolve the location of the module. + * @param {string} originalSpecifier The specified URL path of the module to + * be resolved. + * @param {string} [parentURL] The URL path of the module's parent. + * @param {ImportAttributes} importAttributes Attributes from the import + * statement or expression. + * @returns {{ format: string, url: URL['href'] }} + */ + resolve(originalSpecifier, parentURL, importAttributes) { + return hooksProxy.makeAsyncRequest('resolve', undefined, originalSpecifier, parentURL, importAttributes); + } + + resolveSync(originalSpecifier, parentURL, importAttributes) { + // This happens only as a result of `import.meta.resolve` calls, which must be sync per spec. + return hooksProxy.makeSyncRequest('resolve', undefined, originalSpecifier, parentURL, importAttributes); + } + + /** + * Provide source that is understood by one of Node's translators. + * @param {URL['href']} url The URL/path of the module to be loaded + * @param {object} [context] Metadata about the module + * @returns {Promise<{ format: ModuleFormat, source: ModuleSource }>} + */ + load(url, context) { + return hooksProxy.makeAsyncRequest('load', undefined, url, context); + } + + importMetaInitialize(meta, context, loader) { + hooksProxy.importMetaInitialize(meta, context, loader); + } + + forceLoadHooks() { + hooksProxy.waitForWorker(); + } +} + +let emittedLoaderFlagWarning = false; +/** + * A loader instance is used as the main entry point for loading ES modules. Currently, this is a singleton; there is + * only one used for loading the main module and everything in its dependency graph, though separate instances of this + * class might be instantiated as part of bootstrap for other purposes. + * @param {boolean} useCustomLoadersIfPresent If the user has provided loaders via the --loader flag, use them. + * @returns {ModuleLoader} + */ +function createModuleLoader(useCustomLoadersIfPresent = true) { + let customizations = null; + if (useCustomLoadersIfPresent && + // Don't spawn a new worker if we're already in a worker thread created by instantiating CustomizedModuleLoader; + // doing so would cause an infinite loop. + !require('internal/modules/esm/utils').isLoaderWorker()) { + const userLoaderPaths = getOptionValue('--experimental-loader'); + if (userLoaderPaths.length > 0) { + if (!emittedLoaderFlagWarning) { + const readableURIEncode = (string) => ArrayPrototypeReduce( + [ + [/'/g, '%27'], // We need to URL-encode the single quote as it's the delimiter for the --import flag. + [/%22/g, '"'], // We can decode the double quotes to improve readability. + [/%2F/ig, '/'], // We can decode the slashes to improve readability. + ], + (str, { 0: regex, 1: replacement }) => RegExpPrototypeSymbolReplace(hardenRegExp(regex), str, replacement), + encodeURIComponent(string)); + process.emitWarning( + '`--experimental-loader` may be removed in the future; instead use `register()`:\n' + + `--import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; ${ArrayPrototypeJoin( + ArrayPrototypeMap(userLoaderPaths, (loader) => `register(${readableURIEncode(JSONStringify(loader))}, pathToFileURL("./"))`), + '; ', + )};'`, + 'ExperimentalWarning', + ); + emittedLoaderFlagWarning = true; } - this.#importMetaInitializer(meta, context); + customizations = new CustomizedModuleLoader(); } } + + return new ModuleLoader(customizations); } -ObjectSetPrototypeOf(ESMLoader.prototype, null); -exports.ESMLoader = ESMLoader; +/** + * Get the HooksProxy instance. If it is not defined, then create a new one. + * @returns {HooksProxy} + */ +function getHooksProxy() { + if (!hooksProxy) { + const { HooksProxy } = require('internal/modules/esm/hooks'); + hooksProxy = new HooksProxy(); + } + + return hooksProxy; +} + +/** + * Register a single loader programmatically. + * @param {string|import('url').URL} specifier + * @param {string|import('url').URL} [parentURL] Base to use when resolving `specifier`; optional if + * `specifier` is absolute. Same as `options.parentUrl`, just inline + * @param {object} [options] Additional options to apply, described below. + * @param {string|import('url').URL} [options.parentURL] Base to use when resolving `specifier` + * @param {any} [options.data] Arbitrary data passed to the loader's `initialize` hook + * @param {any[]} [options.transferList] Objects in `data` that are changing ownership + * @returns {void} We want to reserve the return value for potential future extension of the API. + * @example + * ```js + * register('./myLoader.js'); + * register('ts-node/esm', { parentURL: import.meta.url }); + * register('./myLoader.js', { parentURL: import.meta.url }); + * register('ts-node/esm', import.meta.url); + * register('./myLoader.js', import.meta.url); + * register(new URL('./myLoader.js', import.meta.url)); + * register('./myLoader.js', { + * parentURL: import.meta.url, + * data: { banana: 'tasty' }, + * }); + * register('./myLoader.js', { + * parentURL: import.meta.url, + * data: someArrayBuffer, + * transferList: [someArrayBuffer], + * }); + * ``` + */ +function register(specifier, parentURL = undefined, options) { + const moduleLoader = require('internal/process/esm_loader').esmLoader; + if (parentURL != null && typeof parentURL === 'object' && !isURL(parentURL)) { + options = parentURL; + parentURL = options.parentURL; + } + moduleLoader.register( + specifier, + parentURL ?? 'data:', + options?.data, + options?.transferList, + ); +} + +module.exports = { + createModuleLoader, + getHooksProxy, + register, +}; diff --git a/lib/internal/modules/esm/module_job.js b/lib/internal/modules/esm/module_job.js index f1fe73eec6edb6..3bc327a39c7e67 100644 --- a/lib/internal/modules/esm/module_job.js +++ b/lib/internal/modules/esm/module_job.js @@ -5,7 +5,6 @@ const { ArrayPrototypePush, ArrayPrototypeSome, FunctionPrototype, - ObjectCreate, ObjectSetPrototypeOf, PromiseResolve, PromisePrototypeThen, @@ -22,7 +21,7 @@ const { const { ModuleWrap } = internalBinding('module_wrap'); -const { decorateErrorStack } = require('internal/util'); +const { decorateErrorStack, kEmptyObject } = require('internal/util'); const { getSourceMapsEnabled, } = require('internal/source_map/source_map_cache'); @@ -51,10 +50,10 @@ const isCommonJSGlobalLikeNotDefinedError = (errorMessage) => class ModuleJob { // `loader` is the Loader instance used for loading dependencies. // `moduleProvider` is a function - constructor(loader, url, importAssertions = ObjectCreate(null), + constructor(loader, url, importAttributes = { __proto__: null }, moduleProvider, isMain, inspectBrk) { this.loader = loader; - this.importAssertions = importAssertions; + this.importAttributes = importAttributes; this.isMain = isMain; this.inspectBrk = inspectBrk; @@ -73,15 +72,15 @@ class ModuleJob { // so that circular dependencies can't cause a deadlock by two of // these `link` callbacks depending on each other. const dependencyJobs = []; - const promises = this.module.link(async (specifier, assertions) => { - const jobPromise = this.loader.getModuleJob(specifier, url, assertions); - ArrayPrototypePush(dependencyJobs, jobPromise); - const job = await jobPromise; + const promises = this.module.link(async (specifier, attributes) => { + const job = await this.loader.getModuleJob(specifier, url, attributes); + ArrayPrototypePush(dependencyJobs, job); return job.modulePromise; }); - if (promises !== undefined) + if (promises !== undefined) { await SafePromiseAllReturnVoid(promises); + } return SafePromiseAllReturnArrayLike(dependencyJobs); }; @@ -142,12 +141,14 @@ class ModuleJob { /module '(.*)' does not provide an export named '(.+)'/, e.message); const { url: childFileURL } = await this.loader.resolve( - childSpecifier, parentFileUrl, + childSpecifier, + parentFileUrl, + kEmptyObject, ); let format; try { // This might throw for non-CommonJS modules because we aren't passing - // in the import assertions and some formats require them; but we only + // in the import attributes and some formats require them; but we only // care about CommonJS for the purposes of this error message. ({ format } = await this.loader.load(childFileURL)); diff --git a/lib/internal/modules/esm/module_map.js b/lib/internal/modules/esm/module_map.js index 7280f052fef59a..595e251048b900 100644 --- a/lib/internal/modules/esm/module_map.js +++ b/lib/internal/modules/esm/module_map.js @@ -1,18 +1,93 @@ 'use strict'; -const { kImplicitAssertType } = require('internal/modules/esm/assert'); const { + ArrayPrototypeJoin, + ArrayPrototypeMap, + ArrayPrototypeSort, + JSONStringify, ObjectCreate, + ObjectKeys, SafeMap, } = primordials; +const { kImplicitAssertType } = require('internal/modules/esm/assert'); let debug = require('internal/util/debuglog').debuglog('esm', (fn) => { debug = fn; }); const { ERR_INVALID_ARG_TYPE } = require('internal/errors').codes; const { validateString } = require('internal/validators'); -// Tracks the state of the loader-level module cache -class ModuleMap extends SafeMap { +/** + * Cache the results of the `resolve` step of the module resolution and loading process. + * Future resolutions of the same input (specifier, parent URL and import attributes) + * must return the same result if the first attempt was successful, per + * https://tc39.es/ecma262/#sec-HostLoadImportedModule. + * This cache is *not* used when custom loaders are registered. + */ +class ResolveCache extends SafeMap { + constructor(i) { super(i); } // eslint-disable-line no-useless-constructor + + /** + * Generates the internal serialized cache key and returns it along the actual cache object. + * + * It is exposed to allow more efficient read and overwrite a cache entry. + * @param {string} specifier + * @param {Record} importAttributes + * @returns {string} + */ + serializeKey(specifier, importAttributes) { + // To serialize the ModuleRequest (specifier + list of import attributes), + // we need to sort the attributes by key, then stringifying, + // so that different import statements with the same attributes are always treated + // as identical. + const keys = ObjectKeys(importAttributes); + + if (keys.length === 0) { + return specifier + '::'; + } + + return specifier + '::' + ArrayPrototypeJoin( + ArrayPrototypeMap( + ArrayPrototypeSort(keys), + (key) => JSONStringify(key) + JSONStringify(importAttributes[key])), + ','); + } + + #getModuleCachedImports(parentURL) { + let internalCache = super.get(parentURL); + if (internalCache == null) { + super.set(parentURL, internalCache = { __proto__: null }); + } + return internalCache; + } + + /** + * @param {string} serializedKey + * @param {string} parentURL + * @returns {import('./loader').ModuleExports | Promise} + */ + get(serializedKey, parentURL) { + return this.#getModuleCachedImports(parentURL)[serializedKey]; + } + + /** + * @param {string} serializedKey + * @param {string} parentURL + * @param {{ format: string, url: URL['href'] }} result + */ + set(serializedKey, parentURL, result) { + this.#getModuleCachedImports(parentURL)[serializedKey] = result; + return this; + } + + has(serializedKey, parentURL) { + return serializedKey in this.#getModuleCachedImports(parentURL); + } +} + +/** + * Cache the results of the `load` step of the module resolution and loading process. + */ +class LoadCache extends SafeMap { constructor(i) { super(i); } // eslint-disable-line no-useless-constructor get(url, type = kImplicitAssertType) { validateString(url, 'url'); @@ -30,7 +105,7 @@ class ModuleMap extends SafeMap { } debug(`Storing ${url} (${ type === kImplicitAssertType ? 'implicit type' : type - }) in ModuleMap`); + }) in ModuleLoadMap`); const cachedJobsForUrl = super.get(url) ?? ObjectCreate(null); cachedJobsForUrl[type] = job; return super.set(url, cachedJobsForUrl); @@ -41,4 +116,8 @@ class ModuleMap extends SafeMap { return super.get(url)?.[type] !== undefined; } } -module.exports = ModuleMap; + +module.exports = { + LoadCache, + ResolveCache, +}; diff --git a/lib/internal/modules/esm/package_config.js b/lib/internal/modules/esm/package_config.js index dc3c37f6042333..5da47764c9de2c 100644 --- a/lib/internal/modules/esm/package_config.js +++ b/lib/internal/modules/esm/package_config.js @@ -1,106 +1,29 @@ 'use strict'; const { - JSONParse, - ObjectPrototypeHasOwnProperty, - SafeMap, StringPrototypeEndsWith, } = primordials; const { URL, fileURLToPath } = require('internal/url'); -const { - ERR_INVALID_PACKAGE_CONFIG, -} = require('internal/errors').codes; - -const { filterOwnProperties } = require('internal/util'); - +const packageJsonReader = require('internal/modules/package_json_reader'); /** - * @typedef {string | string[] | Record} Exports - * @typedef {'module' | 'commonjs'} PackageType - * @typedef {{ - * pjsonPath: string, - * exports?: ExportConfig, - * name?: string, - * main?: string, - * type?: PackageType, - * }} PackageConfig + * @typedef {object} PackageConfig + * @property {string} pjsonPath - The path to the package.json file. + * @property {boolean} exists - Whether the package.json file exists. + * @property {'none' | 'commonjs' | 'module'} type - The type of the package. + * @property {string} [name] - The name of the package. + * @property {string} [main] - The main entry point of the package. + * @property {PackageTarget} [exports] - The exports configuration of the package. + * @property {Record>} [imports] - The imports configuration of the package. */ - -/** @type {Map} */ -const packageJSONCache = new SafeMap(); - - /** - * @param {string} path - * @param {string} specifier - * @param {string | URL | undefined} base - * @returns {PackageConfig} + * @typedef {string | string[] | Record>} PackageTarget */ -function getPackageConfig(path, specifier, base) { - const existing = packageJSONCache.get(path); - if (existing !== undefined) { - return existing; - } - const packageJsonReader = require('internal/modules/package_json_reader'); - const source = packageJsonReader.read(path).string; - if (source === undefined) { - const packageConfig = { - pjsonPath: path, - exists: false, - main: undefined, - name: undefined, - type: 'none', - exports: undefined, - imports: undefined, - }; - packageJSONCache.set(path, packageConfig); - return packageConfig; - } - - let packageJSON; - try { - packageJSON = JSONParse(source); - } catch (error) { - throw new ERR_INVALID_PACKAGE_CONFIG( - path, - (base ? `"${specifier}" from ` : '') + fileURLToPath(base || specifier), - error.message, - ); - } - - let { imports, main, name, type } = filterOwnProperties(packageJSON, ['imports', 'main', 'name', 'type']); - const exports = ObjectPrototypeHasOwnProperty(packageJSON, 'exports') ? packageJSON.exports : undefined; - if (typeof imports !== 'object' || imports === null) { - imports = undefined; - } - if (typeof main !== 'string') { - main = undefined; - } - if (typeof name !== 'string') { - name = undefined; - } - // Ignore unknown types for forwards compatibility - if (type !== 'module' && type !== 'commonjs') { - type = 'none'; - } - - const packageConfig = { - pjsonPath: path, - exists: true, - main, - name, - type, - exports, - imports, - }; - packageJSONCache.set(path, packageConfig); - return packageConfig; -} - /** - * @param {URL | string} resolved - * @returns {PackageConfig} + * Returns the package configuration for the given resolved URL. + * @param {URL | string} resolved - The resolved URL. + * @returns {PackageConfig} - The package configuration. */ function getPackageScopeConfig(resolved) { let packageJSONUrl = new URL('./package.json', resolved); @@ -109,7 +32,11 @@ function getPackageScopeConfig(resolved) { if (StringPrototypeEndsWith(packageJSONPath, 'node_modules/package.json')) { break; } - const packageConfig = getPackageConfig(fileURLToPath(packageJSONUrl), resolved); + const packageConfig = packageJsonReader.read(fileURLToPath(packageJSONUrl), { + __proto__: null, + specifier: resolved, + isESM: true, + }); if (packageConfig.exists) { return packageConfig; } @@ -124,7 +51,8 @@ function getPackageScopeConfig(resolved) { } } const packageJSONPath = fileURLToPath(packageJSONUrl); - const packageConfig = { + return { + __proto__: null, pjsonPath: packageJSONPath, exists: false, main: undefined, @@ -133,12 +61,9 @@ function getPackageScopeConfig(resolved) { exports: undefined, imports: undefined, }; - packageJSONCache.set(packageJSONPath, packageConfig); - return packageConfig; } module.exports = { - getPackageConfig, getPackageScopeConfig, }; diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js index 7c6dd5cd59c4bf..96dd20a86b076e 100644 --- a/lib/internal/modules/esm/resolve.js +++ b/lib/internal/modules/esm/resolve.js @@ -4,7 +4,6 @@ const { ArrayIsArray, ArrayPrototypeJoin, ArrayPrototypeShift, - JSONParse, JSONStringify, ObjectGetOwnPropertyNames, ObjectPrototypeHasOwnProperty, @@ -37,10 +36,13 @@ const preserveSymlinks = getOptionValue('--preserve-symlinks'); const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main'); const experimentalNetworkImports = getOptionValue('--experimental-network-imports'); -const typeFlag = getOptionValue('--input-type'); -const { URL, pathToFileURL, fileURLToPath, toPathIfFileURL } = require('internal/url'); +const inputTypeFlag = getOptionValue('--input-type'); +const { URL, pathToFileURL, fileURLToPath, isURL, toPathIfFileURL } = require('internal/url'); +const { getCWDURL } = require('internal/util'); +const { canParse: URLCanParse } = internalBinding('url'); const { ERR_INPUT_TYPE_NOT_ALLOWED, + ERR_INVALID_ARG_TYPE, ERR_INVALID_MODULE_SPECIFIER, ERR_INVALID_PACKAGE_CONFIG, ERR_INVALID_PACKAGE_TARGET, @@ -53,9 +55,9 @@ const { } = require('internal/errors').codes; const { Module: CJSModule } = require('internal/modules/cjs/loader'); -const packageJsonReader = require('internal/modules/package_json_reader'); -const { getPackageConfig, getPackageScopeConfig } = require('internal/modules/esm/package_config'); +const { getPackageScopeConfig } = require('internal/modules/esm/package_config'); const { getConditionsSet } = require('internal/modules/esm/utils'); +const packageJsonReader = require('internal/modules/package_json_reader'); const { internalModuleStat } = internalBinding('fs'); /** @@ -65,10 +67,16 @@ const { internalModuleStat } = internalBinding('fs'); const emittedPackageWarnings = new SafeSet(); +/** + * Emits a deprecation warning for the use of a deprecated trailing slash pattern mapping in the "exports" field + * module resolution of a package. + * @param {string} match - The deprecated trailing slash pattern mapping. + * @param {string} pjsonUrl - The URL of the package.json file. + * @param {string} base - The URL of the module that imported the package. + */ function emitTrailingSlashPatternDeprecation(match, pjsonUrl, base) { const pjsonPath = fileURLToPath(pjsonUrl); - if (emittedPackageWarnings.has(pjsonPath + '|' + match)) - return; + if (emittedPackageWarnings.has(pjsonPath + '|' + match)) { return; } emittedPackageWarnings.add(pjsonPath + '|' + match); process.emitWarning( `Use of deprecated trailing slash pattern mapping "${match}" in the ` + @@ -82,6 +90,16 @@ function emitTrailingSlashPatternDeprecation(match, pjsonUrl, base) { const doubleSlashRegEx = /[/\\][/\\]/; +/** + * Emits a deprecation warning for invalid segment in module resolution. + * @param {string} target - The target module. + * @param {string} request - The requested module. + * @param {string} match - The matched module. + * @param {string} pjsonUrl - The package.json URL. + * @param {boolean} internal - Whether the module is in the "imports" or "exports" field. + * @param {string} base - The base URL. + * @param {boolean} isTarget - Whether the target is a module. + */ function emitInvalidSegmentDeprecation(target, request, match, pjsonUrl, internal, base, isTarget) { if (!pendingDeprecation) { return; } const pjsonPath = fileURLToPath(pjsonUrl); @@ -98,16 +116,16 @@ function emitInvalidSegmentDeprecation(target, request, match, pjsonUrl, interna } /** - * @param {URL} url - * @param {URL} packageJSONUrl - * @param {string | URL | undefined} base - * @param {string} [main] - * @returns {void} + * Emits a deprecation warning if the given URL is a module and + * the package.json file does not define a "main" or "exports" field. + * @param {URL} url - The URL of the module being resolved. + * @param {URL} packageJSONUrl - The URL of the package.json file for the module. + * @param {string | URL} [base] - The base URL for the module being resolved. + * @param {string} [main] - The "main" field from the package.json file. */ function emitLegacyIndexDeprecation(url, packageJSONUrl, base, main) { const format = defaultGetFormatWithoutErrors(url); - if (format !== 'module') - return; + if (format !== 'module') { return; } const path = fileURLToPath(url); const pkgPath = fileURLToPath(new URL('.', packageJSONUrl)); const basePath = fileURLToPath(base); @@ -159,22 +177,23 @@ function legacyMainResolve(packageJSONUrl, packageConfig, base) { let guess; if (packageConfig.main !== undefined) { // Note: fs check redundances will be handled by Descriptor cache here. - if (fileExists(guess = new URL(`./${packageConfig.main}`, - packageJSONUrl))) { + if (fileExists(guess = new URL(`./${packageConfig.main}`, packageJSONUrl))) { return guess; - } else if (fileExists(guess = new URL(`./${packageConfig.main}.js`, - packageJSONUrl))); - else if (fileExists(guess = new URL(`./${packageConfig.main}.json`, - packageJSONUrl))); - else if (fileExists(guess = new URL(`./${packageConfig.main}.node`, - packageJSONUrl))); - else if (fileExists(guess = new URL(`./${packageConfig.main}/index.js`, - packageJSONUrl))); - else if (fileExists(guess = new URL(`./${packageConfig.main}/index.json`, - packageJSONUrl))); - else if (fileExists(guess = new URL(`./${packageConfig.main}/index.node`, - packageJSONUrl))); - else guess = undefined; + } else if (fileExists(guess = new URL(`./${packageConfig.main}.js`, packageJSONUrl))) { + // Handled below. + } else if (fileExists(guess = new URL(`./${packageConfig.main}.json`, packageJSONUrl))) { + // Handled below. + } else if (fileExists(guess = new URL(`./${packageConfig.main}.node`, packageJSONUrl))) { + // Handled below. + } else if (fileExists(guess = new URL(`./${packageConfig.main}/index.js`, packageJSONUrl))) { + // Handled below. + } else if (fileExists(guess = new URL(`./${packageConfig.main}/index.json`, packageJSONUrl))) { + // Handled below. + } else if (fileExists(guess = new URL(`./${packageConfig.main}/index.node`, packageJSONUrl))) { + // Handled below. + } else { + guess = undefined; + } if (guess) { emitLegacyIndexDeprecation(guess, packageJSONUrl, base, packageConfig.main); @@ -182,11 +201,15 @@ function legacyMainResolve(packageJSONUrl, packageConfig, base) { } // Fallthrough. } - if (fileExists(guess = new URL('./index.js', packageJSONUrl))); - // So fs. - else if (fileExists(guess = new URL('./index.json', packageJSONUrl))); - else if (fileExists(guess = new URL('./index.node', packageJSONUrl))); - else guess = undefined; + if (fileExists(guess = new URL('./index.js', packageJSONUrl))) { + // Handled below. + } else if (fileExists(guess = new URL('./index.json', packageJSONUrl))) { + // Handled below. + } else if (fileExists(guess = new URL('./index.node', packageJSONUrl))) { + // Handled below. + } else { + guess = undefined; + } if (guess) { emitLegacyIndexDeprecation(guess, packageJSONUrl, base, packageConfig.main); return guess; @@ -201,7 +224,7 @@ function legacyMainResolve(packageJSONUrl, packageConfig, base) { * @returns {URL | undefined} */ function resolveExtensionsWithTryExactName(search) { - if (fileExists(search)) return search; + if (fileExists(search)) { return search; } return resolveExtensions(search); } @@ -215,7 +238,7 @@ function resolveExtensions(search) { for (let i = 0; i < extensions.length; i++) { const extension = extensions[i]; const guess = new URL(`${search.pathname}${extension}`, search); - if (fileExists(guess)) return guess; + if (fileExists(guess)) { return guess; } } return undefined; } @@ -229,8 +252,8 @@ function resolveDirectoryEntry(search) { const pkgJsonPath = resolve(dirPath, 'package.json'); if (fileExists(pkgJsonPath)) { const pkgJson = packageJsonReader.read(pkgJsonPath); - if (pkgJson.containsKeys) { - const { main } = JSONParse(pkgJson.string); + if (pkgJson.exists) { + const { main } = pkgJson; if (main != null) { const mainUrl = pathToFileURL(resolve(dirPath, main)); return resolveExtensionsWithTryExactName(mainUrl); @@ -242,18 +265,33 @@ function resolveDirectoryEntry(search) { const encodedSepRegEx = /%2F|%5C/i; /** - * @param {URL} resolved - * @param {string | URL | undefined} base - * @param {boolean} preserveSymlinks - * @returns {URL | undefined} + * Finalizes the resolution of a module specifier by checking if the resolved pathname contains encoded "/" or "\\" + * characters, checking if the resolved pathname is a directory or file, and resolving any symlinks if necessary. + * @param {URL} resolved - The resolved URL object. + * @param {string | URL | undefined} base - The base URL object. + * @param {boolean} preserveSymlinks - Whether to preserve symlinks or not. + * @returns {URL} - The finalized URL object. + * @throws {ERR_INVALID_MODULE_SPECIFIER} - If the resolved pathname contains encoded "/" or "\\" characters. + * @throws {ERR_UNSUPPORTED_DIR_IMPORT} - If the resolved pathname is a directory. + * @throws {ERR_MODULE_NOT_FOUND} - If the resolved pathname is not a file. */ function finalizeResolution(resolved, base, preserveSymlinks) { - if (RegExpPrototypeExec(encodedSepRegEx, resolved.pathname) !== null) + if (RegExpPrototypeExec(encodedSepRegEx, resolved.pathname) !== null) { throw new ERR_INVALID_MODULE_SPECIFIER( resolved.pathname, 'must not include encoded "/" or "\\" characters', fileURLToPath(base)); + } + + let path; + try { + path = fileURLToPath(resolved); + } catch (err) { + const { setOwnProperty } = require('internal/util'); + setOwnProperty(err, 'input', `${resolved}`); + setOwnProperty(err, 'module', `${base}`); + throw err; + } - let path = fileURLToPath(resolved); if (getOptionValue('--experimental-specifier-resolution') === 'node') { let file = resolveExtensionsWithTryExactName(resolved); @@ -262,7 +300,7 @@ function finalizeResolution(resolved, base, preserveSymlinks) { file = StringPrototypeEndsWith(path, '/') ? (resolveDirectoryEntry(resolved) || resolved) : resolveDirectoryEntry(new URL(`${resolved}/`)); - if (file === resolved) return file; + if (file === resolved) { return file; } if (file === undefined) { throw new ERR_MODULE_NOT_FOUND( @@ -280,16 +318,14 @@ function finalizeResolution(resolved, base, preserveSymlinks) { // Check for stats.isDirectory() if (stats === 1) { - const err = new ERR_UNSUPPORTED_DIR_IMPORT(path, fileURLToPath(base)); - err.url = String(resolved); - throw err; + throw new ERR_UNSUPPORTED_DIR_IMPORT(path, fileURLToPath(base), String(resolved)); } else if (stats !== 0) { // Check for !stats.isFile() if (process.env.WATCH_REPORT_DEPENDENCIES && process.send) { process.send({ 'watch:require': [path || resolved.pathname] }); } throw new ERR_MODULE_NOT_FOUND( - path || resolved.pathname, base && fileURLToPath(base), 'module'); + path || resolved.pathname, base && fileURLToPath(base), resolved); } if (!preserveSymlinks) { @@ -307,9 +343,11 @@ function finalizeResolution(resolved, base, preserveSymlinks) { } /** - * @param {string} specifier - * @param {URL} packageJSONUrl - * @param {string | URL | undefined} base + * Returns an error object indicating that the specified import is not defined. + * @param {string} specifier - The import specifier that is not defined. + * @param {URL} packageJSONUrl - The URL of the package.json file, or null if not available. + * @param {string | URL | undefined} base - The base URL to use for resolving relative URLs. + * @returns {ERR_PACKAGE_IMPORT_NOT_DEFINED} - The error object. */ function importNotDefined(specifier, packageJSONUrl, base) { return new ERR_PACKAGE_IMPORT_NOT_DEFINED( @@ -318,9 +356,11 @@ function importNotDefined(specifier, packageJSONUrl, base) { } /** - * @param {string} subpath - * @param {URL} packageJSONUrl - * @param {string | URL | undefined} base + * Returns an error object indicating that the specified subpath was not exported by the package. + * @param {string} subpath - The subpath that was not exported. + * @param {URL} packageJSONUrl - The URL of the package.json file. + * @param {string | URL | undefined} [base] - The base URL to use for resolving the subpath. + * @returns {ERR_PACKAGE_PATH_NOT_EXPORTED} - The error object. */ function exportsNotFound(subpath, packageJSONUrl, base) { return new ERR_PACKAGE_PATH_NOT_EXPORTED( @@ -329,12 +369,13 @@ function exportsNotFound(subpath, packageJSONUrl, base) { } /** - * - * @param {string} request - * @param {string} match - * @param {URL} packageJSONUrl - * @param {boolean} internal - * @param {string | URL | undefined} base + * Throws an error indicating that the given request is not a valid subpath match for the specified pattern. + * @param {string} request - The request that failed to match the pattern. + * @param {string} match - The pattern that the request was compared against. + * @param {URL} packageJSONUrl - The URL of the package.json file being resolved. + * @param {boolean} internal - Whether the resolution is for an "imports" or "exports" field in package.json. + * @param {string | URL | undefined} base - The base URL for the resolution. + * @throws {ERR_INVALID_MODULE_SPECIFIER} When the request is not a valid match for the pattern. */ function throwInvalidSubpath(request, match, packageJSONUrl, internal, base) { const reason = `request is not a valid match in pattern "${match}" for the "${ @@ -344,6 +385,15 @@ function throwInvalidSubpath(request, match, packageJSONUrl, internal, base) { base && fileURLToPath(base)); } +/** + * Creates an error object for an invalid package target. + * @param {string} subpath - The subpath. + * @param {import('internal/modules/esm/package_config.js').PackageTarget} target - The target. + * @param {URL} packageJSONUrl - The URL of the package.json file. + * @param {boolean} internal - Whether the package is internal. + * @param {string | URL | undefined} base - The base URL. + * @returns {ERR_INVALID_PACKAGE_TARGET} - The error object. + */ function invalidPackageTarget( subpath, target, packageJSONUrl, internal, base) { if (typeof target === 'object' && target !== null) { @@ -362,17 +412,19 @@ const invalidPackageNameRegEx = /^\.|%|\\/; const patternRegEx = /\*/g; /** - * - * @param {string} target - * @param {*} subpath - * @param {*} match - * @param {*} packageJSONUrl - * @param {*} base - * @param {*} pattern - * @param {*} internal - * @param {*} isPathMap - * @param {*} conditions - * @returns {URL} + * Resolves the package target string to a URL object. + * @param {string} target - The target string to resolve. + * @param {string} subpath - The subpath to append to the resolved URL. + * @param {RegExpMatchArray} match - The matched string array from the import statement. + * @param {string} packageJSONUrl - The URL of the package.json file. + * @param {string} base - The base URL to resolve the target against. + * @param {RegExp} pattern - The pattern to replace in the target string. + * @param {boolean} internal - Whether the target is internal to the package. + * @param {boolean} isPathMap - Whether the target is a path map. + * @param {string[]} conditions - The import conditions. + * @returns {URL} - The resolved URL object. + * @throws {ERR_INVALID_PACKAGE_TARGET} - If the target is invalid. + * @throws {ERR_INVALID_SUBPATH} - If the subpath is invalid. */ function resolvePackageTargetString( target, @@ -386,20 +438,15 @@ function resolvePackageTargetString( conditions, ) { - if (subpath !== '' && !pattern && target[target.length - 1] !== '/') + if (subpath !== '' && !pattern && target[target.length - 1] !== '/') { throw invalidPackageTarget(match, target, packageJSONUrl, internal, base); + } if (!StringPrototypeStartsWith(target, './')) { if (internal && !StringPrototypeStartsWith(target, '../') && !StringPrototypeStartsWith(target, '/')) { - let isURL = false; - try { - new URL(target); - isURL = true; - } catch { - // Continue regardless of error. - } - if (!isURL) { + // No need to convert target to string, since it's already presumed to be + if (!URLCanParse(target)) { const exportTarget = pattern ? RegExpPrototypeSymbolReplace(patternRegEx, target, () => subpath) : target + subpath; @@ -430,10 +477,11 @@ function resolvePackageTargetString( const resolvedPath = resolved.pathname; const packagePath = new URL('.', packageJSONUrl).pathname; - if (!StringPrototypeStartsWith(resolvedPath, packagePath)) + if (!StringPrototypeStartsWith(resolvedPath, packagePath)) { throw invalidPackageTarget(match, target, packageJSONUrl, internal, base); + } - if (subpath === '') return resolved; + if (subpath === '') { return resolved; } if (RegExpPrototypeExec(invalidSegmentRegEx, subpath) !== null) { const request = pattern ? StringPrototypeReplace(match, '*', () => subpath) : match + subpath; @@ -459,27 +507,28 @@ function resolvePackageTargetString( } /** - * @param {string} key - * @returns {boolean} + * Checks if the given key is a valid array index. + * @param {string} key - The key to check. + * @returns {boolean} - Returns `true` if the key is a valid array index, else `false`. */ function isArrayIndex(key) { const keyNum = +key; - if (`${keyNum}` !== key) return false; + if (`${keyNum}` !== key) { return false; } return keyNum >= 0 && keyNum < 0xFFFF_FFFF; } /** - * - * @param {*} packageJSONUrl - * @param {string|[string]} target - * @param {*} subpath - * @param {*} packageSubpath - * @param {*} base - * @param {*} pattern - * @param {*} internal - * @param {*} isPathMap - * @param {*} conditions - * @returns {URL|null} + * Resolves the target of a package based on the provided parameters. + * @param {string} packageJSONUrl - The URL of the package.json file. + * @param {import('internal/modules/esm/package_config.js').PackageTarget} target - The target to resolve. + * @param {string} subpath - The subpath to resolve. + * @param {string} packageSubpath - The subpath of the package to resolve. + * @param {string} base - The base path to resolve. + * @param {RegExp} pattern - The pattern to match. + * @param {boolean} internal - Whether the package is internal. + * @param {boolean} isPathMap - Whether the package is a path map. + * @param {Set} conditions - The conditions to match. + * @returns {URL | null | undefined} - The resolved target, or null if not found, or undefined if not resolvable. */ function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath, base, pattern, internal, isPathMap, conditions) { @@ -516,8 +565,9 @@ function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath, } return resolveResult; } - if (lastException === undefined || lastException === null) + if (lastException === undefined || lastException === null) { return lastException; + } throw lastException; } else if (typeof target === 'object' && target !== null) { const keys = ObjectGetOwnPropertyNames(target); @@ -536,8 +586,7 @@ function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath, const resolveResult = resolvePackageTarget( packageJSONUrl, conditionalTarget, subpath, packageSubpath, base, pattern, internal, isPathMap, conditions); - if (resolveResult === undefined) - continue; + if (resolveResult === undefined) { continue; } return resolveResult; } } @@ -550,15 +599,14 @@ function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath, } /** - * - * @param {import('internal/modules/esm/package_config.js').Exports} exports - * @param {URL} packageJSONUrl - * @param {string | URL | undefined} base - * @returns {boolean} + * Is the given exports object using the shorthand syntax? + * @param {import('internal/modules/esm/package_config.js').PackageConfig['exports']} exports + * @param {URL} packageJSONUrl The URL of the package.json file. + * @param {string | URL | undefined} base The base URL. */ function isConditionalExportsMainSugar(exports, packageJSONUrl, base) { - if (typeof exports === 'string' || ArrayIsArray(exports)) return true; - if (typeof exports !== 'object' || exports === null) return false; + if (typeof exports === 'string' || ArrayIsArray(exports)) { return true; } + if (typeof exports !== 'object' || exports === null) { return false; } const keys = ObjectGetOwnPropertyNames(exports); let isConditionalSugar = false; @@ -580,18 +628,20 @@ function isConditionalExportsMainSugar(exports, packageJSONUrl, base) { } /** - * @param {URL} packageJSONUrl - * @param {string} packageSubpath - * @param {PackageConfig} packageConfig - * @param {string | URL | undefined} base - * @param {Set} conditions - * @returns {URL} + * Resolves the exports of a package. + * @param {URL} packageJSONUrl - The URL of the package.json file. + * @param {string} packageSubpath - The subpath of the package to resolve. + * @param {import('internal/modules/esm/package_config.js').PackageConfig} packageConfig - The package metadata. + * @param {string | URL | undefined} base - The base path to resolve from. + * @param {Set} conditions - An array of conditions to match. + * @returns {URL} - The resolved package target. */ function packageExportsResolve( packageJSONUrl, packageSubpath, packageConfig, base, conditions) { let exports = packageConfig.exports; - if (isConditionalExportsMainSugar(exports, packageJSONUrl, base)) + if (isConditionalExportsMainSugar(exports, packageJSONUrl, base)) { exports = { '.': exports }; + } if (ObjectPrototypeHasOwnProperty(exports, packageSubpath) && !StringPrototypeIncludes(packageSubpath, '*') && @@ -624,9 +674,10 @@ function packageExportsResolve( // throwInvalidSubpath(packageSubpath) // // To match "imports" and the spec. - if (StringPrototypeEndsWith(packageSubpath, '/')) + if (StringPrototypeEndsWith(packageSubpath, '/')) { emitTrailingSlashPatternDeprecation(packageSubpath, packageJSONUrl, base); + } const patternTrailer = StringPrototypeSlice(key, patternIndex + 1); if (packageSubpath.length >= key.length && StringPrototypeEndsWith(packageSubpath, patternTrailer) && @@ -662,25 +713,35 @@ function packageExportsResolve( throw exportsNotFound(packageSubpath, packageJSONUrl, base); } +/** + * Compares two strings that may contain a wildcard character ('*') and returns a value indicating their order. + * @param {string} a - The first string to compare. + * @param {string} b - The second string to compare. + * @returns {number} - A negative number if `a` should come before `b`, a positive number if `a` should come after `b`, + * or 0 if they are equal. + */ function patternKeyCompare(a, b) { const aPatternIndex = StringPrototypeIndexOf(a, '*'); const bPatternIndex = StringPrototypeIndexOf(b, '*'); const baseLenA = aPatternIndex === -1 ? a.length : aPatternIndex + 1; const baseLenB = bPatternIndex === -1 ? b.length : bPatternIndex + 1; - if (baseLenA > baseLenB) return -1; - if (baseLenB > baseLenA) return 1; - if (aPatternIndex === -1) return 1; - if (bPatternIndex === -1) return -1; - if (a.length > b.length) return -1; - if (b.length > a.length) return 1; + if (baseLenA > baseLenB) { return -1; } + if (baseLenB > baseLenA) { return 1; } + if (aPatternIndex === -1) { return 1; } + if (bPatternIndex === -1) { return -1; } + if (a.length > b.length) { return -1; } + if (b.length > a.length) { return 1; } return 0; } /** - * @param {string} name - * @param {string | URL | undefined} base - * @param {Set} conditions - * @returns {URL} + * Resolves the given import name for a package. + * @param {string} name - The name of the import to resolve. + * @param {string | URL | undefined} base - The base URL to resolve the import from. + * @param {Set} conditions - An object containing the import conditions. + * @throws {ERR_INVALID_MODULE_SPECIFIER} If the import name is not valid. + * @throws {ERR_PACKAGE_IMPORT_NOT_DEFINED} If the import name cannot be resolved. + * @returns {URL} The resolved import URL. */ function packageImportsResolve(name, base, conditions) { if (name === '#' || StringPrototypeStartsWith(name, '#/') || @@ -743,8 +804,8 @@ function packageImportsResolve(name, base, conditions) { } /** - * @param {URL} url - * @returns {import('internal/modules/esm/package_config.js').PackageType} + * Returns the package type for a given URL. + * @param {URL} url - The URL to get the package type for. */ function getPackageType(url) { const packageConfig = getPackageScopeConfig(url); @@ -752,9 +813,9 @@ function getPackageType(url) { } /** - * @param {string} specifier - * @param {string | URL | undefined} base - * @returns {{ packageName: string, packageSubpath: string, isScoped: boolean }} + * Parse a package name from a specifier. + * @param {string} specifier - The import specifier. + * @param {string | URL | undefined} base - The parent URL. */ function parsePackageName(specifier, base) { let separatorIndex = StringPrototypeIndexOf(specifier, '/'); @@ -775,8 +836,9 @@ function parsePackageName(specifier, base) { // Package name cannot have leading . and cannot have percent-encoding or // \\ separators. - if (RegExpPrototypeExec(invalidPackageNameRegEx, packageName) !== null) + if (RegExpPrototypeExec(invalidPackageNameRegEx, packageName) !== null) { validPackageName = false; + } if (!validPackageName) { throw new ERR_INVALID_MODULE_SPECIFIER( @@ -790,10 +852,11 @@ function parsePackageName(specifier, base) { } /** - * @param {string} specifier - * @param {string | URL | undefined} base - * @param {Set} conditions - * @returns {resolved: URL, format? : string} + * Resolves a package specifier to a URL. + * @param {string} specifier - The package specifier to resolve. + * @param {string | URL | undefined} base - The base URL to use for resolution. + * @param {Set} conditions - An object containing the conditions for resolution. + * @returns {URL} - The resolved URL. */ function packageResolve(specifier, base, conditions) { if (BuiltinModule.canBeRequiredWithoutScheme(specifier)) { @@ -807,8 +870,7 @@ function packageResolve(specifier, base, conditions) { const packageConfig = getPackageScopeConfig(base); if (packageConfig.exists) { const packageJSONUrl = pathToFileURL(packageConfig.pjsonPath); - if (packageConfig.name === packageName && - packageConfig.exports !== undefined && packageConfig.exports !== null) { + if (packageConfig.exports != null && packageConfig.name === packageName) { return packageExportsResolve( packageJSONUrl, packageSubpath, packageConfig, base, conditions); } @@ -832,8 +894,8 @@ function packageResolve(specifier, base, conditions) { } // Package match. - const packageConfig = getPackageConfig(packageJSONPath, specifier, base); - if (packageConfig.exports !== undefined && packageConfig.exports !== null) { + const packageConfig = packageJsonReader.read(packageJSONPath, { __proto__: null, specifier, base, isESM: true }); + if (packageConfig.exports != null) { return packageExportsResolve( packageJSONUrl, packageSubpath, packageConfig, base, conditions); } @@ -851,39 +913,47 @@ function packageResolve(specifier, base, conditions) { // eslint can't handle the above code. // eslint-disable-next-line no-unreachable - throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base)); + throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null); } /** - * @param {string} specifier - * @returns {boolean} + * Checks if a specifier is a bare specifier. + * @param {string} specifier - The specifier to check. */ function isBareSpecifier(specifier) { return specifier[0] && specifier[0] !== '/' && specifier[0] !== '.'; } +/** + * Determines whether a specifier is a relative path. + * @param {string} specifier - The specifier to check. + */ function isRelativeSpecifier(specifier) { if (specifier[0] === '.') { - if (specifier.length === 1 || specifier[1] === '/') return true; + if (specifier.length === 1 || specifier[1] === '/') { return true; } if (specifier[1] === '.') { - if (specifier.length === 2 || specifier[2] === '/') return true; + if (specifier.length === 2 || specifier[2] === '/') { return true; } } } return false; } +/** + * Determines whether a specifier should be treated as a relative or absolute path. + * @param {string} specifier - The specifier to check. + */ function shouldBeTreatedAsRelativeOrAbsolutePath(specifier) { - if (specifier === '') return false; - if (specifier[0] === '/') return true; + if (specifier === '') { return false; } + if (specifier[0] === '/') { return true; } return isRelativeSpecifier(specifier); } /** - * @param {string} specifier - * @param {string | URL | undefined} base - * @param {Set} conditions - * @param {boolean} preserveSymlinks - * @returns {url: URL, format?: string} + * Resolves a module specifier to a URL. + * @param {string} specifier - The module specifier to resolve. + * @param {string | URL | undefined} base - The base URL to resolve against. + * @param {Set} conditions - An object containing environment conditions. + * @param {boolean} preserveSymlinks - Whether to preserve symlinks in the resolved URL. */ function moduleResolve(specifier, base, conditions, preserveSymlinks) { const isRemote = base.protocol === 'http:' || @@ -911,10 +981,9 @@ function moduleResolve(specifier, base, conditions, preserveSymlinks) { } /** - * Try to resolve an import as a CommonJS module - * @param {string} specifier - * @param {string} parentURL - * @returns {boolean|string} + * Try to resolve an import as a CommonJS module. + * @param {string} specifier - The specifier to resolve. + * @param {string} parentURL - The base URL. */ function resolveAsCommonJS(specifier, parentURL) { try { @@ -956,7 +1025,14 @@ function resolveAsCommonJS(specifier, parentURL) { } } -// TODO(@JakobJingleheimer): de-dupe `specifier` & `parsed` +/** + * Throw an error if an import is not allowed. + * TODO(@JakobJingleheimer): de-dupe `specifier` & `parsed` + * @param {string} specifier - The import specifier. + * @param {URL} parsed - The parsed URL of the import specifier. + * @param {URL} parsedParentURL - The parsed URL of the parent module. + * @throws {ERR_NETWORK_IMPORT_DISALLOWED} - If the import is disallowed. + */ function checkIfDisallowedImport(specifier, parsed, parsedParentURL) { if (parsedParentURL) { // Avoid accessing the `protocol` property due to the lazy getters. @@ -1000,9 +1076,31 @@ function checkIfDisallowedImport(specifier, parsed, parsedParentURL) { } } +/** + * Validate user-input in `context` supplied by a custom loader. + * @param {string | URL | undefined} parentURL - The parent URL. + */ +function throwIfInvalidParentURL(parentURL) { + if (parentURL === undefined) { + return; // Main entry point, so no parent + } + if (typeof parentURL !== 'string' && !isURL(parentURL)) { + throw new ERR_INVALID_ARG_TYPE('parentURL', ['string', 'URL'], parentURL); + } +} -async function defaultResolve(specifier, context = {}) { +/** + * Resolves the given specifier using the provided context, which includes the parent URL and conditions. + * Throws an error if the parent URL is invalid or if the resolution is disallowed by the policy manifest. + * Otherwise, attempts to resolve the specifier and returns the resulting URL and format. + * @param {string} specifier - The specifier to resolve. + * @param {object} [context={}] - The context object containing the parent URL and conditions. + * @param {string} [context.parentURL] - The URL of the parent module. + * @param {string[]} [context.conditions] - The conditions for resolving the specifier. + */ +function defaultResolve(specifier, context = {}) { let { parentURL, conditions } = context; + throwIfInvalidParentURL(parentURL); if (parentURL && policy?.manifest) { const redirects = policy.manifest.getDependencyMapper(parentURL); if (redirects) { @@ -1070,15 +1168,15 @@ async function defaultResolve(specifier, context = {}) { parsedParentURL, ); - if (maybeReturn) return maybeReturn; + if (maybeReturn) { return maybeReturn; } // This must come after checkIfDisallowedImport - if (parsed && parsed.protocol === 'node:') return { url: specifier }; + if (parsed && parsed.protocol === 'node:') { return { __proto__: null, url: specifier }; } const isMain = parentURL === undefined; if (isMain) { - parentURL = pathToFileURL(`${process.cwd()}/`).href; + parentURL = getCWDURL().href; // This is the initial entry point to the program, and --input-type has // been passed as an option; but --input-type can only be used with @@ -1086,7 +1184,7 @@ async function defaultResolve(specifier, context = {}) { // input, to avoid user confusion over how expansive the effect of the // flag should be (i.e. entry point only, package scope surrounding the // entry point, etc.). - if (typeFlag) throw new ERR_INPUT_TYPE_NOT_ALLOWED(); + if (inputTypeFlag) { throw new ERR_INPUT_TYPE_NOT_ALLOWED(); } } conditions = getConditionsSet(conditions); @@ -1106,17 +1204,7 @@ async function defaultResolve(specifier, context = {}) { if (StringPrototypeStartsWith(specifier, 'file://')) { specifier = fileURLToPath(specifier); } - const found = resolveAsCommonJS(specifier, parentURL); - if (found) { - // Modify the stack and message string to include the hint - const lines = StringPrototypeSplit(error.stack, '\n'); - const hint = `Did you mean to import ${found}?`; - error.stack = - ArrayPrototypeShift(lines) + '\n' + - hint + '\n' + - ArrayPrototypeJoin(lines, '\n'); - error.message += `\n${hint}`; - } + decorateErrorWithCommonJSHints(error, specifier, parentURL); } throw error; } @@ -1129,13 +1217,35 @@ async function defaultResolve(specifier, context = {}) { }; } +/** + * Decorates the given error with a hint for CommonJS modules. + * @param {Error} error - The error to decorate. + * @param {string} specifier - The specifier that was attempted to be imported. + * @param {string} parentURL - The URL of the parent module. + */ +function decorateErrorWithCommonJSHints(error, specifier, parentURL) { + const found = resolveAsCommonJS(specifier, parentURL); + if (found) { + // Modify the stack and message string to include the hint + const lines = StringPrototypeSplit(error.stack, '\n'); + const hint = `Did you mean to import ${found}?`; + error.stack = + ArrayPrototypeShift(lines) + '\n' + + hint + '\n' + + ArrayPrototypeJoin(lines, '\n'); + error.message += `\n${hint}`; + } +} + module.exports = { + decorateErrorWithCommonJSHints, defaultResolve, encodedSepRegEx, getPackageScopeConfig, getPackageType, packageExportsResolve, packageImportsResolve, + throwIfInvalidParentURL, }; // cycle @@ -1145,11 +1255,11 @@ const { if (policy) { const $defaultResolve = defaultResolve; - module.exports.defaultResolve = async function defaultResolve( + module.exports.defaultResolve = function defaultResolve( specifier, context, ) { - const ret = await $defaultResolve(specifier, context); + const ret = $defaultResolve(specifier, context); // This is a preflight check to avoid data exfiltration by query params etc. policy.manifest.mightAllow(ret.url, () => new ERR_MANIFEST_DEPENDENCY_MISSING( diff --git a/lib/internal/modules/esm/shared_constants.js b/lib/internal/modules/esm/shared_constants.js new file mode 100644 index 00000000000000..4200bc87367d14 --- /dev/null +++ b/lib/internal/modules/esm/shared_constants.js @@ -0,0 +1,25 @@ +// This file contains the definition for the constant values that must be +// available to both the main thread and the loader thread. + +'use strict'; + +/* +The shared memory area is divided in 1 32-bit long section. It has to be 32-bit long as +`Atomics.notify` only works with `Int32Array` objects. + +--32-bits-- + ^ + | + | +WORKER_TO_MAIN_THREAD_NOTIFICATION + +WORKER_TO_MAIN_THREAD_NOTIFICATION is only used to send notifications, its value is going to +increase every time the worker sends a notification to the main thread. + +*/ + +module.exports = { + WORKER_TO_MAIN_THREAD_NOTIFICATION: 0, + + SHARED_MEMORY_BYTE_LENGTH: 1 * 4, +}; diff --git a/lib/internal/modules/esm/translators.js b/lib/internal/modules/esm/translators.js index 267d89f1d44730..39af99c6c3b5b7 100644 --- a/lib/internal/modules/esm/translators.js +++ b/lib/internal/modules/esm/translators.js @@ -11,6 +11,7 @@ const { SafeArrayIterator, SafeMap, SafeSet, + StringPrototypeIncludes, StringPrototypeReplaceAll, StringPrototypeSlice, StringPrototypeStartsWith, @@ -18,9 +19,13 @@ const { globalThis: { WebAssembly }, } = primordials; +/** @type {import('internal/util/types')} */ let _TYPES = null; +/** + * Lazily loads and returns the internal/util/types module. + */ function lazyTypes() { - if (_TYPES !== null) return _TYPES; + if (_TYPES !== null) { return _TYPES; } return _TYPES = require('internal/util/types'); } @@ -50,7 +55,13 @@ const { ModuleWrap } = moduleWrap; const asyncESM = require('internal/process/esm_loader'); const { emitWarningSync } = require('internal/process/warning'); +/** @type {import('deps/cjs-module-lexer/lexer.js').parse} */ let cjsParse; +/** + * Initializes the CommonJS module lexer parser. + * If WebAssembly is available, it uses the optimized version from the dist folder. + * Otherwise, it falls back to the JavaScript version from the lexer folder. + */ async function initCJSParse() { if (typeof WebAssembly === 'undefined') { cjsParse = require('internal/deps/cjs-module-lexer/lexer').parse; @@ -71,6 +82,14 @@ exports.translators = translators; exports.enrichCJSError = enrichCJSError; let DECODER = null; +/** + * Asserts that the given body is a buffer source (either a string, array buffer, or typed array). + * Throws an error if the body is not a buffer source. + * @param {string | ArrayBufferView | ArrayBuffer} body - The body to check. + * @param {boolean} allowString - Whether or not to allow a string as a valid buffer source. + * @param {string} hookName - The name of the hook being called. + * @throws {ERR_INVALID_RETURN_PROPERTY_VALUE} If the body is not a buffer source. + */ function assertBufferSource(body, allowString, hookName) { if (allowString && typeof body === 'string') { return; @@ -87,14 +106,23 @@ function assertBufferSource(body, allowString, hookName) { ); } +/** + * Converts a buffer or buffer-like object to a string. + * @param {string | ArrayBuffer | ArrayBufferView} body - The buffer or buffer-like object to convert to a string. + * @returns {string} The resulting string. + */ function stringify(body) { - if (typeof body === 'string') return body; + if (typeof body === 'string') { return body; } assertBufferSource(body, false, 'transformSource'); const { TextDecoder } = require('internal/encoding'); DECODER = DECODER === null ? new TextDecoder() : DECODER; return DECODER.decode(body); } +/** + * Converts a URL to a file path if the URL protocol is 'file:'. + * @param {string} url - The URL to convert. + */ function errPath(url) { const parsed = new URL(url); if (parsed.protocol === 'file:') { @@ -103,8 +131,16 @@ function errPath(url) { return url; } -async function importModuleDynamically(specifier, { url }, assertions) { - return asyncESM.esmLoader.import(specifier, url, assertions); +/** + * Dynamically imports a module using the ESM loader. + * @param {string} specifier - The module specifier to import. + * @param {object} options - An object containing options for the import. + * @param {string} options.url - The URL of the module requesting the import. + * @param {Record} [attributes] - An object containing attributes for the import. + * @returns {Promise} The imported module. + */ +async function importModuleDynamically(specifier, { url }, attributes) { + return asyncESM.esmLoader.import(specifier, url, attributes); } // Strategy for loading a standard JavaScript module. @@ -123,6 +159,7 @@ translators.set('module', async function moduleStrategy(url, source, isMain) { }); /** + * Provide a more informative error for CommonJS imports. * @param {Error | any} err * @param {string} [content] Content of the file, if known. * @param {string} [filename] Useful only if `content` is unknown. @@ -148,7 +185,7 @@ translators.set('commonjs', async function commonjsStrategy(url, source, const filename = fileURLToPath(new URL(url)); - if (!cjsParse) await initCJSParse(); + if (!cjsParse) { await initCJSParse(); } const { module, exportNames } = cjsPreparseModuleExports(filename); const namesWithDefault = exportNames.has('default') ? [...exportNames] : ['default', ...exportNames]; @@ -171,8 +208,9 @@ translators.set('commonjs', async function commonjsStrategy(url, source, for (const exportName of exportNames) { if (!ObjectPrototypeHasOwnProperty(exports, exportName) || - exportName === 'default') + exportName === 'default') { continue; + } // We might trigger a getter -> dont fail. let value; try { @@ -186,12 +224,17 @@ translators.set('commonjs', async function commonjsStrategy(url, source, }); }); +/** + * Pre-parses a CommonJS module's exports and re-exports. + * @param {string} filename - The filename of the module. + */ function cjsPreparseModuleExports(filename) { let module = CJSModule._cache[filename]; if (module) { const cached = cjsParseCache.get(module); - if (cached) + if (cached) { return { module, exportNames: cached.exportNames }; + } } const loaded = Boolean(module); if (!loaded) { @@ -236,8 +279,9 @@ function cjsPreparseModuleExports(filename) { if ((ext === '.js' || ext === '.cjs' || !CJSModule._extensions[ext]) && isAbsolute(resolved)) { const { exportNames: reexportNames } = cjsPreparseModuleExports(resolved); - for (const name of reexportNames) + for (const name of reexportNames) { exportNames.add(name); + } } }); @@ -265,9 +309,12 @@ translators.set('json', async function jsonStrategy(url, source) { debug(`Loading JSONModule ${url}`); const pathname = StringPrototypeStartsWith(url, 'file:') ? fileURLToPath(url) : null; + const shouldCheckAndPopulateCJSModuleCache = + // We want to involve the CJS loader cache only for `file:` URL with no search query and no hash. + pathname && !StringPrototypeIncludes(url, '?') && !StringPrototypeIncludes(url, '#'); let modulePath; let module; - if (pathname) { + if (shouldCheckAndPopulateCJSModuleCache) { modulePath = isWindows ? StringPrototypeReplaceAll(pathname, '/', '\\') : pathname; module = CJSModule._cache[modulePath]; @@ -279,7 +326,7 @@ translators.set('json', async function jsonStrategy(url, source) { } } source = stringify(source); - if (pathname) { + if (shouldCheckAndPopulateCJSModuleCache) { // A require call could have been called on the same file during loading and // that resolves synchronously. To make sure we always return the identical // export, we have to check again if the module already exists or not. @@ -305,7 +352,7 @@ translators.set('json', async function jsonStrategy(url, source) { err.message = errPath(url) + ': ' + err.message; throw err; } - if (pathname) { + if (shouldCheckAndPopulateCJSModuleCache) { CJSModule._cache[modulePath] = module; } return new ModuleWrap(url, undefined, ['default'], function() { @@ -341,7 +388,8 @@ translators.set('wasm', async function(url, source) { 'internal/modules/esm/create_dynamic_module'); return createDynamicModule(imports, exports, url, (reflect) => { const { exports } = new WebAssembly.Instance(compiled, reflect.imports); - for (const expt of ObjectKeys(exports)) + for (const expt of ObjectKeys(exports)) { reflect.exports[expt].set(exports[expt]); + } }).module; }); diff --git a/lib/internal/modules/esm/utils.js b/lib/internal/modules/esm/utils.js index bf3edc86518b4c..df729ef96c7172 100644 --- a/lib/internal/modules/esm/utils.js +++ b/lib/internal/modules/esm/utils.js @@ -1,4 +1,5 @@ 'use strict'; + const { ArrayIsArray, SafeSet, @@ -10,9 +11,12 @@ const { ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING, ERR_INVALID_ARG_VALUE, } = require('internal/errors').codes; - const { getOptionValue } = require('internal/options'); - +const { + loadPreloadModules, + initializeFrozenIntrinsics, +} = require('internal/process/pre_execution'); +const { getCWDURL } = require('internal/util'); const { setImportModuleDynamicallyCallback, setInitializeImportMetaObjectCallback, @@ -28,18 +32,28 @@ function setCallbackForWrap(wrap, data) { } let defaultConditions; +/** + * Returns the default conditions for ES module loading. + */ function getDefaultConditions() { assert(defaultConditions !== undefined); return defaultConditions; } +/** @type {Set} */ let defaultConditionsSet; +/** + * Returns the default conditions for ES module loading, as a Set. + */ function getDefaultConditionsSet() { assert(defaultConditionsSet !== undefined); return defaultConditionsSet; } -// This function is called during pre-execution, before any user code is run. +/** + * Initializes the default conditions for ESM module loading. + * This function is called during pre-execution, before any user code is run. + */ function initializeDefaultConditions() { const userConditions = getOptionValue('--conditions'); const noAddons = getOptionValue('--no-addons'); @@ -69,27 +83,48 @@ function getConditionsSet(conditions) { return getDefaultConditionsSet(); } +/** + * Defines the `import.meta` object for a given module. + * @param {object} wrap - Reference to the module. + * @param {Record} meta - The import.meta object to initialize. + */ function initializeImportMetaObject(wrap, meta) { if (callbackMap.has(wrap)) { const { initializeImportMeta } = callbackMap.get(wrap); if (initializeImportMeta !== undefined) { - initializeImportMeta(meta, getModuleFromWrap(wrap) || wrap); + meta = initializeImportMeta(meta, getModuleFromWrap(wrap) || wrap); } } } -async function importModuleDynamicallyCallback(wrap, specifier, assertions) { +/** + * Asynchronously imports a module dynamically using a callback function. The native callback. + * @param {object} wrap - Reference to the module. + * @param {string} specifier - The module specifier string. + * @param {Record} attributes - The import attributes object. + * @returns {Promise} - The imported module object. + * @throws {ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING} - If the callback function is missing. + */ +async function importModuleDynamicallyCallback(wrap, specifier, attributes) { if (callbackMap.has(wrap)) { const { importModuleDynamically } = callbackMap.get(wrap); if (importModuleDynamically !== undefined) { return importModuleDynamically( - specifier, getModuleFromWrap(wrap) || wrap, assertions); + specifier, getModuleFromWrap(wrap) || wrap, attributes); } } throw new ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING(); } -function initializeESM() { +let _isLoaderWorker = false; +/** + * Initializes handling of ES modules. + * This is configured during pre-execution. Specifically it's set to true for + * the loader worker in internal/main/worker_thread.js. + * @param {boolean} [isLoaderWorker=false] - A boolean indicating whether the loader is a worker or not. + */ +function initializeESM(isLoaderWorker = false) { + _isLoaderWorker = isLoaderWorker; initializeDefaultConditions(); // Setup per-isolate callbacks that locate data or callbacks that we keep // track of for different ESM modules. @@ -97,9 +132,55 @@ function initializeESM() { setImportModuleDynamicallyCallback(importModuleDynamicallyCallback); } +/** + * Determine whether the current process is a loader worker. + * @returns {boolean} Whether the current process is a loader worker. + */ +function isLoaderWorker() { + return _isLoaderWorker; +} + +/** + * Register module customization hooks. + */ +async function initializeHooks() { + const customLoaderURLs = getOptionValue('--experimental-loader'); + + const { Hooks } = require('internal/modules/esm/hooks'); + const esmLoader = require('internal/process/esm_loader').esmLoader; + + const hooks = new Hooks(); + esmLoader.setCustomizations(hooks); + + // We need the loader customizations to be set _before_ we start invoking + // `--require`, otherwise loops can happen because a `--require` script + // might call `register(...)` before we've installed ourselves. These + // global values are magically set in `setupUserModules` just for us and + // we call them in the correct order. + // N.B. This block appears here specifically in order to ensure that + // `--require` calls occur before `--loader` ones do. + loadPreloadModules(); + initializeFrozenIntrinsics(); + + const parentURL = getCWDURL().href; + for (let i = 0; i < customLoaderURLs.length; i++) { + await hooks.register( + customLoaderURLs[i], + parentURL, + ); + } + + const preloadScripts = hooks.initializeGlobalPreload(); + + return { __proto__: null, hooks, preloadScripts }; +} + module.exports = { setCallbackForWrap, initializeESM, + initializeHooks, getDefaultConditions, getConditionsSet, + loaderWorkerId: 'internal/modules/esm/worker', + isLoaderWorker, }; diff --git a/lib/internal/modules/esm/worker.js b/lib/internal/modules/esm/worker.js new file mode 100644 index 00000000000000..7b295973abe7a4 --- /dev/null +++ b/lib/internal/modules/esm/worker.js @@ -0,0 +1,261 @@ +'use strict'; + +const { + DataViewPrototypeGetBuffer, + Int32Array, + PromisePrototypeThen, + ReflectApply, + SafeSet, + TypedArrayPrototypeGetBuffer, + globalThis: { + Atomics: { + add: AtomicsAdd, + notify: AtomicsNotify, + }, + }, +} = primordials; +const assert = require('internal/assert'); +const { clearImmediate, setImmediate } = require('timers'); +const { + hasUncaughtExceptionCaptureCallback, +} = require('internal/process/execution'); +const { + isArrayBuffer, + isDataView, + isTypedArray, +} = require('util/types'); + +const { receiveMessageOnPort } = require('internal/worker/io'); +const { + WORKER_TO_MAIN_THREAD_NOTIFICATION, +} = require('internal/modules/esm/shared_constants'); +const { initializeHooks } = require('internal/modules/esm/utils'); + + +/** + * Transfers an ArrayBuffer, TypedArray, or DataView to a worker thread. + * @param {boolean} hasError - Whether an error occurred during transfer. + * @param {ArrayBuffer | TypedArray | DataView} source - The data to transfer. + */ +function transferArrayBuffer(hasError, source) { + if (hasError || source == null) { return; } + if (isArrayBuffer(source)) { return [source]; } + if (isTypedArray(source)) { return [TypedArrayPrototypeGetBuffer(source)]; } + if (isDataView(source)) { return [DataViewPrototypeGetBuffer(source)]; } +} + +/** + * Wraps a message with a status and body, and serializes the body if necessary. + * @param {string} status - The status of the message. + * @param {unknown} body - The body of the message. + */ +function wrapMessage(status, body) { + if (status === 'success' || body === null || + (typeof body !== 'object' && + typeof body !== 'function' && + typeof body !== 'symbol')) { + return { status, body }; + } + + let serialized; + let serializationFailed; + try { + const { serializeError } = require('internal/error_serdes'); + serialized = serializeError(body); + } catch { + serializationFailed = true; + } + + return { + status, + body: { + serialized, + serializationFailed, + }, + }; +} + +/** + * Initializes a worker thread for a customized module loader. + * @param {SharedArrayBuffer} lock - The lock used to synchronize communication between the worker and the main thread. + * @param {MessagePort} syncCommPort - The message port used for synchronous communication between the worker and the + * main thread. + * @param {(err: Error, origin?: string) => void} errorHandler - The function to use for uncaught exceptions. + * @returns {Promise} A promise that resolves when the worker thread has been initialized. + */ +async function customizedModuleWorker(lock, syncCommPort, errorHandler) { + let hooks, preloadScripts, initializationError; + let hasInitializationError = false; + + { + // If a custom hook is calling `process.exit`, we should wake up the main thread + // so it can detect the exit event. + const { exit } = process; + process.exit = function(code) { + syncCommPort.postMessage(wrapMessage('exit', code ?? process.exitCode)); + AtomicsAdd(lock, WORKER_TO_MAIN_THREAD_NOTIFICATION, 1); + AtomicsNotify(lock, WORKER_TO_MAIN_THREAD_NOTIFICATION); + return ReflectApply(exit, this, arguments); + }; + } + + + try { + const initResult = await initializeHooks(); + hooks = initResult.hooks; + preloadScripts = initResult.preloadScripts; + } catch (exception) { + // If there was an error while parsing and executing a user loader, for example if because a + // loader contained a syntax error, then we need to send the error to the main thread so it can + // be thrown and printed. + hasInitializationError = true; + initializationError = exception; + } + + syncCommPort.on('message', handleMessage); + + if (hasInitializationError) { + syncCommPort.postMessage(wrapMessage('error', initializationError)); + } else { + syncCommPort.postMessage(wrapMessage('success', { preloadScripts }), preloadScripts.map(({ port }) => port)); + } + + // We're ready, so unlock the main thread. + AtomicsAdd(lock, WORKER_TO_MAIN_THREAD_NOTIFICATION, 1); + AtomicsNotify(lock, WORKER_TO_MAIN_THREAD_NOTIFICATION); + + let immediate; + /** + * Checks for messages on the syncCommPort and handles them asynchronously. + */ + function checkForMessages() { + immediate = setImmediate(checkForMessages).unref(); + // We need to let the event loop tick a few times to give the main thread a chance to send + // follow-up messages. + const response = receiveMessageOnPort(syncCommPort); + + if (response !== undefined) { + PromisePrototypeThen(handleMessage(response.message), undefined, errorHandler); + } + } + + const unsettledResponsePorts = new SafeSet(); + + process.on('beforeExit', () => { + for (const port of unsettledResponsePorts) { + port.postMessage(wrapMessage('never-settle')); + } + unsettledResponsePorts.clear(); + + AtomicsAdd(lock, WORKER_TO_MAIN_THREAD_NOTIFICATION, 1); + AtomicsNotify(lock, WORKER_TO_MAIN_THREAD_NOTIFICATION); + + // Attach back the event handler. + syncCommPort.on('message', handleMessage); + // Also check synchronously for a message, in case it's already there. + clearImmediate(immediate); + checkForMessages(); + // We don't need the sync check after this tick, as we already have added the event handler. + clearImmediate(immediate); + // Add some work for next tick so the worker cannot exit. + setImmediate(() => {}); + }); + + /** + * Handles incoming messages from the main thread or other workers. + * @param {object} options - The options object. + * @param {string} options.method - The name of the hook. + * @param {Array} options.args - The arguments to pass to the method. + * @param {MessagePort} options.port - The message port to use for communication. + */ + async function handleMessage({ method, args, port }) { + // Each potential exception needs to be caught individually so that the correct error is sent to + // the main thread. + let hasError = false; + let shouldRemoveGlobalErrorHandler = false; + assert(typeof hooks[method] === 'function'); + if (port == null && !hasUncaughtExceptionCaptureCallback()) { + // When receiving sync messages, we want to unlock the main thread when there's an exception. + process.on('uncaughtException', errorHandler); + shouldRemoveGlobalErrorHandler = true; + } + + // We are about to yield the execution with `await ReflectApply` below. In case the code + // following the `await` never runs, we remove the message handler so the `beforeExit` event + // can be triggered. + syncCommPort.off('message', handleMessage); + + // We keep checking for new messages to not miss any. + clearImmediate(immediate); + immediate = setImmediate(checkForMessages).unref(); + + unsettledResponsePorts.add(port ?? syncCommPort); + + let response; + try { + response = await ReflectApply(hooks[method], hooks, args); + } catch (exception) { + hasError = true; + response = exception; + } + + unsettledResponsePorts.delete(port ?? syncCommPort); + + // Send the method response (or exception) to the main thread. + try { + (port ?? syncCommPort).postMessage( + wrapMessage(hasError ? 'error' : 'success', response), + transferArrayBuffer(hasError, response?.source), + ); + } catch (exception) { + // Or send the exception thrown when trying to send the response. + (port ?? syncCommPort).postMessage(wrapMessage('error', exception)); + } + + AtomicsAdd(lock, WORKER_TO_MAIN_THREAD_NOTIFICATION, 1); + AtomicsNotify(lock, WORKER_TO_MAIN_THREAD_NOTIFICATION); + if (shouldRemoveGlobalErrorHandler) { + process.off('uncaughtException', errorHandler); + } + + syncCommPort.off('message', handleMessage); + // We keep checking for new messages to not miss any. + clearImmediate(immediate); + immediate = setImmediate(checkForMessages).unref(); + } +} + +/** + * Initializes a worker thread for a module with customized hooks. + * ! Run everything possible within this function so errors get reported. + * @param {{lock: SharedArrayBuffer}} workerData - The lock used to synchronize with the main thread. + * @param {MessagePort} syncCommPort - The communication port used to communicate with the main thread. + */ +module.exports = function setupModuleWorker(workerData, syncCommPort) { + const lock = new Int32Array(workerData.lock); + + /** + * Handles errors that occur in the worker thread. + * @param {Error} err - The error that occurred. + * @param {string} [origin='unhandledRejection'] - The origin of the error. + */ + function errorHandler(err, origin = 'unhandledRejection') { + AtomicsAdd(lock, WORKER_TO_MAIN_THREAD_NOTIFICATION, 1); + AtomicsNotify(lock, WORKER_TO_MAIN_THREAD_NOTIFICATION); + process.off('uncaughtException', errorHandler); + if (hasUncaughtExceptionCaptureCallback()) { + process._fatalException(err); + return; + } + internalBinding('errors').triggerUncaughtException( + err, + origin === 'unhandledRejection', + ); + } + + return PromisePrototypeThen( + customizedModuleWorker(lock, syncCommPort, errorHandler), + undefined, + errorHandler, + ); +}; diff --git a/lib/internal/modules/helpers.js b/lib/internal/modules/helpers.js index 307a34cb09b512..7f2959cc469dc1 100644 --- a/lib/internal/modules/helpers.js +++ b/lib/internal/modules/helpers.js @@ -21,6 +21,8 @@ const { const { BuiltinModule } = require('internal/bootstrap/realm'); const { validateString } = require('internal/validators'); +const fs = require('fs'); // Import all of `fs` so that it can be monkey-patched. +const internalFS = require('internal/fs/utils'); const path = require('path'); const { pathToFileURL, fileURLToPath, URL } = require('internal/url'); @@ -37,7 +39,30 @@ let debug = require('internal/util/debuglog').debuglog('module', (fn) => { debug = fn; }); +/** @typedef {import('internal/modules/cjs/loader.js').Module} Module */ + +/** + * Cache for storing resolved real paths of modules. + * In order to minimize unnecessary lstat() calls, this cache is a list of known-real paths. + * Set to an empty Map to reset. + * @type {Map} + */ +const realpathCache = new SafeMap(); +/** + * Resolves the path of a given `require` specifier, following symlinks. + * @param {string} requestPath The `require` specifier + */ +function toRealPath(requestPath) { + return fs.realpathSync(requestPath, { + [internalFS.realpathCacheKey]: realpathCache, + }); +} + +/** @type {Set} */ let cjsConditions; +/** + * Define the conditions that apply to the CommonJS loader. + */ function initializeCjsConditions() { const userConditions = getOptionValue('--conditions'); const noAddons = getOptionValue('--no-addons'); @@ -51,6 +76,9 @@ function initializeCjsConditions() { ]); } +/** + * Get the conditions that apply to the CommonJS loader. + */ function getCjsConditions() { if (cjsConditions === undefined) { initializeCjsConditions(); @@ -58,27 +86,45 @@ function getCjsConditions() { return cjsConditions; } -function loadBuiltinModule(filename, request) { - if (!BuiltinModule.canBeRequiredByUsers(filename)) { +/** + * Provide one of Node.js' public modules to user code. + * @param {string} id - The identifier/specifier of the builtin module to load + * @param {string} request - The module requiring or importing the builtin module + */ +function loadBuiltinModule(id, request) { + if (!BuiltinModule.canBeRequiredByUsers(id)) { return; } - const mod = BuiltinModule.map.get(filename); + /** @type {import('internal/bootstrap/realm.js').BuiltinModule} */ + const mod = BuiltinModule.map.get(id); debug('load built-in module %s', request); // compileForPublicLoader() throws if canBeRequiredByUsers is false: mod.compileForPublicLoader(); return mod; } +/** @type {Module} */ let $Module = null; +/** + * Import the Module class on first use. + */ function lazyModule() { $Module = $Module || require('internal/modules/cjs/loader').Module; return $Module; } -// Invoke with makeRequireFunction(module) where |module| is the Module object -// to use as the context for the require() function. -// Use redirects to set up a mapping from a policy and restrict dependencies +/** + * Invoke with `makeRequireFunction(module)` where `module` is the `Module` object to use as the context for the + * `require()` function. + * Use redirects to set up a mapping from a policy and restrict dependencies. + */ const urlToFileCache = new SafeMap(); +/** + * Create the module-scoped `require` function to pass into CommonJS modules. + * @param {Module} mod - The module to create the `require` function for. + * @param {ReturnType} redirects + * @typedef {(specifier: string) => unknown} RequireFunction + */ function makeRequireFunction(mod, redirects) { // lazy due to cycle const Module = lazyModule(); @@ -86,6 +132,7 @@ function makeRequireFunction(mod, redirects) { throw new ERR_INVALID_ARG_TYPE('mod', 'Module', mod); } + /** @type {RequireFunction} */ let require; if (redirects) { const id = mod.filename || mod.id; @@ -131,6 +178,11 @@ function makeRequireFunction(mod, redirects) { }; } + /** + * The `resolve` method that gets attached to module-scope `require`. + * @param {string} request + * @param {Parameters[3]} options + */ function resolve(request, options) { validateString(request, 'request'); return Module._resolveFilename(request, mod, false, options); @@ -138,6 +190,10 @@ function makeRequireFunction(mod, redirects) { require.resolve = resolve; + /** + * The `paths` method that gets attached to module-scope `require`. + * @param {string} request + */ function paths(request) { validateString(request, 'request'); return Module._resolveLookupPaths(request, mod); @@ -159,6 +215,7 @@ function makeRequireFunction(mod, redirects) { * Remove byte order marker. This catches EF BB BF (the UTF-8 BOM) * because the buffer-to-string conversion in `fs.readFileSync()` * translates it to FEFF, the UTF-16 BOM. + * @param {string} content */ function stripBOM(content) { if (StringPrototypeCharCodeAt(content) === 0xFEFF) { @@ -167,6 +224,11 @@ function stripBOM(content) { return content; } +/** + * Add built-in modules to a global or REPL scope object. + * @param {Record} object - The object such as `globalThis` to add the built-in modules to. + * @param {string} dummyModuleName - The label representing the set of built-in modules to add. + */ function addBuiltinLibsToObject(object, dummyModuleName) { // Make built-in modules available directly (loaded lazily). const Module = require('internal/modules/cjs/loader').Module; @@ -227,9 +289,8 @@ function addBuiltinLibsToObject(object, dummyModuleName) { } /** - * + * If a referrer is an URL instance or absolute path, convert it into an URL string. * @param {string | URL} referrer - * @returns {string} */ function normalizeReferrerURL(referrer) { if (typeof referrer === 'string' && path.isAbsolute(referrer)) { @@ -238,7 +299,10 @@ function normalizeReferrerURL(referrer) { return new URL(referrer).href; } -// For error messages only - used to check if ESM syntax is in use. +/** + * For error messages only, check if ESM syntax is in use. + * @param {string} code + */ function hasEsmSyntax(code) { debug('Checking for ESM syntax'); const parser = require('internal/deps/acorn/acorn/dist/acorn').Parser; @@ -265,4 +329,5 @@ module.exports = { makeRequireFunction, normalizeReferrerURL, stripBOM, + toRealPath, }; diff --git a/lib/internal/modules/package_json_reader.js b/lib/internal/modules/package_json_reader.js index bb175d0df54c04..1968960576013c 100644 --- a/lib/internal/modules/package_json_reader.js +++ b/lib/internal/modules/package_json_reader.js @@ -1,30 +1,124 @@ 'use strict'; -const { SafeMap } = primordials; +const { + JSONParse, + ObjectPrototypeHasOwnProperty, + SafeMap, + StringPrototypeEndsWith, + StringPrototypeIndexOf, + StringPrototypeLastIndexOf, + StringPrototypeSlice, +} = primordials; +const { + ERR_INVALID_PACKAGE_CONFIG, +} = require('internal/errors').codes; const { internalModuleReadJSON } = internalBinding('fs'); -const { pathToFileURL } = require('url'); -const { toNamespacedPath } = require('path'); +const { resolve, sep, toNamespacedPath } = require('path'); +const { kEmptyObject } = require('internal/util'); + +const { fileURLToPath, pathToFileURL } = require('internal/url'); const cache = new SafeMap(); +const isAIX = process.platform === 'aix'; let manifest; /** - * + * @typedef {{ + * exists: boolean, + * pjsonPath: string, + * exports?: string | string[] | Record, + * imports?: string | string[] | Record, + * name?: string, + * main?: string, + * type: 'commonjs' | 'module' | 'none', + * }} PackageConfig + */ + +/** * @param {string} jsonPath + * @param {{ + * base?: string, + * specifier: string, + * isESM: boolean, + * }} options + * @returns {PackageConfig} */ -function read(jsonPath) { +function read(jsonPath, { base, specifier, isESM } = kEmptyObject) { if (cache.has(jsonPath)) { return cache.get(jsonPath); } - const { 0: string, 1: containsKeys } = internalModuleReadJSON( + const { + 0: string, + 1: containsKeys, + } = internalModuleReadJSON( toNamespacedPath(jsonPath), ); - const result = { string, containsKeys }; - const { getOptionValue } = require('internal/options'); - if (string !== undefined) { + const result = { + __proto__: null, + exists: false, + pjsonPath: jsonPath, + main: undefined, + name: undefined, + type: 'none', // Ignore unknown types for forwards compatibility + exports: undefined, + imports: undefined, + }; + + // Folder read operation succeeds in AIX. + // For libuv change, see https://github.com/libuv/libuv/pull/2025. + // https://github.com/nodejs/node/pull/48477#issuecomment-1604586650 + // TODO(anonrig): Follow-up on this change and remove it since it is a + // semver-major change. + const isResultValid = isAIX && !isESM ? containsKeys : string !== undefined; + + if (isResultValid) { + let parsed; + try { + parsed = JSONParse(string); + } catch (error) { + if (isESM) { + throw new ERR_INVALID_PACKAGE_CONFIG( + jsonPath, + (base ? `"${specifier}" from ` : '') + fileURLToPath(base || specifier), + error.message, + ); + } else { + // For backward compat, we modify the error returned by JSON.parse rather than creating a new one. + // TODO(aduh95): make it throw ERR_INVALID_PACKAGE_CONFIG in a semver-major with original error as cause + error.message = 'Error parsing ' + jsonPath + ': ' + error.message; + error.path = jsonPath; + throw error; + } + } + + result.exists = true; + + // ObjectPrototypeHasOwnProperty is used to avoid prototype pollution. + if (ObjectPrototypeHasOwnProperty(parsed, 'name') && typeof parsed.name === 'string') { + result.name = parsed.name; + } + + if (ObjectPrototypeHasOwnProperty(parsed, 'main') && typeof parsed.main === 'string') { + result.main = parsed.main; + } + + if (ObjectPrototypeHasOwnProperty(parsed, 'exports')) { + result.exports = parsed.exports; + } + + if (ObjectPrototypeHasOwnProperty(parsed, 'imports')) { + result.imports = parsed.imports; + } + + // Ignore unknown types for forwards compatibility + if (ObjectPrototypeHasOwnProperty(parsed, 'type') && (parsed.type === 'commonjs' || parsed.type === 'module')) { + result.type = parsed.type; + } + if (manifest === undefined) { + const { getOptionValue } = require('internal/options'); manifest = getOptionValue('--experimental-policy') ? require('internal/process/policy').manifest : null; @@ -38,4 +132,41 @@ function read(jsonPath) { return result; } -module.exports = { read }; +/** + * @param {string} requestPath + * @return {PackageConfig} + */ +function readPackage(requestPath) { + return read(resolve(requestPath, 'package.json')); +} + +/** + * Get the nearest parent package.json file from a given path. + * Return the package.json data and the path to the package.json file, or false. + * @param {string} checkPath The path to start searching from. + */ +function readPackageScope(checkPath) { + const rootSeparatorIndex = StringPrototypeIndexOf(checkPath, sep); + let separatorIndex; + do { + separatorIndex = StringPrototypeLastIndexOf(checkPath, sep); + checkPath = StringPrototypeSlice(checkPath, 0, separatorIndex); + if (StringPrototypeEndsWith(checkPath, sep + 'node_modules')) { + return false; + } + const pjson = readPackage(checkPath + sep); + if (pjson.exists) { + return { + data: pjson, + path: checkPath, + }; + } + } while (separatorIndex > rootSeparatorIndex); + return false; +} + +module.exports = { + read, + readPackage, + readPackageScope, +}; diff --git a/lib/internal/modules/run_main.js b/lib/internal/modules/run_main.js index 42a0b6af0626ec..287f3ed91b1d7b 100644 --- a/lib/internal/modules/run_main.js +++ b/lib/internal/modules/run_main.js @@ -1,30 +1,56 @@ 'use strict'; const { - ObjectCreate, StringPrototypeEndsWith, } = primordials; const { getOptionValue } = require('internal/options'); const path = require('path'); +/** + * Get the absolute path to the main entry point. + * @param {string} main - Entry point path + */ function resolveMainPath(main) { - // Note extension resolution for the main entry point can be deprecated in a - // future major. - // Module._findPath is monkey-patchable here. - const { Module, toRealPath } = require('internal/modules/cjs/loader'); - let mainPath = Module._findPath(path.resolve(main), null, true); - if (!mainPath) - return; + const defaultType = getOptionValue('--experimental-default-type'); + /** @type {string} */ + let mainPath; + if (defaultType === 'module') { + if (getOptionValue('--preserve-symlinks-main')) { return; } + mainPath = path.resolve(main); + } else { + // Extension searching for the main entry point is supported only in legacy mode. + // Module._findPath is monkey-patchable here. + const { Module } = require('internal/modules/cjs/loader'); + mainPath = Module._findPath(path.resolve(main), null, true); + } + if (!mainPath) { return; } const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main'); - if (!preserveSymlinksMain) - mainPath = toRealPath(mainPath); + if (!preserveSymlinksMain) { + const { toRealPath } = require('internal/modules/helpers'); + try { + mainPath = toRealPath(mainPath); + } catch (err) { + if (defaultType === 'module' && err?.code === 'ENOENT') { + const { decorateErrorWithCommonJSHints } = require('internal/modules/esm/resolve'); + const { getCWDURL } = require('internal/util'); + decorateErrorWithCommonJSHints(err, mainPath, getCWDURL()); + } + throw err; + } + } return mainPath; } +/** + * Determine whether the main entry point should be loaded through the ESM Loader. + * @param {string} mainPath - Absolute path to the main entry point + */ function shouldUseESMLoader(mainPath) { + if (getOptionValue('--experimental-default-type') === 'module') { return true; } + /** * @type {string[]} userLoaders A list of custom loaders registered by the user * (or an empty list when none have been registered). @@ -35,33 +61,42 @@ function shouldUseESMLoader(mainPath) { * (or an empty list when none have been registered). */ const userImports = getOptionValue('--import'); - if (userLoaders.length > 0 || userImports.length > 0) + if (userLoaders.length > 0 || userImports.length > 0) { return true; + } const esModuleSpecifierResolution = getOptionValue('--experimental-specifier-resolution'); - if (esModuleSpecifierResolution === 'node') - return true; - const { readPackageScope } = require('internal/modules/cjs/loader'); - // Determine the module format of the main - if (mainPath && StringPrototypeEndsWith(mainPath, '.mjs')) + if (esModuleSpecifierResolution === 'node') { return true; - if (!mainPath || StringPrototypeEndsWith(mainPath, '.cjs')) - return false; + } + // Determine the module format of the entry point. + if (mainPath && StringPrototypeEndsWith(mainPath, '.mjs')) { return true; } + if (!mainPath || StringPrototypeEndsWith(mainPath, '.cjs')) { return false; } + + const { readPackageScope } = require('internal/modules/package_json_reader'); const pkg = readPackageScope(mainPath); - return pkg && pkg.data.type === 'module'; + // No need to guard `pkg` as it can only be an object or `false`. + return pkg.data?.type === 'module' || getOptionValue('--experimental-default-type') === 'module'; } +/** + * Run the main entry point through the ESM Loader. + * @param {string} mainPath - Absolute path for the main entry point + */ function runMainESM(mainPath) { const { loadESM } = require('internal/process/esm_loader'); const { pathToFileURL } = require('internal/url'); + const main = pathToFileURL(mainPath).href; handleMainPromise(loadESM((esmLoader) => { - const main = path.isAbsolute(mainPath) ? - pathToFileURL(mainPath).href : mainPath; - return esmLoader.import(main, undefined, ObjectCreate(null)); + return esmLoader.import(main, undefined, { __proto__: null }); })); } +/** + * Handle process exit events around the main entry point promise. + * @param {Promise} promise - Main entry point promise + */ async function handleMainPromise(promise) { const { handleProcessExit, @@ -74,9 +109,14 @@ async function handleMainPromise(promise) { } } -// For backwards compatibility, we have to run a bunch of -// monkey-patchable code that belongs to the CJS loader (exposed by -// `require('module')`) even when the entry point is ESM. +/** + * Parse the CLI main entry point string and run it. + * For backwards compatibility, we have to run a bunch of monkey-patchable code that belongs to the CJS loader (exposed + * by `require('module')`) even when the entry point is ESM. + * This monkey-patchable code is bypassed under `--experimental-default-type=module`. + * Because of backwards compatibility, this function is exposed publicly via `import { runMain } from 'node:module'`. + * @param {string} main - First positional CLI argument, such as `'entry.js'` from `node entry.js` + */ function executeUserEntryPoint(main = process.argv[1]) { const resolvedMain = resolveMainPath(main); const useESMLoader = shouldUseESMLoader(resolvedMain); diff --git a/lib/internal/process/esm_loader.js b/lib/internal/process/esm_loader.js index 9a04e094e001c4..a3451ddab307f2 100644 --- a/lib/internal/process/esm_loader.js +++ b/lib/internal/process/esm_loader.js @@ -1,105 +1,46 @@ 'use strict'; const { - ArrayIsArray, - ArrayPrototypePushApply, + SafePromiseAllReturnVoid, } = primordials; -const { ESMLoader } = require('internal/modules/esm/loader'); +const { createModuleLoader } = require('internal/modules/esm/loader'); +const { getOptionValue } = require('internal/options'); const { hasUncaughtExceptionCaptureCallback, } = require('internal/process/execution'); -const { pathToFileURL } = require('internal/url'); -const { kEmptyObject } = require('internal/util'); - -const esmLoader = new ESMLoader(); -exports.esmLoader = esmLoader; - -// Module.runMain() causes loadESM() to re-run (which it should do); however, this should NOT cause -// ESM to be re-initialized; doing so causes duplicate custom loaders to be added to the public -// esmLoader. -let isESMInitialized = false; - -/** - * Causes side-effects: user-defined loader hooks are added to esmLoader. - * @returns {void} - */ -async function initializeLoader() { - if (isESMInitialized) { return; } - - const { getOptionValue } = require('internal/options'); - const customLoaders = getOptionValue('--experimental-loader'); - const preloadModules = getOptionValue('--import'); - - let cwd; - try { - cwd = process.cwd() + '/'; - } catch { - cwd = '/'; - } - - const internalEsmLoader = new ESMLoader(); - const allLoaders = []; - - const parentURL = pathToFileURL(cwd).href; - - for (let i = 0; i < customLoaders.length; i++) { - const customLoader = customLoaders[i]; - - // Importation must be handled by internal loader to avoid polluting user-land - const keyedExportsSublist = await internalEsmLoader.import( - [customLoader], - parentURL, - kEmptyObject, - ); - - internalEsmLoader.addCustomLoaders(keyedExportsSublist); - ArrayPrototypePushApply(allLoaders, keyedExportsSublist); - } - - // Hooks must then be added to external/public loader - // (so they're triggered in userland) - esmLoader.addCustomLoaders(allLoaders); - esmLoader.preload(); - - // Preload after loaders are added so they can be used - if (preloadModules?.length) { - await loadModulesInIsolation(parentURL, preloadModules, allLoaders); - } - - isESMInitialized = true; -} - -function loadModulesInIsolation(parentURL, specifiers, loaders = []) { - if (!ArrayIsArray(specifiers) || specifiers.length === 0) { return; } - - // A separate loader instance is necessary to avoid cross-contamination - // between internal Node.js and userland. For example, a module with internal - // state (such as a counter) should be independent. - const internalEsmLoader = new ESMLoader(); - internalEsmLoader.addCustomLoaders(loaders); - internalEsmLoader.preload(); - - // Importation must be handled by internal loader to avoid polluting userland - return internalEsmLoader.import( - specifiers, - parentURL, - kEmptyObject, - ); -} - -exports.loadESM = async function loadESM(callback) { - try { - await initializeLoader(); - await callback(esmLoader); - } catch (err) { - if (hasUncaughtExceptionCaptureCallback()) { - process._fatalException(err); - return; +const { kEmptyObject, getCWDURL } = require('internal/util'); + +let esmLoader; + +module.exports = { + get esmLoader() { + return esmLoader ??= createModuleLoader(true); + }, + async loadESM(callback) { + esmLoader ??= createModuleLoader(true); + try { + const userImports = getOptionValue('--import'); + if (userImports.length > 0) { + const parentURL = getCWDURL().href; + await SafePromiseAllReturnVoid(userImports, (specifier) => esmLoader.import( + specifier, + parentURL, + kEmptyObject, + )); + } else { + esmLoader.forceLoadHooks(); + } + await callback(esmLoader); + } catch (err) { + if (hasUncaughtExceptionCaptureCallback()) { + process._fatalException(err); + return; + } + internalBinding('errors').triggerUncaughtException( + err, + true, /* fromPromise */ + ); } - internalBinding('errors').triggerUncaughtException( - err, - true, /* fromPromise */ - ); - } + }, }; diff --git a/lib/internal/process/execution.js b/lib/internal/process/execution.js index 7379db357bdcc4..c3a63f63d1d613 100644 --- a/lib/internal/process/execution.js +++ b/lib/internal/process/execution.js @@ -83,9 +83,9 @@ function evalScript(name, body, breakFirstLine, print, shouldLoadESM = false) { filename: name, displayErrors: true, [kVmBreakFirstLineSymbol]: !!breakFirstLine, - importModuleDynamically(specifier, _, importAssertions) { + importModuleDynamically(specifier, _, importAttributes) { const loader = asyncESM.esmLoader; - return loader.import(specifier, baseUrl, importAssertions); + return loader.import(specifier, baseUrl, importAttributes); }, })); if (print) { diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js index d948b2b933eba2..f8f34f25f0c163 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -37,7 +37,7 @@ const { } = require('internal/v8/startup_snapshot'); function prepareMainThreadExecution(expandArgv1 = false, initializeModules = true) { - prepareExecution({ + return prepareExecution({ expandArgv1, initializeModules, isMainThread: true, @@ -58,8 +58,8 @@ function prepareExecution(options) { refreshRuntimeOptions(); reconnectZeroFillToggle(); - // Patch the process object with legacy properties and normalizations - patchProcessObject(expandArgv1); + // Patch the process object and get the resolved main entry point. + const mainEntry = patchProcessObject(expandArgv1); setupTraceCategoryState(); setupPerfHooks(); setupInspectorHooks(); @@ -113,6 +113,8 @@ function prepareExecution(options) { if (initializeModules) { setupUserModules(); } + + return mainEntry; } function setupSymbolDisposePolyfill() { @@ -123,11 +125,15 @@ function setupSymbolDisposePolyfill() { Symbol.asyncDispose ??= SymbolAsyncDispose; } -function setupUserModules() { +function setupUserModules(isLoaderWorker = false) { initializeCJSLoader(); - initializeESMLoader(); + initializeESMLoader(isLoaderWorker); const CJSLoader = require('internal/modules/cjs/loader'); assert(!CJSLoader.hasLoadedAnyUserCJSModule); + // Loader workers are responsible for doing this themselves. + if (isLoaderWorker) { + return; + } loadPreloadModules(); // Need to be done after --require setup. initializeFrozenIntrinsics(); @@ -137,12 +143,20 @@ function refreshRuntimeOptions() { refreshOptions(); } +/** + * Patch the process object with legacy properties and normalizations. + * Replace `process.argv[0]` with `process.execPath`, preserving the original `argv[0]` value as `process.argv0`. + * Replace `process.argv[1]` with the resolved absolute file path of the entry point, if found. + * @param {boolean} expandArgv1 - Whether to replace `process.argv[1]` with the resolved absolute file path of + * the main entry point. + */ function patchProcessObject(expandArgv1) { const binding = internalBinding('process_methods'); binding.patchProcessObject(process); require('internal/process/per_thread').refreshHrtimeBuffer(); + // Since we replace process.argv[0] below, preserve the original value in case the user needs it. ObjectDefineProperty(process, 'argv0', { __proto__: null, enumerable: true, @@ -155,12 +169,17 @@ function patchProcessObject(expandArgv1) { process._exiting = false; process.argv[0] = process.execPath; + /** @type {string} */ + let mainEntry; + // If requested, update process.argv[1] to replace whatever the user provided with the resolved absolute file path of + // the entry point. if (expandArgv1 && process.argv[1] && !StringPrototypeStartsWith(process.argv[1], '-')) { // Expand process.argv[1] into a full path. const path = require('path'); try { - process.argv[1] = path.resolve(process.argv[1]); + mainEntry = path.resolve(process.argv[1]); + process.argv[1] = mainEntry; } catch { // Continue regardless of error. } @@ -187,6 +206,8 @@ function patchProcessObject(expandArgv1) { addReadOnlyProcessAlias('traceDeprecation', '--trace-deprecation'); addReadOnlyProcessAlias('_breakFirstLine', '--inspect-brk', false); addReadOnlyProcessAlias('_breakNodeFirstLine', '--inspect-brk-node', false); + + return mainEntry; } function addReadOnlyProcessAlias(name, option, enumerable = true) { @@ -546,9 +567,9 @@ function initializeCJSLoader() { initializeCJS(); } -function initializeESMLoader() { +function initializeESMLoader(isLoaderWorker) { const { initializeESM } = require('internal/modules/esm/utils'); - initializeESM(); + initializeESM(isLoaderWorker); // Patch the vm module when --experimental-vm-modules is on. // Please update the comments in vm.js when this block changes. @@ -600,4 +621,6 @@ module.exports = { prepareMainThreadExecution, prepareWorkerThreadExecution, markBootstrapComplete, + loadPreloadModules, + initializeFrozenIntrinsics, }; diff --git a/lib/internal/url.js b/lib/internal/url.js index 9e26e42b27a229..8e4a5e97b5d531 100644 --- a/lib/internal/url.js +++ b/lib/internal/url.js @@ -22,6 +22,7 @@ const { ReflectOwnKeys, RegExpPrototypeSymbolReplace, SafeMap, + SafeSet, SafeWeakMap, StringPrototypeCharAt, StringPrototypeCharCodeAt, @@ -106,6 +107,40 @@ const searchParams = Symbol('query'); */ const internalSearchParams = new SafeWeakMap(); +// `unsafeProtocol`, `hostlessProtocol` and `slashedProtocol` is +// deliberately moved to `internal/url` rather than `url`. +// Workers does not bootstrap URL module. Therefore, `SafeSet` +// is not initialized on bootstrap. This case breaks the +// test-require-delete-array-iterator test. + +// Protocols that can allow "unsafe" and "unwise" chars. +const unsafeProtocol = new SafeSet([ + 'javascript', + 'javascript:', +]); +// Protocols that never have a hostname. +const hostlessProtocol = new SafeSet([ + 'javascript', + 'javascript:', +]); +// Protocols that always contain a // bit. +const slashedProtocol = new SafeSet([ + 'http', + 'http:', + 'https', + 'https:', + 'ftp', + 'ftp:', + 'gopher', + 'gopher:', + 'file', + 'file:', + 'ws', + 'ws:', + 'wss', + 'wss:', +]); + const updateActions = { kProtocol: 0, kHost: 1, @@ -1534,4 +1569,7 @@ module.exports = { isURL, urlUpdateActions: updateActions, + unsafeProtocol, + hostlessProtocol, + slashedProtocol, }; diff --git a/lib/internal/util.js b/lib/internal/util.js index d9c292c99a6710..aedfa2e98896e7 100644 --- a/lib/internal/util.js +++ b/lib/internal/util.js @@ -357,6 +357,36 @@ function getConstructorOf(obj) { return null; } +let cachedURL; +let cachedCWD; + +/** + * Get the current working directory while accounting for the possibility that it has been deleted. + * `process.cwd()` can fail if the parent directory is deleted while the process runs. + * @returns {URL} The current working directory or the volume root if it cannot be determined. + */ +function getCWDURL() { + const { sep } = require('path'); + const { pathToFileURL } = require('internal/url'); + + let cwd; + + try { + // The implementation of `process.cwd()` already uses proper cache when it can. + // It's a relatively cheap call performance-wise for the most common use case. + cwd = process.cwd(); + } catch { + cachedURL ??= pathToFileURL(sep); + } + + if (cwd != null && cwd !== cachedCWD) { + cachedURL = pathToFileURL(cwd + sep); + cachedCWD = cwd; + } + + return cachedURL; +} + function getSystemErrorName(err) { const entry = uvErrmapGet(err); return entry ? entry[0] : `Unknown system error ${err}`; @@ -784,6 +814,7 @@ module.exports = { filterDuplicateStrings, filterOwnProperties, getConstructorOf, + getCWDURL, getInternalGlobal, getSystemErrorMap, getSystemErrorName, diff --git a/lib/internal/vm/module.js b/lib/internal/vm/module.js index ec9618139b5dc2..19d93e1abfbd42 100644 --- a/lib/internal/vm/module.js +++ b/lib/internal/vm/module.js @@ -302,8 +302,8 @@ class SourceTextModule extends Module { this[kLink] = async (linker) => { this.#statusOverride = 'linking'; - const promises = this[kWrap].link(async (identifier, assert) => { - const module = await linker(identifier, this, { assert }); + const promises = this[kWrap].link(async (identifier, attributes) => { + const module = await linker(identifier, this, { attributes, assert: attributes }); if (module[kWrap] === undefined) { throw new ERR_VM_MODULE_NOT_MODULE(); } diff --git a/lib/internal/worker.js b/lib/internal/worker.js index 3d828d2f6f2b19..98067387249627 100644 --- a/lib/internal/worker.js +++ b/lib/internal/worker.js @@ -44,6 +44,7 @@ const { getOptionValue } = require('internal/options'); const workerIo = require('internal/worker/io'); const { drainMessagePort, + receiveMessageOnPort, MessageChannel, messageTypes, kPort, @@ -81,6 +82,7 @@ const kOnCouldNotSerializeErr = Symbol('kOnCouldNotSerializeErr'); const kOnErrorMessage = Symbol('kOnErrorMessage'); const kParentSideStdio = Symbol('kParentSideStdio'); const kLoopStartTime = Symbol('kLoopStartTime'); +const kIsInternal = Symbol('kIsInternal'); const kIsOnline = Symbol('kIsOnline'); const SHARE_ENV = SymbolFor('nodejs.worker_threads.SHARE_ENV'); @@ -124,7 +126,13 @@ function assignEnvironmentData(data) { class Worker extends EventEmitter { constructor(filename, options = kEmptyObject) { super(); - debug(`[${threadId}] create new worker`, filename, options); + const isInternal = arguments[2] === kIsInternal; + debug( + `[${threadId}] create new worker`, + filename, + options, + `isInternal: ${isInternal}`, + ); if (options.execArgv) validateArray(options.execArgv, 'options.execArgv'); @@ -135,7 +143,10 @@ class Worker extends EventEmitter { } let url, doEval; - if (options.eval) { + if (isInternal) { + doEval = 'internal'; + url = `node:${filename}`; + } else if (options.eval) { if (typeof filename !== 'string') { throw new ERR_INVALID_ARG_VALUE( 'options.eval', @@ -191,12 +202,14 @@ class Worker extends EventEmitter { name = StringPrototypeTrim(options.name); } + debug('instantiating Worker.', `url: ${url}`, `doEval: ${doEval}`); // Set up the C++ handle for the worker, as well as some internal wiring. this[kHandle] = new WorkerImpl(url, env === process.env ? null : env, options.execArgv, parseResourceLimits(options.resourceLimits), !!(options.trackUnmanagedFds ?? true), + isInternal, name); if (this[kHandle].invalidExecArgv) { throw new ERR_WORKER_INVALID_EXEC_ARGV(this[kHandle].invalidExecArgv); @@ -248,6 +261,7 @@ class Worker extends EventEmitter { type: messageTypes.LOAD_SCRIPT, filename, doEval, + isInternal, cwdCounter: cwdCounter || workerIo.sharedCwdCounter, workerData: options.workerData, environmentData, @@ -428,6 +442,20 @@ class Worker extends EventEmitter { } } +/** + * A worker which has an internal module for entry point (e.g. internal/module/esm/worker). + * Internal workers bypass the permission model. + */ +class InternalWorker extends Worker { + constructor(filename, options) { + super(filename, options, kIsInternal); + } + + receiveMessageSync() { + return receiveMessageOnPort(this[kPublicPort]); + } +} + function pipeWithoutWarning(source, dest) { const sourceMaxListeners = source._maxListeners; const destMaxListeners = dest._maxListeners; @@ -508,6 +536,7 @@ function eventLoopUtilization(util1, util2) { module.exports = { ownsProcessState, + kIsOnline, isMainThread, SHARE_ENV, resourceLimits: @@ -516,5 +545,6 @@ module.exports = { getEnvironmentData, assignEnvironmentData, threadId, + InternalWorker, Worker, }; diff --git a/lib/module.js b/lib/module.js index b4a6dd7d18de56..ee90e92f53093c 100644 --- a/lib/module.js +++ b/lib/module.js @@ -2,8 +2,10 @@ const { findSourceMap } = require('internal/source_map/source_map_cache'); const { Module } = require('internal/modules/cjs/loader'); +const { register } = require('internal/modules/esm/loader'); const { SourceMap } = require('internal/source_map/source_map'); Module.findSourceMap = findSourceMap; +Module.register = register; Module.SourceMap = SourceMap; module.exports = Module; diff --git a/lib/repl.js b/lib/repl.js index aae269712f3d3f..b2d143619ae093 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -483,9 +483,9 @@ function REPLServer(prompt, vm.createScript(fallbackCode, { filename: file, displayErrors: true, - importModuleDynamically: (specifier, _, importAssertions) => { + importModuleDynamically: (specifier, _, importAttributes) => { return asyncESM.esmLoader.import(specifier, parentURL, - importAssertions); + importAttributes); }, }); } catch (fallbackError) { @@ -527,9 +527,9 @@ function REPLServer(prompt, script = vm.createScript(code, { filename: file, displayErrors: true, - importModuleDynamically: (specifier, _, importAssertions) => { + importModuleDynamically: (specifier, _, importAttributes) => { return asyncESM.esmLoader.import(specifier, parentURL, - importAssertions); + importAttributes); }, }); } catch (e) { diff --git a/lib/url.js b/lib/url.js index 2b32a7e8bc8843..c99bf384962f90 100644 --- a/lib/url.js +++ b/lib/url.js @@ -26,7 +26,6 @@ const { Int8Array, ObjectCreate, ObjectKeys, - SafeSet, StringPrototypeCharCodeAt, decodeURIComponent, } = primordials; @@ -57,6 +56,9 @@ const { fileURLToPath, pathToFileURL, urlToHttpOptions, + unsafeProtocol, + hostlessProtocol, + slashedProtocol, } = require('internal/url'); const bindingUrl = internalBinding('url'); @@ -92,33 +94,6 @@ const hostPattern = /^\/\/[^@/]+@[^@/]+/; const simplePathPattern = /^(\/\/?(?!\/)[^?\s]*)(\?[^\s]*)?$/; const hostnameMaxLen = 255; -// Protocols that can allow "unsafe" and "unwise" chars. -const unsafeProtocol = new SafeSet([ - 'javascript', - 'javascript:', -]); -// Protocols that never have a hostname. -const hostlessProtocol = new SafeSet([ - 'javascript', - 'javascript:', -]); -// Protocols that always contain a // bit. -const slashedProtocol = new SafeSet([ - 'http', - 'http:', - 'https', - 'https:', - 'ftp', - 'ftp:', - 'gopher', - 'gopher:', - 'file', - 'file:', - 'ws', - 'ws:', - 'wss', - 'wss:', -]); const { CHAR_SPACE, CHAR_TAB, diff --git a/pyproject.toml b/pyproject.toml index 6b51197ad66c2e..d0c3a056f2e92c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ select = [ exclude = [ "deps", "tools/inspector_protocol", + "tools/node_modules", ] ignore = [ "E401", diff --git a/src/module_wrap.cc b/src/module_wrap.cc index 9b2b0b8334d102..21b9a702aebaf4 100644 --- a/src/module_wrap.cc +++ b/src/module_wrap.cc @@ -250,19 +250,19 @@ void ModuleWrap::New(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(that); } -static Local createImportAssertionContainer(Environment* env, - Isolate* isolate, Local raw_assertions) { - Local assertions = - Object::New(isolate, v8::Null(env->isolate()), nullptr, nullptr, 0); - for (int i = 0; i < raw_assertions->Length(); i += 3) { - assertions - ->Set(env->context(), - raw_assertions->Get(env->context(), i).As(), - raw_assertions->Get(env->context(), i + 1).As()) - .ToChecked(); - } - - return assertions; +static Local createImportAttributesContainer( + Environment* env, Isolate* isolate, Local raw_attributes) { + Local attributes = + Object::New(isolate, v8::Null(env->isolate()), nullptr, nullptr, 0); + for (int i = 0; i < raw_attributes->Length(); i += 3) { + attributes + ->Set(env->context(), + raw_attributes->Get(env->context(), i).As(), + raw_attributes->Get(env->context(), i + 1).As()) + .ToChecked(); + } + + return attributes; } void ModuleWrap::Link(const FunctionCallbackInfo& args) { @@ -298,13 +298,13 @@ void ModuleWrap::Link(const FunctionCallbackInfo& args) { Utf8Value specifier_utf8(env->isolate(), specifier); std::string specifier_std(*specifier_utf8, specifier_utf8.length()); - Local raw_assertions = module_request->GetImportAssertions(); - Local assertions = - createImportAssertionContainer(env, isolate, raw_assertions); + Local raw_attributes = module_request->GetImportAssertions(); + Local attributes = + createImportAttributesContainer(env, isolate, raw_attributes); Local argv[] = { specifier, - assertions, + attributes, }; MaybeLocal maybe_resolve_return_value = @@ -500,7 +500,7 @@ void ModuleWrap::GetError(const FunctionCallbackInfo& args) { MaybeLocal ModuleWrap::ResolveModuleCallback( Local context, Local specifier, - Local import_assertions, + Local import_attributes, Local referrer) { Environment* env = Environment::GetCurrent(context); if (env == nullptr) { @@ -553,7 +553,7 @@ static MaybeLocal ImportModuleDynamically( Local host_defined_options, Local resource_name, Local specifier, - Local import_assertions) { + Local import_attributes) { Isolate* isolate = context->GetIsolate(); Environment* env = Environment::GetCurrent(context); if (env == nullptr) { @@ -602,13 +602,13 @@ static MaybeLocal ImportModuleDynamically( UNREACHABLE(); } - Local assertions = - createImportAssertionContainer(env, isolate, import_assertions); + Local attributes = + createImportAttributesContainer(env, isolate, import_attributes); Local import_args[] = { object, Local(specifier), - assertions, + attributes, }; Local result; diff --git a/src/module_wrap.h b/src/module_wrap.h index c609ba5509dcd0..ce4610a461a2b4 100644 --- a/src/module_wrap.h +++ b/src/module_wrap.h @@ -98,7 +98,7 @@ class ModuleWrap : public BaseObject { static v8::MaybeLocal ResolveModuleCallback( v8::Local context, v8::Local specifier, - v8::Local import_assertions, + v8::Local import_attributes, v8::Local referrer); static ModuleWrap* GetFromModule(node::Environment*, v8::Local); diff --git a/src/node_file.cc b/src/node_file.cc index 4dc4b91145d2b7..7f627ac458492c 100644 --- a/src/node_file.cc +++ b/src/node_file.cc @@ -1052,8 +1052,9 @@ static void InternalModuleReadJSON(const FunctionCallbackInfo& args) { if (offset >= 3 && 0 == memcmp(chars.data(), "\xEF\xBB\xBF", 3)) { start = 3; // Skip UTF-8 BOM. } - const size_t size = offset - start; + + // TODO(anonrig): Follow-up on removing the following changes for AIX. char* p = &chars[start]; char* pe = &chars[size]; char* pos[2]; @@ -1081,16 +1082,14 @@ static void InternalModuleReadJSON(const FunctionCallbackInfo& args) { } } - Local return_value[] = { - String::NewFromUtf8(isolate, - &chars[start], - v8::NewStringType::kNormal, - size).ToLocalChecked(), - Boolean::New(isolate, p < pe ? true : false) - }; + String::NewFromUtf8( + isolate, &chars[start], v8::NewStringType::kNormal, size) + .ToLocalChecked(), + Boolean::New(isolate, p < pe ? true : false)}; + args.GetReturnValue().Set( - Array::New(isolate, return_value, arraysize(return_value))); + Array::New(isolate, return_value, arraysize(return_value))); } // Used to speed up module loading. Returns 0 if the path refers to diff --git a/src/node_options.cc b/src/node_options.cc index 626f72a06022a8..3de52d9a2e28f0 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -114,12 +114,19 @@ void EnvironmentOptions::CheckOptions(std::vector* errors, errors->push_back("--policy-integrity cannot be empty"); } - if (!module_type.empty()) { - if (module_type != "commonjs" && module_type != "module") { + if (!input_type.empty()) { + if (input_type != "commonjs" && input_type != "module") { errors->push_back("--input-type must be \"module\" or \"commonjs\""); } } + if (!type.empty()) { + if (type != "commonjs" && type != "module") { + errors->push_back("--experimental-default-type must be " + "\"module\" or \"commonjs\""); + } + } + if (!experimental_specifier_resolution.empty()) { if (experimental_specifier_resolution != "node" && experimental_specifier_resolution != "explicit") { @@ -399,7 +406,7 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { &EnvironmentOptions::experimental_wasm_modules, kAllowedInEnvvar); AddOption("--experimental-import-meta-resolve", - "experimental ES Module import.meta.resolve() support", + "experimental ES Module import.meta.resolve() parentURL support", &EnvironmentOptions::experimental_import_meta_resolve, kAllowedInEnvvar); AddOption("--experimental-policy", @@ -451,7 +458,7 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { kAllowedInEnvvar); AddOption("--input-type", "set module type for string input", - &EnvironmentOptions::module_type, + &EnvironmentOptions::input_type, kAllowedInEnvvar); AddOption("--experimental-specifier-resolution", "Select extension resolution algorithm for es modules; " @@ -620,6 +627,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { "show stack traces on process warnings", &EnvironmentOptions::trace_warnings, kAllowedInEnvvar); + AddOption("--experimental-default-type", + "set module system to use by default", + &EnvironmentOptions::type, + kAllowedInEnvvar); AddOption("--extra-info-on-fatal-exception", "hide extra information on fatal exception that causes exit", &EnvironmentOptions::extra_info_on_fatal_exception, diff --git a/src/node_options.h b/src/node_options.h index 6f822061dc654d..9a05f80eeb5254 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -117,7 +117,8 @@ class EnvironmentOptions : public Options { std::string experimental_specifier_resolution; bool experimental_wasm_modules = false; bool experimental_import_meta_resolve = false; - std::string module_type; + std::string input_type; // Value of --input-type + std::string type; // Value of --experimental-default-type std::string experimental_policy; std::string experimental_policy_integrity; bool has_policy_integrity_string = false; diff --git a/src/node_worker.cc b/src/node_worker.cc index 6a49144ec4f205..eef7bc1737aff1 100644 --- a/src/node_worker.cc +++ b/src/node_worker.cc @@ -445,6 +445,8 @@ Worker::~Worker() { void Worker::New(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); + auto is_internal = args[5]; + CHECK(is_internal->IsBoolean()); Isolate* isolate = args.GetIsolate(); CHECK(args.IsConstructCall()); @@ -468,9 +470,9 @@ void Worker::New(const FunctionCallbackInfo& args) { url.append(value.out(), value.length()); } - if (!args[5]->IsNullOrUndefined()) { + if (!args[6]->IsNullOrUndefined()) { Utf8Value value( - isolate, args[5]->ToString(env->context()).FromMaybe(Local())); + isolate, args[6]->ToString(env->context()).FromMaybe(Local())); name.append(value.out(), value.length()); } diff --git a/test/common/README.md b/test/common/README.md index 86c6e4208a648d..35c6d835b3ddc8 100644 --- a/test/common/README.md +++ b/test/common/README.md @@ -1011,6 +1011,16 @@ The `tmpdir` module supports the use of a temporary directory for testing. The realpath of the testing temporary directory. +### `fileURL([...paths])` + +* `...paths` [\][] +* return [\][] + +Resolves a sequence of paths into absolute url in the temporary directory. + +When called without arguments, returns absolute url of the testing +temporary directory with explicit trailing `/`. + ### `refresh()` Deletes and recreates the testing temporary directory. @@ -1080,6 +1090,7 @@ See [the WPT tests README][] for details. []: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function []: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object []: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp +[]: https://developer.mozilla.org/en-US/docs/Web/API/URL []: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Data_types []: https://github.com/tc39/proposal-bigint []: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type diff --git a/test/common/assertSnapshot.js b/test/common/assertSnapshot.js index c403751ac3ef5e..33d6570efbc75e 100644 --- a/test/common/assertSnapshot.js +++ b/test/common/assertSnapshot.js @@ -5,9 +5,13 @@ const test = require('node:test'); const fs = require('node:fs/promises'); const assert = require('node:assert/strict'); -const stackFramesRegexp = /(\s+)((.+?)\s+\()?(?:\(?(.+?):(\d+)(?::(\d+))?)\)?(\s+\{)?(\[\d+m)?(\n|$)/g; +const stackFramesRegexp = /(?<=\n)(\s+)((.+?)\s+\()?(?:\(?(.+?):(\d+)(?::(\d+))?)\)?(\s+\{)?(\[\d+m)?(\n|$)/g; const windowNewlineRegexp = /\r/g; +function replaceNodeVersion(str) { + return str.replaceAll(process.version, '*'); +} + function replaceStackTrace(str, replacement = '$1*$7$8\n') { return str.replace(stackFramesRegexp, replacement); } @@ -17,7 +21,7 @@ function replaceWindowsLineEndings(str) { } function replaceWindowsPaths(str) { - return str.replaceAll(path.win32.sep, path.posix.sep); + return common.isWindows ? str.replaceAll(path.win32.sep, path.posix.sep) : str; } function transform(...args) { @@ -69,6 +73,7 @@ async function spawnAndAssert(filename, transform = (x) => x, { tty = false, ... module.exports = { assertSnapshot, getSnapshotPath, + replaceNodeVersion, replaceStackTrace, replaceWindowsLineEndings, replaceWindowsPaths, diff --git a/test/common/index.mjs b/test/common/index.mjs index 6b4cd00e410d78..ca2994f6e1360f 100644 --- a/test/common/index.mjs +++ b/test/common/index.mjs @@ -4,105 +4,105 @@ const require = createRequire(import.meta.url); const common = require('./index.js'); const { - isMainThread, - isWindows, + allowGlobals, + buildType, + canCreateSymLink, + checkoutEOL, + childShouldThrowAndAbort, + createZeroFilledFile, + enoughTestMem, + expectsError, + expectWarning, + getArrayBufferViews, + getBufferSources, + getCallSite, + getTTYfd, + hasCrypto, + hasIPv6, + hasMultiLocalhost, isAIX, - isIBMi, - isLinuxPPCBE, - isSunOS, + isAlive, isDumbTerminal, isFreeBSD, - isOpenBSD, + isIBMi, isLinux, + isLinuxPPCBE, + isMainThread, + isOpenBSD, isOSX, - enoughTestMem, - buildType, + isSunOS, + isWindows, localIPv6Hosts, - opensslCli, - PIPE, - hasCrypto, - hasIPv6, - childShouldThrowAndAbort, - checkoutEOL, - createZeroFilledFile, - platformTimeout, - allowGlobals, mustCall, mustCallAtLeast, - mustSucceed, - hasMultiLocalhost, - skipIfDumbTerminal, - skipIfEslintMissing, - canCreateSymLink, - getCallSite, mustNotCall, mustNotMutateObjectDeep, + mustSucceed, + nodeProcessAborted, + opensslCli, parseTestFlags, + PIPE, + platformTimeout, printSkipMessage, + runWithInvalidFD, skip, - nodeProcessAborted, - isAlive, - expectWarning, - expectsError, - skipIfInspectorDisabled, skipIf32Bits, - getArrayBufferViews, - getBufferSources, - getTTYfd, - runWithInvalidFD, + skipIfDumbTerminal, + skipIfEslintMissing, + skipIfInspectorDisabled, spawnPromisified, } = common; const getPort = () => common.PORT; export { - isMainThread, - isWindows, + allowGlobals, + buildType, + canCreateSymLink, + checkoutEOL, + childShouldThrowAndAbort, + createRequire, + createZeroFilledFile, + enoughTestMem, + expectsError, + expectWarning, + getArrayBufferViews, + getBufferSources, + getCallSite, + getPort, + getTTYfd, + hasCrypto, + hasIPv6, + hasMultiLocalhost, isAIX, - isIBMi, - isLinuxPPCBE, - isSunOS, + isAlive, isDumbTerminal, isFreeBSD, - isOpenBSD, + isIBMi, isLinux, + isLinuxPPCBE, + isMainThread, + isOpenBSD, isOSX, - enoughTestMem, - buildType, + isSunOS, + isWindows, localIPv6Hosts, - opensslCli, - PIPE, - hasCrypto, - hasIPv6, - childShouldThrowAndAbort, - checkoutEOL, - createZeroFilledFile, - platformTimeout, - allowGlobals, mustCall, mustCallAtLeast, - mustSucceed, - hasMultiLocalhost, - skipIfDumbTerminal, - skipIfEslintMissing, - canCreateSymLink, - getCallSite, mustNotCall, mustNotMutateObjectDeep, + mustSucceed, + nodeProcessAborted, + opensslCli, parseTestFlags, + PIPE, + platformTimeout, printSkipMessage, + runWithInvalidFD, skip, - nodeProcessAborted, - isAlive, - expectWarning, - expectsError, - skipIfInspectorDisabled, skipIf32Bits, - getArrayBufferViews, - getBufferSources, - getTTYfd, - runWithInvalidFD, - createRequire, + skipIfDumbTerminal, + skipIfEslintMissing, + skipIfInspectorDisabled, spawnPromisified, - getPort, }; diff --git a/test/common/package.json b/test/common/package.json new file mode 100644 index 00000000000000..5bbefffbabee39 --- /dev/null +++ b/test/common/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/test/common/tmpdir.js b/test/common/tmpdir.js index decfa97a892771..19e997e937559f 100644 --- a/test/common/tmpdir.js +++ b/test/common/tmpdir.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); +const { pathToFileURL } = require('url'); const { isMainThread } = require('worker_threads'); function rmSync(pathname) { @@ -64,9 +65,17 @@ function hasEnoughSpace(size) { return bavail >= Math.ceil(size / bsize); } +function fileURL(...paths) { + // When called without arguments, add explicit trailing slash + const fullPath = path.resolve(tmpPath + path.sep, ...paths); + + return pathToFileURL(fullPath); +} + module.exports = { + fileURL, + hasEnoughSpace, path: tmpPath, refresh, - hasEnoughSpace, resolve, }; diff --git a/test/es-module/test-cjs-esm-warn.js b/test/es-module/test-cjs-esm-warn.js index c1d60a209502bb..7ac85fd58c5f18 100644 --- a/test/es-module/test-cjs-esm-warn.js +++ b/test/es-module/test-cjs-esm-warn.js @@ -31,7 +31,7 @@ describe('CJS ↔︎ ESM interop warnings', { concurrency: true }, () => { ); assert.ok( stderr.replaceAll('\r', '').includes( - `Instead rename ${basename} to end in .cjs, change the requiring ` + + `Instead either rename ${basename} to end in .cjs, change the requiring ` + 'code to use dynamic import() which is available in all CommonJS ' + `modules, or change "type": "module" to "type": "commonjs" in ${pjson} to ` + 'treat all .js files as CommonJS (using .mjs for all ES modules ' + diff --git a/test/es-module/test-esm-dynamic-import-assertion.js b/test/es-module/test-esm-dynamic-import-attribute.js similarity index 100% rename from test/es-module/test-esm-dynamic-import-assertion.js rename to test/es-module/test-esm-dynamic-import-attribute.js diff --git a/test/es-module/test-esm-dynamic-import-assertion.mjs b/test/es-module/test-esm-dynamic-import-attribute.mjs similarity index 100% rename from test/es-module/test-esm-dynamic-import-assertion.mjs rename to test/es-module/test-esm-dynamic-import-attribute.mjs diff --git a/test/es-module/test-esm-dynamic-import-mutating-fs.js b/test/es-module/test-esm-dynamic-import-mutating-fs.js new file mode 100644 index 00000000000000..09cbffe487959e --- /dev/null +++ b/test/es-module/test-esm-dynamic-import-mutating-fs.js @@ -0,0 +1,25 @@ +'use strict'; +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); + +const assert = require('node:assert'); +const fs = require('node:fs/promises'); +const { pathToFileURL } = require('node:url'); + +tmpdir.refresh(); +const tmpDir = pathToFileURL(tmpdir.path); + +const target = new URL(`./${Math.random()}.mjs`, tmpDir); + +(async () => { + + await assert.rejects(import(target), { code: 'ERR_MODULE_NOT_FOUND' }); + + await fs.writeFile(target, 'export default "actual target"\n'); + + const moduleRecord = await import(target); + + await fs.rm(target); + + assert.strictEqual(await import(target), moduleRecord); +})().then(common.mustCall()); diff --git a/test/es-module/test-esm-dynamic-import-mutating-fs.mjs b/test/es-module/test-esm-dynamic-import-mutating-fs.mjs new file mode 100644 index 00000000000000..7eb79337065765 --- /dev/null +++ b/test/es-module/test-esm-dynamic-import-mutating-fs.mjs @@ -0,0 +1,42 @@ +import { spawnPromisified } from '../common/index.mjs'; +import tmpdir from '../common/tmpdir.js'; + +import assert from 'node:assert'; +import fs from 'node:fs/promises'; +import { execPath } from 'node:process'; +import { pathToFileURL } from 'node:url'; + +tmpdir.refresh(); +const tmpDir = pathToFileURL(tmpdir.path); + +const target = new URL(`./${Math.random()}.mjs`, tmpDir); + +await assert.rejects(import(target), { code: 'ERR_MODULE_NOT_FOUND' }); + +await fs.writeFile(target, 'export default "actual target"\n'); + +const moduleRecord = await import(target); + +await fs.rm(target); + +assert.strictEqual(await import(target), moduleRecord); + +// Add the file back, it should be deleted by the subprocess. +await fs.writeFile(target, 'export default "actual target"\n'); + +assert.deepStrictEqual( + await spawnPromisified(execPath, [ + '--input-type=module', + '--eval', + [`import * as d from${JSON.stringify(target)};`, + 'import{rm}from"node:fs/promises";', + `await rm(new URL(${JSON.stringify(target)}));`, + 'import{strictEqual}from"node:assert";', + `strictEqual(JSON.stringify(await import(${JSON.stringify(target)})),JSON.stringify(d));`].join(''), + ]), + { + code: 0, + signal: null, + stderr: '', + stdout: '', + }); diff --git a/test/es-module/test-esm-dynamic-import.js b/test/es-module/test-esm-dynamic-import.js index ac6b35ebc1bc15..d246841c2a6d8b 100644 --- a/test/es-module/test-esm-dynamic-import.js +++ b/test/es-module/test-esm-dynamic-import.js @@ -1,11 +1,11 @@ 'use strict'; const common = require('../common'); +const { pathToFileURL } = require('url'); const assert = require('assert'); const relativePath = '../fixtures/es-modules/test-esm-ok.mjs'; -const absolutePath = require.resolve('../fixtures/es-modules/test-esm-ok.mjs'); -const targetURL = new URL('file:///'); -targetURL.pathname = absolutePath; +const absolutePath = require.resolve(relativePath); +const targetURL = pathToFileURL(absolutePath); function expectModuleError(result, code, message) { Promise.resolve(result).catch(common.mustCall((error) => { @@ -41,7 +41,7 @@ function expectFsNamespace(result) { // expectOkNamespace(import(relativePath)); expectOkNamespace(eval(`import("${relativePath}")`)); expectOkNamespace(eval(`import("${relativePath}")`)); - expectOkNamespace(eval(`import("${targetURL}")`)); + expectOkNamespace(eval(`import(${JSON.stringify(targetURL)})`)); // Importing a built-in, both direct & via eval expectFsNamespace(import('fs')); diff --git a/test/es-module/test-esm-experimental-warnings.mjs b/test/es-module/test-esm-experimental-warnings.mjs index fc167c63584b87..68a48f2d697bcf 100644 --- a/test/es-module/test-esm-experimental-warnings.mjs +++ b/test/es-module/test-esm-experimental-warnings.mjs @@ -24,15 +24,19 @@ describe('ESM: warn for obsolete hooks provided', { concurrency: true }, () => { describe('experimental warnings for enabled experimental feature', () => { for ( - const [experiment, arg] of [ - [/Custom ESM Loaders/, `--experimental-loader=${fileURL('es-module-loaders', 'hooks-custom.mjs')}`], + const [experiment, ...args] of [ + [ + /`--experimental-loader` may be removed in the future/, + '--experimental-loader', + fileURL('es-module-loaders', 'hooks-custom.mjs'), + ], [/Network Imports/, '--experimental-network-imports'], [/specifier resolution/, '--experimental-specifier-resolution=node'], ] ) { it(`should print for ${experiment.toString().replaceAll('/', '')}`, async () => { const { code, signal, stderr } = await spawnPromisified(execPath, [ - arg, + ...args, '--input-type=module', '--eval', `import ${JSON.stringify(fileURL('es-module-loaders', 'module-named-exports.mjs'))}`, diff --git a/test/es-module/test-esm-extension-lookup-deprecation.mjs b/test/es-module/test-esm-extension-lookup-deprecation.mjs index e8da1a8b176bc7..dc391486f7edc2 100644 --- a/test/es-module/test-esm-extension-lookup-deprecation.mjs +++ b/test/es-module/test-esm-extension-lookup-deprecation.mjs @@ -1,5 +1,5 @@ import { spawnPromisified } from '../common/index.mjs'; -import * as tmpdir from '../common/tmpdir.js'; +import tmpdir from '../common/tmpdir.js'; import assert from 'node:assert'; import { mkdir, writeFile } from 'node:fs/promises'; diff --git a/test/es-module/test-esm-extensionless-esm-and-wasm.mjs b/test/es-module/test-esm-extensionless-esm-and-wasm.mjs new file mode 100644 index 00000000000000..db20bc047feec1 --- /dev/null +++ b/test/es-module/test-esm-extensionless-esm-and-wasm.mjs @@ -0,0 +1,106 @@ +// Flags: --experimental-wasm-modules +import { mustNotCall, spawnPromisified } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { describe, it } from 'node:test'; +import { match, ok, strictEqual } from 'node:assert'; + +describe('extensionless ES modules within a "type": "module" package scope', { concurrency: true }, () => { + it('should run as the entry point', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + fixtures.path('es-modules/package-type-module/noext-esm'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, 'executed\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it('should be importable', async () => { + const { default: defaultExport } = + await import(fixtures.fileURL('es-modules/package-type-module/noext-esm')); + strictEqual(defaultExport, 'module'); + }); + + it('should be importable from a module scope under node_modules', async () => { + const { default: defaultExport } = + await import(fixtures.fileURL( + 'es-modules/package-type-module/node_modules/dep-with-package-json-type-module/noext-esm')); + strictEqual(defaultExport, 'module'); + }); +}); +describe('extensionless Wasm modules within a "type": "module" package scope', { concurrency: true }, () => { + it('should run as the entry point', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-wasm-modules', + '--no-warnings', + fixtures.path('es-modules/package-type-module/noext-wasm'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, 'executed\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it('should be importable', async () => { + const { add } = await import(fixtures.fileURL('es-modules/package-type-module/noext-wasm')); + strictEqual(add(1, 2), 3); + }); + + it('should be importable from a module scope under node_modules', async () => { + const { add } = await import(fixtures.fileURL( + 'es-modules/package-type-module/node_modules/dep-with-package-json-type-module/noext-wasm')); + strictEqual(add(1, 2), 3); + }); +}); + +describe('extensionless ES modules within no package scope', { concurrency: true }, () => { + // This succeeds with `--experimental-default-type=module` + it('should error as the entry point', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + fixtures.path('es-modules/noext-esm'), + ]); + + match(stderr, /SyntaxError/); + strictEqual(stdout, ''); + strictEqual(code, 1); + strictEqual(signal, null); + }); + + // This succeeds with `--experimental-default-type=module` + it('should error on import', async () => { + try { + await import(fixtures.fileURL('es-modules/noext-esm')); + mustNotCall(); + } catch (err) { + ok(err instanceof SyntaxError); + } + }); +}); + +describe('extensionless Wasm within no package scope', { concurrency: true }, () => { + // This succeeds with `--experimental-default-type=module` + it('should error as the entry point', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-wasm-modules', + '--no-warnings', + fixtures.path('es-modules/noext-wasm'), + ]); + + match(stderr, /SyntaxError/); + strictEqual(stdout, ''); + strictEqual(code, 1); + strictEqual(signal, null); + }); + + // This succeeds with `--experimental-default-type=module` + it('should error on import', async () => { + try { + await import(fixtures.fileURL('es-modules/noext-wasm')); + mustNotCall(); + } catch (err) { + ok(err instanceof SyntaxError); + } + }); +}); diff --git a/test/es-module/test-esm-import-assertion-2.mjs b/test/es-module/test-esm-import-assertion-2.mjs deleted file mode 100644 index 8001c29772b1f0..00000000000000 --- a/test/es-module/test-esm-import-assertion-2.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import '../common/index.mjs'; -import { strictEqual } from 'assert'; - -import secret from '../fixtures/experimental.json' assert { type: 'json', unsupportedAssertion: 'should ignore' }; - -strictEqual(secret.ofLife, 42); diff --git a/test/es-module/test-esm-import-assertion-validation.js b/test/es-module/test-esm-import-assertion-validation.js deleted file mode 100644 index ec2d2a2c08f7b5..00000000000000 --- a/test/es-module/test-esm-import-assertion-validation.js +++ /dev/null @@ -1,45 +0,0 @@ -// Flags: --expose-internals -'use strict'; -const common = require('../common'); - -const assert = require('assert'); - -const { validateAssertions } = require('internal/modules/esm/assert'); - -common.expectWarning( - 'ExperimentalWarning', - 'Import assertions are not a stable feature of the JavaScript language. ' + - 'Avoid relying on their current behavior and syntax as those might change ' + - 'in a future version of Node.js.' -); - - -const url = 'test://'; - -assert.ok(validateAssertions(url, 'builtin', {})); -assert.ok(validateAssertions(url, 'commonjs', {})); -assert.ok(validateAssertions(url, 'json', { type: 'json' })); -assert.ok(validateAssertions(url, 'module', {})); -assert.ok(validateAssertions(url, 'wasm', {})); - -assert.throws(() => validateAssertions(url, 'json', {}), { - code: 'ERR_IMPORT_ASSERTION_TYPE_MISSING', -}); - -assert.throws(() => validateAssertions(url, 'module', { type: 'json' }), { - code: 'ERR_IMPORT_ASSERTION_TYPE_FAILED', -}); - -// The HTML spec specifically disallows this for now, while Wasm module import -// and whether it will require a type assertion is still an open question. -assert.throws(() => validateAssertions(url, 'module', { type: 'javascript' }), { - code: 'ERR_IMPORT_ASSERTION_TYPE_UNSUPPORTED', -}); - -assert.throws(() => validateAssertions(url, 'module', { type: 'css' }), { - code: 'ERR_IMPORT_ASSERTION_TYPE_UNSUPPORTED', -}); - -assert.throws(() => validateAssertions(url, 'module', { type: false }), { - code: 'ERR_INVALID_ARG_TYPE', -}); diff --git a/test/es-module/test-esm-import-assertion-warning.mjs b/test/es-module/test-esm-import-assertion-warning.mjs index 0b18d8ff9eaf62..a11b5164cebffc 100644 --- a/test/es-module/test-esm-import-assertion-warning.mjs +++ b/test/es-module/test-esm-import-assertion-warning.mjs @@ -1,10 +1,37 @@ -import { expectWarning } from '../common/index.mjs'; +import { spawnPromisified } from '../common/index.mjs'; +import assert from 'node:assert'; +import { execPath } from 'node:process'; -expectWarning( - 'ExperimentalWarning', - 'Import assertions are not a stable feature of the JavaScript language. ' + - 'Avoid relying on their current behavior and syntax as those might change ' + - 'in a future version of Node.js.' -); +await Promise.all([ + // Using importAssertions in the resolve hook should warn but still work. + `data:text/javascript,export ${encodeURIComponent(function resolve() { + return { shortCircuit: true, url: 'data:application/json,1', importAssertions: { type: 'json' } }; + })}`, + // Setting importAssertions on the context object of the load hook should warn but still work. + `data:text/javascript,export ${encodeURIComponent(function load(u, c, n) { + c.importAssertions = { type: 'json' }; + return n('data:application/json,1', c); + })}`, + // Creating a new context object with importAssertions in the load hook should warn but still work. + `data:text/javascript,export ${encodeURIComponent(function load(u, c, n) { + return n('data:application/json,1', { importAssertions: { type: 'json' } }); + })}`, +].map(async (loaderURL) => { + const { stdout, stderr, code } = await spawnPromisified(execPath, [ + '--input-type=module', + '--eval', ` + import assert from 'node:assert'; + import { register } from 'node:module'; + + register(${JSON.stringify(loaderURL)}); + + assert.deepStrictEqual( + { ...await import('data:') }, + { default: 1 } + );`, + ]); -await import('data:text/javascript,', { assert: { someUnsupportedKey: 'value' } }); + assert.match(stderr, /Use `importAttributes` instead of `importAssertions`/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 0); +})); diff --git a/test/es-module/test-esm-import-assertion-1.mjs b/test/es-module/test-esm-import-attributes-1.mjs similarity index 100% rename from test/es-module/test-esm-import-assertion-1.mjs rename to test/es-module/test-esm-import-attributes-1.mjs diff --git a/test/es-module/test-esm-import-assertion-4.mjs b/test/es-module/test-esm-import-attributes-2.mjs similarity index 98% rename from test/es-module/test-esm-import-assertion-4.mjs rename to test/es-module/test-esm-import-attributes-2.mjs index 547983e51f449a..1b4669ac276474 100644 --- a/test/es-module/test-esm-import-assertion-4.mjs +++ b/test/es-module/test-esm-import-attributes-2.mjs @@ -4,7 +4,7 @@ import { strictEqual } from 'assert'; import secret0 from '../fixtures/experimental.json' assert { type: 'json' }; const secret1 = await import('../fixtures/experimental.json', { assert: { type: 'json' }, - }); +}); strictEqual(secret0.ofLife, 42); strictEqual(secret1.default.ofLife, 42); diff --git a/test/es-module/test-esm-import-assertion-3.mjs b/test/es-module/test-esm-import-attributes-3.mjs similarity index 100% rename from test/es-module/test-esm-import-assertion-3.mjs rename to test/es-module/test-esm-import-attributes-3.mjs diff --git a/test/es-module/test-esm-import-assertion-errors.js b/test/es-module/test-esm-import-attributes-errors.js similarity index 80% rename from test/es-module/test-esm-import-assertion-errors.js rename to test/es-module/test-esm-import-attributes-errors.js index e2abd3fb43976d..f6e57f19b6b432 100644 --- a/test/es-module/test-esm-import-assertion-errors.js +++ b/test/es-module/test-esm-import-attributes-errors.js @@ -5,19 +5,17 @@ const { rejects } = require('assert'); const jsModuleDataUrl = 'data:text/javascript,export{}'; const jsonModuleDataUrl = 'data:application/json,""'; -common.expectWarning( - 'ExperimentalWarning', - 'Import assertions are not a stable feature of the JavaScript language. ' + - 'Avoid relying on their current behavior and syntax as those might change ' + - 'in a future version of Node.js.' -); - async function test() { await rejects( import('data:text/css,', { assert: { type: 'css' } }), { code: 'ERR_UNKNOWN_MODULE_FORMAT' } ); + await rejects( + import('data:text/css,', { assert: { unsupportedAttribute: 'value' } }), + { code: 'ERR_IMPORT_ATTRIBUTE_UNSUPPORTED' } + ); + await rejects( import(`data:text/javascript,import${JSON.stringify(jsModuleDataUrl)}assert{type:"json"}`), { code: 'ERR_IMPORT_ASSERTION_TYPE_FAILED' } @@ -49,7 +47,7 @@ async function test() { ); await rejects( - import(jsonModuleDataUrl, { assert: { type: 'unsupported' }}), + import(jsonModuleDataUrl, { assert: { type: 'unsupported' } }), { code: 'ERR_IMPORT_ASSERTION_TYPE_UNSUPPORTED' } ); } diff --git a/test/es-module/test-esm-import-assertion-errors.mjs b/test/es-module/test-esm-import-attributes-errors.mjs similarity index 76% rename from test/es-module/test-esm-import-assertion-errors.mjs rename to test/es-module/test-esm-import-attributes-errors.mjs index 9cc08c06528fc6..6621585be412e0 100644 --- a/test/es-module/test-esm-import-assertion-errors.mjs +++ b/test/es-module/test-esm-import-attributes-errors.mjs @@ -1,17 +1,9 @@ -import { expectWarning } from '../common/index.mjs'; +import '../common/index.mjs'; import { rejects } from 'assert'; const jsModuleDataUrl = 'data:text/javascript,export{}'; const jsonModuleDataUrl = 'data:application/json,""'; -expectWarning( - 'ExperimentalWarning', - 'Import assertions are not a stable feature of the JavaScript language. ' + - 'Avoid relying on their current behavior and syntax as those might change ' + - 'in a future version of Node.js.' -); - - await rejects( // This rejects because of the unsupported MIME type, not because of the // unsupported assertion. @@ -50,6 +42,6 @@ await rejects( ); await rejects( - import(jsonModuleDataUrl, { assert: { type: 'unsupported' }}), + import(jsonModuleDataUrl, { assert: { type: 'unsupported' } }), { code: 'ERR_IMPORT_ASSERTION_TYPE_UNSUPPORTED' } ); diff --git a/test/es-module/test-esm-import-attributes-validation.js b/test/es-module/test-esm-import-attributes-validation.js new file mode 100644 index 00000000000000..f436f7073126d7 --- /dev/null +++ b/test/es-module/test-esm-import-attributes-validation.js @@ -0,0 +1,45 @@ +// Flags: --expose-internals +'use strict'; +require('../common'); + +const assert = require('assert'); + +const { validateAttributes } = require('internal/modules/esm/assert'); + +const url = 'test://'; + +assert.ok(validateAttributes(url, 'builtin', {})); +assert.ok(validateAttributes(url, 'commonjs', {})); +assert.ok(validateAttributes(url, 'json', { type: 'json' })); +assert.ok(validateAttributes(url, 'module', {})); +assert.ok(validateAttributes(url, 'wasm', {})); + +assert.throws(() => validateAttributes(url, 'json', {}), { + code: 'ERR_IMPORT_ASSERTION_TYPE_MISSING', +}); + +assert.throws(() => validateAttributes(url, 'json', { type: 'json', unsupportedAttribute: 'value' }), { + code: 'ERR_IMPORT_ATTRIBUTE_UNSUPPORTED', +}); + +assert.throws(() => validateAttributes(url, 'module', { unsupportedAttribute: 'value' }), { + code: 'ERR_IMPORT_ATTRIBUTE_UNSUPPORTED', +}); + +assert.throws(() => validateAttributes(url, 'module', { type: 'json' }), { + code: 'ERR_IMPORT_ASSERTION_TYPE_FAILED', +}); + +// The HTML spec specifically disallows this for now, while Wasm module import +// and whether it will require a type assertion is still an open question. +assert.throws(() => validateAttributes(url, 'module', { type: 'javascript' }), { + code: 'ERR_IMPORT_ASSERTION_TYPE_UNSUPPORTED', +}); + +assert.throws(() => validateAttributes(url, 'module', { type: 'css' }), { + code: 'ERR_IMPORT_ASSERTION_TYPE_UNSUPPORTED', +}); + +assert.throws(() => validateAttributes(url, 'module', { type: false }), { + code: 'ERR_INVALID_ARG_TYPE', +}); diff --git a/test/es-module/test-esm-import-meta-resolve.mjs b/test/es-module/test-esm-import-meta-resolve.mjs index 69ec84291d3cd6..7bd1a65fbb71df 100644 --- a/test/es-module/test-esm-import-meta-resolve.mjs +++ b/test/es-module/test-esm-import-meta-resolve.mjs @@ -1,38 +1,68 @@ // Flags: --experimental-import-meta-resolve -import { mustCall } from '../common/index.mjs'; +import '../common/index.mjs'; import assert from 'assert'; +import { spawn } from 'child_process'; +import { execPath } from 'process'; const dirname = import.meta.url.slice(0, import.meta.url.lastIndexOf('/') + 1); const fixtures = dirname.slice(0, dirname.lastIndexOf('/', dirname.length - 2) + 1) + 'fixtures/'; -(async () => { - assert.strictEqual(await import.meta.resolve('./test-esm-import-meta.mjs'), - dirname + 'test-esm-import-meta.mjs'); - try { - await import.meta.resolve('./notfound.mjs'); - assert.fail(); - } catch (e) { - assert.strictEqual(e.code, 'ERR_MODULE_NOT_FOUND'); - } - assert.strictEqual( - await import.meta.resolve('../fixtures/empty-with-bom.txt'), - fixtures + 'empty-with-bom.txt'); - assert.strictEqual(await import.meta.resolve('../fixtures/'), fixtures); - assert.strictEqual( - await import.meta.resolve('../fixtures/', import.meta.url), - fixtures); - assert.strictEqual( - await import.meta.resolve('../fixtures/', new URL(import.meta.url)), - fixtures); - await Promise.all( - [[], {}, Symbol(), 0, 1, 1n, 1.1, () => {}, true, false].map((arg) => - assert.rejects(import.meta.resolve('../fixtures/', arg), { - code: 'ERR_INVALID_ARG_TYPE', - }) - ) - ); - assert.strictEqual(await import.meta.resolve('http://some-absolute/url'), 'http://some-absolute/url'); - assert.strictEqual(await import.meta.resolve('some://weird/protocol'), 'some://weird/protocol'); - assert.strictEqual(await import.meta.resolve('baz/', fixtures), - fixtures + 'node_modules/baz/'); -})().then(mustCall()); +assert.strictEqual(import.meta.resolve('./test-esm-import-meta.mjs'), + dirname + 'test-esm-import-meta.mjs'); +assert.strictEqual(import.meta.resolve('./notfound.mjs'), new URL('./notfound.mjs', import.meta.url).href); +assert.strictEqual(import.meta.resolve('./asset'), new URL('./asset', import.meta.url).href); +try { + import.meta.resolve('does-not-exist'); + assert.fail(); +} catch (e) { + assert.strictEqual(e.code, 'ERR_MODULE_NOT_FOUND'); +} +assert.strictEqual( + import.meta.resolve('../fixtures/empty-with-bom.txt'), + fixtures + 'empty-with-bom.txt'); +assert.strictEqual(import.meta.resolve('../fixtures/'), fixtures); +assert.strictEqual( + import.meta.resolve('../fixtures/', import.meta.url), + fixtures); +assert.strictEqual( + import.meta.resolve('../fixtures/', new URL(import.meta.url)), + fixtures); +[[], {}, Symbol(), 0, 1, 1n, 1.1, () => {}, true, false].map((arg) => + assert.throws(() => import.meta.resolve('../fixtures/', arg), { + code: 'ERR_INVALID_ARG_TYPE', + }) +); +assert.strictEqual(import.meta.resolve('baz/', fixtures), + fixtures + 'node_modules/baz/'); + +{ + const cp = spawn(execPath, [ + '--input-type=module', + '--eval', 'console.log(typeof import.meta.resolve)', + ]); + assert.match((await cp.stdout.toArray()).toString(), /^function\r?\n$/); +} + +{ + const cp = spawn(execPath, [ + '--input-type=module', + ]); + cp.stdin.end('console.log(typeof import.meta.resolve)'); + assert.match((await cp.stdout.toArray()).toString(), /^function\r?\n$/); +} + +{ + const cp = spawn(execPath, [ + '--input-type=module', + '--eval', 'import "data:text/javascript,console.log(import.meta.resolve(%22node:os%22))"', + ]); + assert.match((await cp.stdout.toArray()).toString(), /^node:os\r?\n$/); +} + +{ + const cp = spawn(execPath, [ + '--input-type=module', + ]); + cp.stdin.end('import "data:text/javascript,console.log(import.meta.resolve(%22node:os%22))"'); + assert.match((await cp.stdout.toArray()).toString(), /^node:os\r?\n$/); +} diff --git a/test/es-module/test-esm-import-meta.mjs b/test/es-module/test-esm-import-meta.mjs index 0151177b21c302..4c5aa902d4a970 100644 --- a/test/es-module/test-esm-import-meta.mjs +++ b/test/es-module/test-esm-import-meta.mjs @@ -3,7 +3,7 @@ import assert from 'assert'; assert.strictEqual(Object.getPrototypeOf(import.meta), null); -const keys = ['url']; +const keys = ['resolve', 'url']; assert.deepStrictEqual(Reflect.ownKeys(import.meta), keys); const descriptors = Object.getOwnPropertyDescriptors(import.meta); diff --git a/test/es-module/test-esm-initialization.mjs b/test/es-module/test-esm-initialization.mjs index aa946a50152d40..f03a47d5d3791a 100644 --- a/test/es-module/test-esm-initialization.mjs +++ b/test/es-module/test-esm-initialization.mjs @@ -8,22 +8,23 @@ import { describe, it } from 'node:test'; describe('ESM: ensure initialization happens only once', { concurrency: true }, () => { it(async () => { const { code, stderr, stdout } = await spawnPromisified(execPath, [ + '--experimental-import-meta-resolve', '--loader', fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs'), '--no-warnings', fixtures.path('es-modules', 'runmain.mjs'), ]); - // Length minus 1 because the first match is the needle. - const resolveHookRunCount = (stdout.match(/resolve passthru/g)?.length ?? 0) - 1; - assert.strictEqual(stderr, ''); /** * resolveHookRunCount = 2: * 1. fixtures/…/runmain.mjs * 2. node:module (imported by fixtures/…/runmain.mjs) + * 3. doesnt-matter.mjs (first import.meta.resolve call) + * 4. fixtures/…/runmain.mjs (entry point) + * 5. doesnt-matter.mjs (second import.meta.resolve call) */ - assert.strictEqual(resolveHookRunCount, 2); + assert.strictEqual(stdout.match(/resolve passthru/g)?.length, 5); assert.strictEqual(code, 0); }); }); diff --git a/test/es-module/test-esm-json.mjs b/test/es-module/test-esm-json.mjs index 2740c0097f77da..e5a0ab9f74e2cf 100644 --- a/test/es-module/test-esm-json.mjs +++ b/test/es-module/test-esm-json.mjs @@ -2,7 +2,10 @@ import { spawnPromisified } from '../common/index.mjs'; import * as fixtures from '../common/fixtures.mjs'; import assert from 'node:assert'; import { execPath } from 'node:process'; -import { describe, it } from 'node:test'; +import { describe, it, test } from 'node:test'; + +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import * as tmpdir from '../common/tmpdir.js'; import secret from '../fixtures/experimental.json' assert { type: 'json' }; @@ -17,8 +20,64 @@ describe('ESM: importing JSON', () => { ]); assert.match(stderr, /ExperimentalWarning: Importing JSON modules/); - assert.match(stderr, /ExperimentalWarning: Import assertions/); assert.strictEqual(code, 0); assert.strictEqual(signal, null); }); + + test('should load different modules when the URL is different', async (t) => { + const root = tmpdir.fileURL(`./test-esm-json-${Math.random()}/`); + try { + await mkdir(root, { recursive: true }); + + await t.test('json', async () => { + let i = 0; + const url = new URL('./foo.json', root); + await writeFile(url, JSON.stringify({ id: i++ })); + const absoluteURL = await import(`${url}`, { + assert: { type: 'json' }, + }); + await writeFile(url, JSON.stringify({ id: i++ })); + const queryString = await import(`${url}?a=2`, { + assert: { type: 'json' }, + }); + await writeFile(url, JSON.stringify({ id: i++ })); + const hash = await import(`${url}#a=2`, { + assert: { type: 'json' }, + }); + await writeFile(url, JSON.stringify({ id: i++ })); + const queryStringAndHash = await import(`${url}?a=2#a=2`, { + assert: { type: 'json' }, + }); + + assert.notDeepStrictEqual(absoluteURL, queryString); + assert.notDeepStrictEqual(absoluteURL, hash); + assert.notDeepStrictEqual(queryString, hash); + assert.notDeepStrictEqual(absoluteURL, queryStringAndHash); + assert.notDeepStrictEqual(queryString, queryStringAndHash); + assert.notDeepStrictEqual(hash, queryStringAndHash); + }); + + await t.test('js', async () => { + let i = 0; + const url = new URL('./foo.mjs', root); + await writeFile(url, `export default ${JSON.stringify({ id: i++ })}\n`); + const absoluteURL = await import(`${url}`); + await writeFile(url, `export default ${JSON.stringify({ id: i++ })}\n`); + const queryString = await import(`${url}?a=1`); + await writeFile(url, `export default ${JSON.stringify({ id: i++ })}\n`); + const hash = await import(`${url}#a=1`); + await writeFile(url, `export default ${JSON.stringify({ id: i++ })}\n`); + const queryStringAndHash = await import(`${url}?a=1#a=1`); + + assert.notDeepStrictEqual(absoluteURL, queryString); + assert.notDeepStrictEqual(absoluteURL, hash); + assert.notDeepStrictEqual(queryString, hash); + assert.notDeepStrictEqual(absoluteURL, queryStringAndHash); + assert.notDeepStrictEqual(queryString, queryStringAndHash); + assert.notDeepStrictEqual(hash, queryStringAndHash); + }); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); }); diff --git a/test/es-module/test-esm-loader-chaining.mjs b/test/es-module/test-esm-loader-chaining.mjs index 0f67d71ece0aa4..b43ac740500cd8 100644 --- a/test/es-module/test-esm-loader-chaining.mjs +++ b/test/es-module/test-esm-loader-chaining.mjs @@ -470,4 +470,38 @@ describe('ESM: loader chaining', { concurrency: true }, () => { assert.match(stderr, /'load' hook's nextLoad\(\) context/); assert.strictEqual(code, 1); }); + + it('should allow loaders to influence subsequent loader `import()` calls in `resolve`', async () => { + const { code, stderr, stdout } = await spawnPromisified( + execPath, + [ + '--loader', + fixtures.fileURL('es-module-loaders', 'loader-resolve-strip-xxx.mjs'), + '--loader', + fixtures.fileURL('es-module-loaders', 'loader-resolve-dynamic-import.mjs'), + ...commonArgs, + ], + { encoding: 'utf8' }, + ); + assert.strictEqual(stderr, ''); + assert.match(stdout, /resolve dynamic import/); // It did go thru resolve-dynamic + assert.strictEqual(code, 0); + }); + + it('should allow loaders to influence subsequent loader `import()` calls in `load`', async () => { + const { code, stderr, stdout } = await spawnPromisified( + execPath, + [ + '--loader', + fixtures.fileURL('es-module-loaders', 'loader-resolve-strip-xxx.mjs'), + '--loader', + fixtures.fileURL('es-module-loaders', 'loader-load-dynamic-import.mjs'), + ...commonArgs, + ], + { encoding: 'utf8' }, + ); + assert.strictEqual(stderr, ''); + assert.match(stdout, /load dynamic import/); // It did go thru load-dynamic + assert.strictEqual(code, 0); + }); }); diff --git a/test/es-module/test-esm-loader-default-resolver.mjs b/test/es-module/test-esm-loader-default-resolver.mjs index 27320fcfcfe862..2a69010e05047f 100644 --- a/test/es-module/test-esm-loader-default-resolver.mjs +++ b/test/es-module/test-esm-loader-default-resolver.mjs @@ -49,4 +49,18 @@ describe('default resolver', () => { assert.strictEqual(stdout.trim(), 'index.byoe!'); assert.strictEqual(stderr, ''); }); + + it('should identify the parent module of an invalid URL host in import specifier', async () => { + if (process.platform === 'win32') return; + + const { code, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + fixtures.path('es-modules', 'invalid-posix-host.mjs'), + ]); + + assert.match(stderr, /ERR_INVALID_FILE_URL_HOST/); + assert.match(stderr, /file:\/\/hmm\.js/); + assert.match(stderr, /invalid-posix-host\.mjs/); + assert.strictEqual(code, 1); + }); }); diff --git a/test/es-module/test-esm-loader-globalpreload-hook.mjs b/test/es-module/test-esm-loader-globalpreload-hook.mjs new file mode 100644 index 00000000000000..87def31fb3d0ea --- /dev/null +++ b/test/es-module/test-esm-loader-globalpreload-hook.mjs @@ -0,0 +1,149 @@ +import { spawnPromisified } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import assert from 'node:assert'; +import os from 'node:os'; +import { execPath } from 'node:process'; +import { describe, it } from 'node:test'; + +describe('globalPreload hook', () => { + it('should not emit deprecation warning when initialize is supplied', async () => { + const { stderr } = await spawnPromisified(execPath, [ + '--experimental-loader', + 'data:text/javascript,export function globalPreload(){}export function initialize(){}', + fixtures.path('empty.js'), + ]); + + assert.doesNotMatch(stderr, /`globalPreload` is an experimental feature/); + }); + + it('should handle globalPreload returning undefined', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,export function globalPreload(){}', + fixtures.path('empty.js'), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should handle loading node:test', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,export function globalPreload(){return `getBuiltin("node:test")()`}', + fixtures.path('empty.js'), + ]); + + assert.strictEqual(stderr, ''); + assert.match(stdout, /\n# pass 1\r?\n/); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should handle loading node:os with node: prefix', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,export function globalPreload(){return `console.log(getBuiltin("node:os").arch())`}', + fixtures.path('empty.js'), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout.trim(), os.arch()); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + // `os` is used here because it's simple and not mocked (the builtin module otherwise doesn't matter). + it('should handle loading builtin module without node: prefix', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,export function globalPreload(){return `console.log(getBuiltin("os").arch())`}', + fixtures.path('empty.js'), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout.trim(), os.arch()); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should throw when loading node:test without node: prefix', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,export function globalPreload(){return `getBuiltin("test")()`}', + fixtures.path('empty.js'), + ]); + + assert.match(stderr, /ERR_UNKNOWN_BUILTIN_MODULE/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should register globals set from globalPreload', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,export function globalPreload(){return "this.myGlobal=4"}', + '--print', 'myGlobal', + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout.trim(), '4'); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should log console.log calls returned from globalPreload', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,export function globalPreload(){return `console.log("Hello from globalPreload")`}', + fixtures.path('empty.js'), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout.trim(), 'Hello from globalPreload'); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should crash if globalPreload returns code that throws', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,export function globalPreload(){return `throw new Error("error from globalPreload")`}', + fixtures.path('empty.js'), + ]); + + assert.match(stderr, /error from globalPreload/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should have a `this` value that is not bound to the loader instance', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + `data:text/javascript,export ${function globalPreload() { + if (this != null) { + throw new Error('hook function must not be bound to ESMLoader instance'); + } + }}`, + fixtures.path('empty.js'), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); +}); diff --git a/test/es-module/test-esm-loader-hooks.mjs b/test/es-module/test-esm-loader-hooks.mjs index 63cffdf40daf56..3dac3677bb804b 100644 --- a/test/es-module/test-esm-loader-hooks.mjs +++ b/test/es-module/test-esm-loader-hooks.mjs @@ -9,8 +9,8 @@ describe('Loader hooks', { concurrency: true }, () => { const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ '--no-warnings', '--experimental-loader', - fixtures.fileURL('/es-module-loaders/hooks-input.mjs'), - fixtures.path('/es-modules/json-modules.mjs'), + fixtures.fileURL('es-module-loaders/hooks-input.mjs'), + fixtures.path('es-modules/json-modules.mjs'), ]); assert.strictEqual(stderr, ''); @@ -22,6 +22,32 @@ describe('Loader hooks', { concurrency: true }, () => { assert.match(lines[1], /{"source":{"type":"Buffer","data":\[.*\]},"format":"module","shortCircuit":true}/); assert.match(lines[2], /{"url":"file:\/\/\/.*\/experimental\.json","format":"test","shortCircuit":true}/); assert.match(lines[3], /{"source":{"type":"Buffer","data":\[.*\]},"format":"json","shortCircuit":true}/); + assert.strictEqual(lines[4], ''); + assert.strictEqual(lines.length, 5); + }); + + it('are called with all expected arguments using register function', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader=data:text/javascript,', + '--input-type=module', + '--eval', + "import { register } from 'node:module';" + + `register(${JSON.stringify(fixtures.fileURL('es-module-loaders/hooks-input.mjs'))});` + + `await import(${JSON.stringify(fixtures.fileURL('es-modules/json-modules.mjs'))});`, + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + + const lines = stdout.split('\n'); + assert.match(lines[0], /{"url":"file:\/\/\/.*\/json-modules\.mjs","format":"test","shortCircuit":true}/); + assert.match(lines[1], /{"source":{"type":"Buffer","data":\[.*\]},"format":"module","shortCircuit":true}/); + assert.match(lines[2], /{"url":"file:\/\/\/.*\/experimental\.json","format":"test","shortCircuit":true}/); + assert.match(lines[3], /{"source":{"type":"Buffer","data":\[.*\]},"format":"json","shortCircuit":true}/); + assert.strictEqual(lines[4], ''); + assert.strictEqual(lines.length, 5); }); describe('should handle never-settling hooks in ESM files', { concurrency: true }, () => { @@ -71,7 +97,6 @@ describe('Loader hooks', { concurrency: true }, () => { it('import.meta.resolve of a never-settling resolve', async () => { const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ '--no-warnings', - '--experimental-import-meta-resolve', '--experimental-loader', fixtures.fileURL('es-module-loaders/never-settling-resolve-step/loader.mjs'), fixtures.path('es-module-loaders/never-settling-resolve-step/import.meta.never-resolve.mjs'), @@ -132,7 +157,6 @@ describe('Loader hooks', { concurrency: true }, () => { it('should not leak internals or expose import.meta.resolve', async () => { const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ '--no-warnings', - '--experimental-import-meta-resolve', '--experimental-loader', fixtures.fileURL('es-module-loaders/loader-edge-cases.mjs'), fixtures.path('empty.js'), @@ -143,4 +167,479 @@ describe('Loader hooks', { concurrency: true }, () => { assert.strictEqual(code, 0); assert.strictEqual(signal, null); }); + + it('should be fine to call `process.exit` from a custom async hook', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,export function load(a,b,next){if(a==="data:exit")process.exit(42);return next(a,b)}', + '--input-type=module', + '--eval', + 'import "data:exit"', + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 42); + assert.strictEqual(signal, null); + }); + + it('should be fine to call `process.exit` from a custom sync hook', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,export function resolve(a,b,next){if(a==="exit:")process.exit(42);return next(a,b)}', + '--input-type=module', + '--eval', + 'import "data:text/javascript,import.meta.resolve(%22exit:%22)"', + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 42); + assert.strictEqual(signal, null); + }); + + it('should be fine to call `process.exit` from the loader thread top-level', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,process.exit(42)', + fixtures.path('empty.js'), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 42); + assert.strictEqual(signal, null); + }); + + describe('should handle a throwing top-level body', () => { + it('should handle regular Error object', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,throw new Error("error message")', + fixtures.path('empty.js'), + ]); + + assert.match(stderr, /Error: error message\r?\n/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should handle null', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,throw null', + fixtures.path('empty.js'), + ]); + + assert.match(stderr, /\nnull\r?\n/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should handle undefined', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,throw undefined', + fixtures.path('empty.js'), + ]); + + assert.match(stderr, /\nundefined\r?\n/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should handle boolean', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,throw true', + fixtures.path('empty.js'), + ]); + + assert.match(stderr, /\ntrue\r?\n/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should handle empty plain object', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,throw {}', + fixtures.path('empty.js'), + ]); + + assert.match(stderr, /\n\{\}\r?\n/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should handle plain object', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,throw {fn(){},symbol:Symbol("symbol"),u:undefined}', + fixtures.path('empty.js'), + ]); + + assert.match(stderr, /\n\{ fn: \[Function: fn\], symbol: Symbol\(symbol\), u: undefined \}\r?\n/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should handle number', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,throw 1', + fixtures.path('empty.js'), + ]); + + assert.match(stderr, /\n1\r?\n/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should handle bigint', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,throw 1n', + fixtures.path('empty.js'), + ]); + + assert.match(stderr, /\n1\r?\n/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should handle string', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,throw "literal string"', + fixtures.path('empty.js'), + ]); + + assert.match(stderr, /\nliteral string\r?\n/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should handle symbol', async () => { + const { code, signal, stdout } = await spawnPromisified(execPath, [ + '--experimental-loader', + 'data:text/javascript,throw Symbol("symbol descriptor")', + fixtures.path('empty.js'), + ]); + + // Throwing a symbol doesn't produce any output + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should handle function', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,throw function fnName(){}', + fixtures.path('empty.js'), + ]); + + assert.match(stderr, /\n\[Function: fnName\]\r?\n/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + }); + + describe('globalPreload', () => { + it('should emit warning', async () => { + const { stderr } = await spawnPromisified(execPath, [ + '--experimental-loader', + 'data:text/javascript,export function globalPreload(){}', + '--experimental-loader', + 'data:text/javascript,export function globalPreload(){return""}', + fixtures.path('empty.js'), + ]); + + assert.strictEqual(stderr.match(/`globalPreload` is an experimental feature/g).length, 1); + }); + }); + + it('should be fine to call `process.removeAllListeners("beforeExit")` from the main thread', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + 'data:text/javascript,export function load(a,b,c){return new Promise(d=>setTimeout(()=>d(c(a,b)),99))}', + '--input-type=module', + '--eval', + 'setInterval(() => process.removeAllListeners("beforeExit"),1).unref();await import("data:text/javascript,")', + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + describe('`initialize`/`register`', () => { + it('should invoke `initialize` correctly', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + fixtures.fileURL('es-module-loaders/hooks-initialize.mjs'), + '--input-type=module', + '--eval', + 'import os from "node:os";', + ]); + + assert.strictEqual(stderr, ''); + assert.deepStrictEqual(stdout.split('\n'), ['hooks initialize 1', '']); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should allow communicating with loader via `register` ports', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--input-type=module', + '--eval', + ` + import {MessageChannel} from 'node:worker_threads'; + import {register} from 'node:module'; + import {once} from 'node:events'; + const {port1, port2} = new MessageChannel(); + port1.on('message', (msg) => { + console.log('message', msg); + }); + const result = register( + ${JSON.stringify(fixtures.fileURL('es-module-loaders/hooks-initialize-port.mjs'))}, + {data: port2, transferList: [port2]}, + ); + console.log('register', result); + + const timeout = setTimeout(() => {}, 2**31 - 1); // to keep the process alive. + await Promise.all([ + once(port1, 'message').then(() => once(port1, 'message')), + import('node:os'), + ]); + clearTimeout(timeout); + port1.close(); + `, + ]); + + assert.strictEqual(stderr, ''); + assert.deepStrictEqual(stdout.split('\n'), [ 'register undefined', + 'message initialize', + 'message resolve node:os', + '' ]); + + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should have `register` accept URL objects as `parentURL`', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--import', + `data:text/javascript,${encodeURIComponent( + 'import{ register } from "node:module";' + + 'import { pathToFileURL } from "node:url";' + + 'register("./hooks-initialize.mjs", pathToFileURL("./"));' + )}`, + '--input-type=module', + '--eval', + ` + import {register} from 'node:module'; + register( + ${JSON.stringify(fixtures.fileURL('es-module-loaders/loader-load-foo-or-42.mjs'))}, + new URL('data:'), + ); + + import('node:os').then((result) => { + console.log(JSON.stringify(result)); + }); + `, + ], { cwd: fixtures.fileURL('es-module-loaders/') }); + + assert.strictEqual(stderr, ''); + assert.deepStrictEqual(stdout.split('\n').sort(), ['hooks initialize 1', '{"default":"foo"}', ''].sort()); + + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should have `register` work with cjs', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--input-type=commonjs', + '--eval', + ` + 'use strict'; + const {register} = require('node:module'); + register( + ${JSON.stringify(fixtures.fileURL('es-module-loaders/hooks-initialize.mjs'))}, + ); + register( + ${JSON.stringify(fixtures.fileURL('es-module-loaders/loader-load-foo-or-42.mjs'))}, + ); + + import('node:os').then((result) => { + console.log(JSON.stringify(result)); + }); + `, + ]); + + assert.strictEqual(stderr, ''); + assert.deepStrictEqual(stdout.split('\n').sort(), ['hooks initialize 1', '{"default":"foo"}', ''].sort()); + + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('`register` should work with `require`', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--require', + fixtures.path('es-module-loaders/register-loader.cjs'), + '--input-type=module', + '--eval', + 'import "node:os";', + ]); + + assert.strictEqual(stderr, ''); + assert.deepStrictEqual(stdout.split('\n'), ['resolve passthru', 'resolve passthru', '']); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('`register` should work with `import`', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--import', + fixtures.fileURL('es-module-loaders/register-loader.mjs'), + '--input-type=module', + '--eval', + 'import "node:os"', + ]); + + assert.strictEqual(stderr, ''); + assert.deepStrictEqual(stdout.split('\n'), ['resolve passthru', '']); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should execute `initialize` in sequence', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--input-type=module', + '--eval', + ` + import {register} from 'node:module'; + console.log('result 1', register( + ${JSON.stringify(fixtures.fileURL('es-module-loaders/hooks-initialize.mjs'))} + )); + console.log('result 2', register( + ${JSON.stringify(fixtures.fileURL('es-module-loaders/hooks-initialize.mjs'))} + )); + + await import('node:os'); + `, + ]); + + assert.strictEqual(stderr, ''); + assert.deepStrictEqual(stdout.split('\n'), [ 'hooks initialize 1', + 'result 1 undefined', + 'hooks initialize 2', + 'result 2 undefined', + '' ]); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should handle `initialize` returning never-settling promise', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--input-type=module', + '--eval', + ` + import {register} from 'node:module'; + register('data:text/javascript,export function initialize(){return new Promise(()=>{})}'); + `, + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 13); + assert.strictEqual(signal, null); + }); + + it('should handle `initialize` returning rejecting promise', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--input-type=module', + '--eval', + ` + import {register} from 'node:module'; + register('data:text/javascript,export function initialize(){return Promise.reject()}'); + `, + ]); + + assert.match(stderr, /undefined\r?\n/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should handle `initialize` throwing null', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--input-type=module', + '--eval', + ` + import {register} from 'node:module'; + register('data:text/javascript,export function initialize(){throw null}'); + `, + ]); + + assert.match(stderr, /null\r?\n/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + }); + + it('should be fine to call `process.exit` from a initialize hook', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--input-type=module', + '--eval', + ` + import {register} from 'node:module'; + register('data:text/javascript,export function initialize(){process.exit(42);}'); + `, + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 42); + assert.strictEqual(signal, null); + }); + }); }); diff --git a/test/es-module/test-esm-loader-mock.mjs b/test/es-module/test-esm-loader-mock.mjs new file mode 100644 index 00000000000000..164d0ac3775039 --- /dev/null +++ b/test/es-module/test-esm-loader-mock.mjs @@ -0,0 +1,42 @@ +import '../common/index.mjs'; +import assert from 'node:assert/strict'; +import { mock } from '../fixtures/es-module-loaders/mock.mjs'; + +mock('node:events', { + EventEmitter: 'This is mocked!' +}); + +// This resolves to node:events +// It is intercepted by mock-loader and doesn't return the normal value +assert.deepStrictEqual(await import('events'), Object.defineProperty({ + __proto__: null, + EventEmitter: 'This is mocked!' +}, Symbol.toStringTag, { + enumerable: false, + value: 'Module' +})); + +const mutator = mock('node:events', { + EventEmitter: 'This is mocked v2!' +}); + +// It is intercepted by mock-loader and doesn't return the normal value. +// This is resolved separately from the import above since the specifiers +// are different. +const mockedV2 = await import('node:events'); +assert.deepStrictEqual(mockedV2, Object.defineProperty({ + __proto__: null, + EventEmitter: 'This is mocked v2!' +}, Symbol.toStringTag, { + enumerable: false, + value: 'Module' +})); + +mutator.EventEmitter = 'This is mocked v3!'; +assert.deepStrictEqual(mockedV2, Object.defineProperty({ + __proto__: null, + EventEmitter: 'This is mocked v3!' +}, Symbol.toStringTag, { + enumerable: false, + value: 'Module' +})); diff --git a/test/es-module/test-esm-loader-modulemap.js b/test/es-module/test-esm-loader-modulemap.js index 190676ec725cd2..860775df0a2ce8 100644 --- a/test/es-module/test-esm-loader-modulemap.js +++ b/test/es-module/test-esm-loader-modulemap.js @@ -4,8 +4,8 @@ require('../common'); const { strictEqual, throws } = require('assert'); -const { ESMLoader } = require('internal/modules/esm/loader'); -const ModuleMap = require('internal/modules/esm/module_map'); +const { createModuleLoader } = require('internal/modules/esm/loader'); +const { LoadCache, ResolveCache } = require('internal/modules/esm/module_map'); const ModuleJob = require('internal/modules/esm/module_job'); const createDynamicModule = require( 'internal/modules/esm/create_dynamic_module'); @@ -16,7 +16,7 @@ const jsonModuleDataUrl = 'data:application/json,""'; const stubJsModule = createDynamicModule([], ['default'], jsModuleDataUrl); const stubJsonModule = createDynamicModule([], ['default'], jsonModuleDataUrl); -const loader = new ESMLoader(); +const loader = createModuleLoader(false); const jsModuleJob = new ModuleJob(loader, stubJsModule.module, undefined, () => new Promise(() => {})); const jsonModuleJob = new ModuleJob(loader, stubJsonModule.module, @@ -24,11 +24,11 @@ const jsonModuleJob = new ModuleJob(loader, stubJsonModule.module, () => new Promise(() => {})); -// ModuleMap.set and ModuleMap.get store and retrieve module jobs for a -// specified url/type tuple; ModuleMap.has correctly reports whether such jobs +// LoadCache.set and LoadCache.get store and retrieve module jobs for a +// specified url/type tuple; LoadCache.has correctly reports whether such jobs // are stored in the map. { - const moduleMap = new ModuleMap(); + const moduleMap = new LoadCache(); moduleMap.set(jsModuleDataUrl, undefined, jsModuleJob); moduleMap.set(jsonModuleDataUrl, 'json', jsonModuleJob); @@ -50,10 +50,10 @@ const jsonModuleJob = new ModuleJob(loader, stubJsonModule.module, strictEqual(moduleMap.has(jsonModuleDataUrl, 'unknown'), false); } -// ModuleMap.get, ModuleMap.has and ModuleMap.set should only accept string +// LoadCache.get, LoadCache.has and LoadCache.set should only accept string // values as url argument. { - const moduleMap = new ModuleMap(); + const moduleMap = new LoadCache(); const errorObj = { code: 'ERR_INVALID_ARG_TYPE', @@ -68,10 +68,10 @@ const jsonModuleJob = new ModuleJob(loader, stubJsonModule.module, }); } -// ModuleMap.get, ModuleMap.has and ModuleMap.set should only accept string +// LoadCache.get, LoadCache.has and LoadCache.set should only accept string // values (or the kAssertType symbol) as type argument. { - const moduleMap = new ModuleMap(); + const moduleMap = new LoadCache(); const errorObj = { code: 'ERR_INVALID_ARG_TYPE', @@ -86,9 +86,9 @@ const jsonModuleJob = new ModuleJob(loader, stubJsonModule.module, }); } -// ModuleMap.set should only accept ModuleJob values as job argument. +// LoadCache.set should only accept ModuleJob values as job argument. { - const moduleMap = new ModuleMap(); + const moduleMap = new LoadCache(); [{}, [], true, 1].forEach((value) => { throws(() => moduleMap.set('', undefined, value), { @@ -98,3 +98,21 @@ const jsonModuleJob = new ModuleJob(loader, stubJsonModule.module, }); }); } + +{ + const resolveMap = new ResolveCache(); + + strictEqual(resolveMap.serializeKey('./file', { __proto__: null }), './file::'); + strictEqual(resolveMap.serializeKey('./file', { __proto__: null, type: 'json' }), './file::"type""json"'); + strictEqual(resolveMap.serializeKey('./file::"type""json"', { __proto__: null }), './file::"type""json"::'); + strictEqual(resolveMap.serializeKey('./file', { __proto__: null, c: 'd', a: 'b' }), './file::"a""b","c""d"'); + strictEqual(resolveMap.serializeKey('./s', { __proto__: null, c: 'd', a: 'b', b: 'c' }), './s::"a""b","b""c","c""d"'); + + resolveMap.set('key1', 'parent1', 1); + resolveMap.set('key2', 'parent1', 2); + resolveMap.set('key2', 'parent2', 3); + + strictEqual(resolveMap.get('key1', 'parent1'), 1); + strictEqual(resolveMap.get('key2', 'parent1'), 2); + strictEqual(resolveMap.get('key2', 'parent2'), 3); +} diff --git a/test/es-module/test-esm-loader-obsolete-hooks.mjs b/test/es-module/test-esm-loader-obsolete-hooks.mjs deleted file mode 100644 index fa0baef8a216b7..00000000000000 --- a/test/es-module/test-esm-loader-obsolete-hooks.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { spawnPromisified } from '../common/index.mjs'; -import { fileURL, path } from '../common/fixtures.mjs'; -import { match, notStrictEqual } from 'node:assert'; -import { execPath } from 'node:process'; -import { describe, it } from 'node:test'; - - -describe('ESM: deprecation warnings for obsolete hooks', { concurrency: true }, () => { - it(async () => { - const { code, stderr } = await spawnPromisified(execPath, [ - '--no-warnings', - '--throw-deprecation', - '--experimental-loader', - fileURL('es-module-loaders', 'hooks-obsolete.mjs').href, - path('print-error-message.js'), - ]); - - // DeprecationWarning: Obsolete loader hook(s) supplied and will be ignored: - // dynamicInstantiate, getFormat, getSource, transformSource - match(stderr, /DeprecationWarning:/); - match(stderr, /dynamicInstantiate/); - match(stderr, /getFormat/); - match(stderr, /getSource/); - match(stderr, /transformSource/); - - notStrictEqual(code, 0); - }); -}); diff --git a/test/es-module/test-esm-loader-programmatically.mjs b/test/es-module/test-esm-loader-programmatically.mjs new file mode 100644 index 00000000000000..8d70b110ebbfda --- /dev/null +++ b/test/es-module/test-esm-loader-programmatically.mjs @@ -0,0 +1,251 @@ +import { spawnPromisified } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.js'; +import assert from 'node:assert'; +import { execPath } from 'node:process'; +import { describe, it } from 'node:test'; + +// This test ensures that the register function can register loaders +// programmatically. + +const commonArgs = [ + '--no-warnings', + '--input-type=module', + '--loader=data:text/javascript,', +]; + +const commonEvals = { + import: (module) => `await import(${JSON.stringify(module)});`, + register: (loader, parentURL = 'file:///') => `register(${JSON.stringify(loader)}, ${JSON.stringify(parentURL)});`, + dynamicImport: (module) => `await import(${JSON.stringify(`data:text/javascript,${encodeURIComponent(module)}`)});`, + staticImport: (module) => `import ${JSON.stringify(`data:text/javascript,${encodeURIComponent(module)}`)};`, +}; + +describe('ESM: programmatically register loaders', { concurrency: true }, () => { + it('works with only a dummy CLI argument', async () => { + const parentURL = fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs'); + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + ...commonArgs, + '--eval', + "import { register } from 'node:module';" + + commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs')) + + commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-load-passthru.mjs')) + + `register(${JSON.stringify('./loader-resolve-passthru.mjs')}, ${JSON.stringify({ parentURL })});` + + `register(${JSON.stringify('./loader-load-passthru.mjs')}, ${JSON.stringify({ parentURL })});` + + commonEvals.dynamicImport('console.log("Hello from dynamic import");'), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + + const lines = stdout.split('\n'); + + assert.match(lines[0], /resolve passthru/); + assert.match(lines[1], /resolve passthru/); + assert.match(lines[2], /load passthru/); + assert.match(lines[3], /load passthru/); + assert.match(lines[4], /Hello from dynamic import/); + + assert.strictEqual(lines[5], ''); + }); + + describe('registering via --import', { concurrency: true }, () => { + for (const moduleType of ['mjs', 'cjs']) { + it(`should programmatically register a loader from a ${moduleType.toUpperCase()} file`, async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + ...commonArgs, + '--import', fixtures.fileURL('es-module-loaders', `register-loader.${moduleType}`).href, + '--eval', commonEvals.staticImport('console.log("entry point")'), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + + const [ + passthruStdout, + entryPointStdout, + ] = stdout.split('\n'); + + assert.match(passthruStdout, /resolve passthru/); + assert.match(entryPointStdout, /entry point/); + }); + } + }); + + it('programmatically registered loaders are appended to an existing chaining', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + ...commonArgs, + '--loader', + fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs'), + '--eval', + "import { register } from 'node:module';" + + commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-load-passthru.mjs')) + + commonEvals.dynamicImport('console.log("Hello from dynamic import");'), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + + const lines = stdout.split('\n'); + + assert.match(lines[0], /resolve passthru/); + assert.match(lines[1], /resolve passthru/); + assert.match(lines[2], /load passthru/); + assert.match(lines[3], /Hello from dynamic import/); + + assert.strictEqual(lines[4], ''); + }); + + it('works registering loaders across files', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + ...commonArgs, + '--eval', + commonEvals.import(fixtures.fileURL('es-module-loaders', 'register-programmatically-loader-load.mjs')) + + commonEvals.import(fixtures.fileURL('es-module-loaders', 'register-programmatically-loader-resolve.mjs')) + + commonEvals.dynamicImport('console.log("Hello from dynamic import");'), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + + const lines = stdout.split('\n'); + + assert.match(lines[0], /resolve passthru/); + assert.match(lines[1], /load passthru/); + assert.match(lines[2], /Hello from dynamic import/); + + assert.strictEqual(lines[3], ''); + }); + + it('works registering loaders across virtual files', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + ...commonArgs, + '--eval', + commonEvals.import(fixtures.fileURL('es-module-loaders', 'register-programmatically-loader-load.mjs')) + + commonEvals.dynamicImport( + commonEvals.import(fixtures.fileURL('es-module-loaders', 'register-programmatically-loader-resolve.mjs')) + + commonEvals.dynamicImport('console.log("Hello from dynamic import");'), + ), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + + const lines = stdout.split('\n'); + + assert.match(lines[0], /resolve passthru/); + assert.match(lines[1], /load passthru/); + assert.match(lines[2], /Hello from dynamic import/); + + assert.strictEqual(lines[3], ''); + }); + + it('works registering the same loaders more them once', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + ...commonArgs, + '--eval', + "import { register } from 'node:module';" + + commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs')) + + commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-load-passthru.mjs')) + + commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs')) + + commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-load-passthru.mjs')) + + commonEvals.dynamicImport('console.log("Hello from dynamic import");'), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + + const lines = stdout.split('\n'); + + assert.match(lines[0], /resolve passthru/); + assert.match(lines[1], /resolve passthru/); + assert.match(lines[2], /load passthru/); + assert.match(lines[3], /load passthru/); + assert.match(lines[4], /Hello from dynamic import/); + + assert.strictEqual(lines[5], ''); + }); + + it('works registering loaders as package name', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + ...commonArgs, + '--eval', + "import { register } from 'node:module';" + + commonEvals.register('resolve', fixtures.fileURL('es-module-loaders', 'package.json')) + + commonEvals.register('load', fixtures.fileURL('es-module-loaders', 'package.json')) + + commonEvals.dynamicImport('console.log("Hello from dynamic import");'), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + + const lines = stdout.split('\n'); + + // Resolve occurs twice because it is first used to resolve the `load` loader + // _AND THEN_ the `register` module. + assert.match(lines[0], /resolve passthru/); + assert.match(lines[1], /resolve passthru/); + assert.match(lines[2], /load passthru/); + assert.match(lines[3], /Hello from dynamic import/); + + assert.strictEqual(lines[4], ''); + }); + + it('works without a CLI flag', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--input-type=module', + '--eval', + "import { register } from 'node:module';" + + commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-load-passthru.mjs')) + + commonEvals.dynamicImport('console.log("Hello from dynamic import");'), + ]); + + assert.strictEqual(stderr, ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + + const lines = stdout.split('\n'); + + assert.match(lines[0], /load passthru/); + assert.match(lines[1], /Hello from dynamic import/); + + assert.strictEqual(lines[2], ''); + }); + + it('does not work with a loader specifier that does not exist', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + ...commonArgs, + '--eval', + "import { register } from 'node:module';" + + commonEvals.register('./not-found.mjs', import.meta.url) + + commonEvals.dynamicImport('console.log("Hello from dynamic import");'), + ]); + + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + assert.match(stderr, /ERR_MODULE_NOT_FOUND/); + }); + + it('does not work with a loader that got syntax errors', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + ...commonArgs, + '--eval', + "import { register } from 'node:module';" + + commonEvals.register(fixtures.fileURL('es-module-loaders', 'syntax-error.mjs')) + + commonEvals.dynamicImport('console.log("Hello from dynamic import");'), + ]); + + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + assert.match(stderr, /SyntaxError/); + }); +}); diff --git a/test/es-module/test-esm-loader-resolve-type.mjs b/test/es-module/test-esm-loader-resolve-type.mjs index 482320c664c5d8..4c5e7aede8fccc 100644 --- a/test/es-module/test-esm-loader-resolve-type.mjs +++ b/test/es-module/test-esm-loader-resolve-type.mjs @@ -1,44 +1,46 @@ -// Flags: --loader ./test/fixtures/es-module-loaders/hook-resolve-type.mjs -import { allowGlobals } from '../common/index.mjs'; +import { spawnPromisified } from '../common/index.mjs'; +import * as tmpdir from '../common/tmpdir.js'; import * as fixtures from '../common/fixtures.mjs'; -import { strict as assert } from 'assert'; -import * as fs from 'fs'; - -allowGlobals(global.getModuleTypeStats); - -const { importedESM: importedESMBefore, - importedCJS: importedCJSBefore } = await global.getModuleTypeStats(); - -const basePath = - new URL('./node_modules/', import.meta.url); - -const rel = (file) => new URL(file, basePath); -const createDir = (path) => { - if (!fs.existsSync(path)) { - fs.mkdirSync(path); - } -}; +import { deepStrictEqual } from 'node:assert'; +import { mkdir, rm, cp } from 'node:fs/promises'; +import { execPath } from 'node:process'; +const base = tmpdir.fileURL(`test-esm-loader-resolve-type-${(Math.random() * Date.now()).toFixed(0)}`); const moduleName = 'module-counter-by-type'; -const moduleDir = rel(`${moduleName}`); +const moduleURL = new URL(`${base}/node_modules/${moduleName}`); try { - createDir(basePath); - createDir(moduleDir); - fs.cpSync( - fixtures.path('es-modules', moduleName), - moduleDir, + await mkdir(moduleURL, { recursive: true }); + await cp( + fixtures.path('es-modules', 'module-counter-by-type'), + moduleURL, { recursive: true } ); + const output = await spawnPromisified( + execPath, + [ + '--no-warnings', + '--input-type=module', + '--eval', + `import { getModuleTypeStats } from ${JSON.stringify(fixtures.fileURL('es-module-loaders', 'hook-resolve-type.mjs'))}; + const before = getModuleTypeStats(); + await import(${JSON.stringify(moduleName)}); + const after = getModuleTypeStats(); + console.log(JSON.stringify({ before, after }));`, + ], + { cwd: base }, + ); - await import(`${moduleName}`); + deepStrictEqual(output, { + code: 0, + signal: null, + stderr: '', + stdout: JSON.stringify({ + before: { importedESM: 0, importedCJS: 0 }, + // Dynamic import in the eval script should increment ESM counter but not CJS counter + after: { importedESM: 1, importedCJS: 0 }, + }) + '\n', + }); } finally { - fs.rmSync(basePath, { recursive: true, force: true }); + await rm(base, { recursive: true, force: true }); } - -const { importedESM: importedESMAfter, - importedCJS: importedCJSAfter } = await global.getModuleTypeStats(); - -// Dynamic import above should increment ESM counter but not CJS counter -assert.strictEqual(importedESMBefore + 1, importedESMAfter); -assert.strictEqual(importedCJSBefore, importedCJSAfter); diff --git a/test/es-module/test-esm-loader-search.js b/test/es-module/test-esm-loader-search.js index 0440d3d7775cff..3c451409b356db 100644 --- a/test/es-module/test-esm-loader-search.js +++ b/test/es-module/test-esm-loader-search.js @@ -10,8 +10,8 @@ const { defaultResolve: resolve } = require('internal/modules/esm/resolve'); -assert.rejects( - resolve('target'), +assert.throws( + () => resolve('target'), { code: 'ERR_MODULE_NOT_FOUND', name: 'Error', diff --git a/test/es-module/test-esm-loader-spawn-promisified.mjs b/test/es-module/test-esm-loader-spawn-promisified.mjs index 3a107ea64816b2..162316ade410b9 100644 --- a/test/es-module/test-esm-loader-spawn-promisified.mjs +++ b/test/es-module/test-esm-loader-spawn-promisified.mjs @@ -28,7 +28,7 @@ describe('Loader hooks throwing errors', { concurrency: true }, () => { fixtures.fileURL('/es-module-loaders/hooks-custom.mjs'), '--input-type=module', '--eval', - `import '${fixtures.fileURL('/es-modules/file.unknown')}'`, + `import ${JSON.stringify(fixtures.fileURL('/es-modules/file.unknown'))}`, ]); assert.match(stderr, /ERR_UNKNOWN_FILE_EXTENSION/); @@ -142,7 +142,7 @@ describe('Loader hooks throwing errors', { concurrency: true }, () => { `import assert from 'node:assert'; await Promise.allSettled([ import('nonexistent/file.mjs'), - import('${fixtures.fileURL('/es-modules/file.unknown')}'), + import(${JSON.stringify(fixtures.fileURL('/es-modules/file.unknown'))}), import('esmHook/badReturnVal.mjs'), import('esmHook/format.false'), import('esmHook/format.true'), @@ -170,7 +170,7 @@ describe('Loader hooks parsing modules', { concurrency: true }, () => { '--input-type=module', '--eval', `import assert from 'node:assert'; - await import('${fixtures.fileURL('/es-module-loaders/js-as-esm.js')}') + await import(${JSON.stringify(fixtures.fileURL('/es-module-loaders/js-as-esm.js'))}) .then((parsedModule) => { assert.strictEqual(typeof parsedModule, 'object'); assert.strictEqual(parsedModule.namedExport, 'named-export'); @@ -191,7 +191,7 @@ describe('Loader hooks parsing modules', { concurrency: true }, () => { '--input-type=module', '--eval', `import assert from 'node:assert'; - await import('${fixtures.fileURL('/es-modules/file.ext')}') + await import(${JSON.stringify(fixtures.fileURL('/es-modules/file.ext'))}) .then((parsedModule) => { assert.strictEqual(typeof parsedModule, 'object'); const { default: defaultExport } = parsedModule; @@ -258,7 +258,7 @@ describe('Loader hooks parsing modules', { concurrency: true }, () => { '--input-type=module', '--eval', `import assert from 'node:assert'; - await import('${fixtures.fileURL('/es-modules/stateful.mjs')}') + await import(${JSON.stringify(fixtures.fileURL('/es-modules/stateful.mjs'))}) .then(({ default: count }) => { assert.strictEqual(count(), 1); });`, diff --git a/test/es-module/test-esm-loader-with-syntax-error.mjs b/test/es-module/test-esm-loader-with-syntax-error.mjs index 0ed995ad510ee7..02a96b5b9470a8 100644 --- a/test/es-module/test-esm-loader-with-syntax-error.mjs +++ b/test/es-module/test-esm-loader-with-syntax-error.mjs @@ -13,7 +13,7 @@ describe('ESM: loader with syntax error', { concurrency: true }, () => { path('print-error-message.js'), ]); - match(stderr, /SyntaxError:/); + match(stderr, /SyntaxError \[Error\]:/); ok(!stderr.includes('Bad command or file name')); notStrictEqual(code, 0); }); diff --git a/test/es-module/test-esm-main-lookup.mjs b/test/es-module/test-esm-main-lookup.mjs index 4694d1c8e626e0..4f4f1c378914d7 100644 --- a/test/es-module/test-esm-main-lookup.mjs +++ b/test/es-module/test-esm-main-lookup.mjs @@ -1,24 +1,22 @@ -import '../common/index.mjs'; +import { mustNotCall } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; import assert from 'assert'; -async function main() { - let mod; - try { - mod = await import('../fixtures/es-modules/pjson-main'); - } catch (e) { - assert.strictEqual(e.code, 'ERR_UNSUPPORTED_DIR_IMPORT'); - } +Object.defineProperty(Error.prototype, 'url', { + get: mustNotCall('get %Error.prototype%.url'), + set: mustNotCall('set %Error.prototype%.url'), +}); +Object.defineProperty(Object.prototype, 'url', { + get: mustNotCall('get %Object.prototype%.url'), + set: mustNotCall('set %Object.prototype%.url'), +}); - assert.strictEqual(mod, undefined); +await assert.rejects(import('../fixtures/es-modules/pjson-main'), { + code: 'ERR_UNSUPPORTED_DIR_IMPORT', + url: fixtures.fileURL('es-modules/pjson-main').href, +}); - try { - mod = await import('../fixtures/es-modules/pjson-main/main.mjs'); - } catch (e) { - console.log(e); - assert.fail(); - } - - assert.strictEqual(mod.main, 'main'); -} - -main(); +assert.deepStrictEqual( + { ...await import('../fixtures/es-modules/pjson-main/main.mjs') }, + { main: 'main' }, +); diff --git a/test/es-module/test-esm-named-exports.mjs b/test/es-module/test-esm-named-exports.mjs index ce8599e68b1bf5..bbe9c96b92d9b8 100644 --- a/test/es-module/test-esm-named-exports.mjs +++ b/test/es-module/test-esm-named-exports.mjs @@ -1,4 +1,4 @@ -// Flags: --experimental-loader ./test/fixtures/es-module-loaders/builtin-named-exports-loader.mjs +// Flags: --import ./test/fixtures/es-module-loaders/builtin-named-exports.mjs import '../common/index.mjs'; import { readFile, __fromLoader } from 'fs'; import assert from 'assert'; diff --git a/test/es-module/test-esm-resolve-type.mjs b/test/es-module/test-esm-resolve-type.mjs index f594e56cf18510..7a0527ff59e554 100644 --- a/test/es-module/test-esm-resolve-type.mjs +++ b/test/es-module/test-esm-resolve-type.mjs @@ -37,16 +37,16 @@ try { * ensure that resolving by full path does not return the format * with the defaultResolver */ - await Promise.all([ + [ [ '/es-modules/package-type-module/index.js', 'module' ], [ '/es-modules/package-type-commonjs/index.js', 'commonjs' ], [ '/es-modules/package-without-type/index.js', 'commonjs' ], [ '/es-modules/package-without-pjson/index.js', 'commonjs' ], - ].map(async ([ testScript, expectedType ]) => { + ].forEach(([ testScript, expectedType ]) => { const resolvedPath = path.resolve(fixtures.path(testScript)); - const resolveResult = await resolve(url.pathToFileURL(resolvedPath)); + const resolveResult = resolve(url.pathToFileURL(resolvedPath)); assert.strictEqual(resolveResult.format, expectedType); - })); + }); /** * create a test module and try to resolve it by module name. @@ -85,7 +85,7 @@ try { fs.writeFileSync(script, 'export function esm-resolve-tester() {return 42}'); - const resolveResult = await resolve(`${moduleName}`); + const resolveResult = resolve(`${moduleName}`); assert.strictEqual(resolveResult.format, expectedResolvedType); fs.rmSync(nmDir, { recursive: true, force: true }); @@ -168,14 +168,14 @@ try { ); // test the resolve - const resolveResult = await resolve(`${moduleName}`); + const resolveResult = resolve(`${moduleName}`); assert.strictEqual(resolveResult.format, 'module'); assert.ok(resolveResult.url.includes('my-dual-package/es/index.js')); } // TestParameters are ModuleName, mainRequireScript, mainImportScript, // mainPackageType, subdirPkgJsonType, expectedResolvedFormat, mainSuffix - await Promise.all([ + [ [ 'mjs-mod-mod', 'index.js', 'index.mjs', 'module', 'module', 'module'], [ 'mjs-com-com', 'idx.js', 'idx.mjs', 'commonjs', 'commonjs', 'module'], [ 'mjs-mod-com', 'index.js', 'imp.mjs', 'module', 'commonjs', 'module'], @@ -186,7 +186,7 @@ try { [ 'hmod', 'index.js', 'imp.js', 'commonjs', 'module', 'module', '#Key'], [ 'qhmod', 'index.js', 'imp.js', 'commonjs', 'module', 'module', '?k=v#h'], [ 'ts-mod-com', 'index.js', 'imp.ts', 'module', 'commonjs', undefined], - ].map(async (testVariant) => { + ].forEach((testVariant) => { const [ moduleName, mainRequireScript, @@ -234,10 +234,10 @@ try { ); // test the resolve - const resolveResult = await resolve(`${moduleName}`); + const resolveResult = resolve(`${moduleName}`); assert.strictEqual(resolveResult.format, expectedResolvedFormat); assert.ok(resolveResult.url.endsWith(`${moduleName}/subdir/${mainImportScript}${mainSuffix}`)); - })); + }); } finally { process.chdir(previousCwd); diff --git a/test/es-module/test-esm-type-flag-errors.js b/test/es-module/test-esm-type-field-errors.js similarity index 100% rename from test/es-module/test-esm-type-flag-errors.js rename to test/es-module/test-esm-type-field-errors.js diff --git a/test/es-module/test-esm-type-flag.mjs b/test/es-module/test-esm-type-field.mjs similarity index 100% rename from test/es-module/test-esm-type-flag.mjs rename to test/es-module/test-esm-type-field.mjs diff --git a/test/es-module/test-esm-type-flag-cli-entry.mjs b/test/es-module/test-esm-type-flag-cli-entry.mjs new file mode 100644 index 00000000000000..002840751532ff --- /dev/null +++ b/test/es-module/test-esm-type-flag-cli-entry.mjs @@ -0,0 +1,92 @@ +import { spawnPromisified } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { describe, it } from 'node:test'; +import { match, strictEqual } from 'node:assert'; + +describe('--experimental-default-type=module should not support extension searching', { concurrency: true }, () => { + it('should support extension searching under --experimental-default-type=commonjs', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=commonjs', + 'index', + ], { + cwd: fixtures.path('es-modules/package-without-type'), + }); + + strictEqual(stdout, 'package-without-type\n'); + strictEqual(stderr, ''); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it('should error with implicit extension under --experimental-default-type=module', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + 'index', + ], { + cwd: fixtures.path('es-modules/package-without-type'), + }); + + match(stderr, /ENOENT.*Did you mean to import .*index\.js\?/s); + strictEqual(stdout, ''); + strictEqual(code, 1); + strictEqual(signal, null); + }); +}); + +describe('--experimental-default-type=module should not parse paths as URLs', { concurrency: true }, () => { + it('should not parse a `?` in a filename as starting a query string', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + 'file#1.js', + ], { + cwd: fixtures.path('es-modules/package-without-type'), + }); + + strictEqual(stderr, ''); + strictEqual(stdout, 'file#1\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it('should resolve `..`', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + '../package-without-type/file#1.js', + ], { + cwd: fixtures.path('es-modules/package-without-type'), + }); + + strictEqual(stderr, ''); + strictEqual(stdout, 'file#1\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it('should allow a leading `./`', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + './file#1.js', + ], { + cwd: fixtures.path('es-modules/package-without-type'), + }); + + strictEqual(stderr, ''); + strictEqual(stdout, 'file#1\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it('should not require a leading `./`', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + 'file#1.js', + ], { + cwd: fixtures.path('es-modules/package-without-type'), + }); + + strictEqual(stderr, ''); + strictEqual(stdout, 'file#1\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); +}); diff --git a/test/es-module/test-esm-type-flag-errors.mjs b/test/es-module/test-esm-type-flag-errors.mjs new file mode 100644 index 00000000000000..6d54eff94763ef --- /dev/null +++ b/test/es-module/test-esm-type-flag-errors.mjs @@ -0,0 +1,31 @@ +import { spawnPromisified } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { describe, it } from 'node:test'; +import { match, strictEqual } from 'node:assert'; + +describe('--experimental-default-type=module should not affect the interpretation of files with unknown extensions', + { concurrency: true }, () => { + it('should error on an entry point with an unknown extension', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + fixtures.path('es-modules/package-type-module/extension.unknown'), + ]); + + match(stderr, /ERR_UNKNOWN_FILE_EXTENSION/); + strictEqual(stdout, ''); + strictEqual(code, 1); + strictEqual(signal, null); + }); + + it('should error on an import with an unknown extension', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + fixtures.path('es-modules/package-type-module/imports-unknownext.mjs'), + ]); + + match(stderr, /ERR_UNKNOWN_FILE_EXTENSION/); + strictEqual(stdout, ''); + strictEqual(code, 1); + strictEqual(signal, null); + }); + }); diff --git a/test/es-module/test-esm-type-flag-loose-files.mjs b/test/es-module/test-esm-type-flag-loose-files.mjs new file mode 100644 index 00000000000000..ed95e1807f57c7 --- /dev/null +++ b/test/es-module/test-esm-type-flag-loose-files.mjs @@ -0,0 +1,75 @@ +// Flags: --experimental-default-type=module --experimental-wasm-modules +import { spawnPromisified } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +describe('the type flag should change the interpretation of certain files outside of any package scope', + { concurrency: true }, () => { + it('should run as ESM a .js file that is outside of any package scope', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + fixtures.path('es-modules/loose.js'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, 'executed\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it('should run as ESM an extensionless JavaScript file that is outside of any package scope', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + fixtures.path('es-modules/noext-esm'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, 'executed\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it('should run as Wasm an extensionless Wasm file that is outside of any package scope', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + '--experimental-wasm-modules', + '--no-warnings', + fixtures.path('es-modules/noext-wasm'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, ''); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it('should import as ESM a .js file that is outside of any package scope', async () => { + const { default: defaultExport } = await import(fixtures.fileURL('es-modules/loose.js')); + strictEqual(defaultExport, 'module'); + }); + + it('should import as ESM an extensionless JavaScript file that is outside of any package scope', + async () => { + const { default: defaultExport } = await import(fixtures.fileURL('es-modules/noext-esm')); + strictEqual(defaultExport, 'module'); + }); + + it('should import as Wasm an extensionless Wasm file that is outside of any package scope', async () => { + const { add } = await import(fixtures.fileURL('es-modules/noext-wasm')); + strictEqual(add(1, 2), 3); + }); + + it('should check as ESM input passed via --check', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + '--check', + fixtures.path('es-modules/loose.js'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, ''); + strictEqual(code, 0); + strictEqual(signal, null); + }); + }); diff --git a/test/es-module/test-esm-type-flag-package-scopes.mjs b/test/es-module/test-esm-type-flag-package-scopes.mjs new file mode 100644 index 00000000000000..bf9d7d7ca4944c --- /dev/null +++ b/test/es-module/test-esm-type-flag-package-scopes.mjs @@ -0,0 +1,167 @@ +// Flags: --experimental-default-type=module --experimental-wasm-modules +import { spawnPromisified } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +describe('the type flag should change the interpretation of certain files within a "type": "module" package scope', + { concurrency: true }, () => { + it('should run as ESM an extensionless JavaScript file within a "type": "module" scope', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + fixtures.path('es-modules/package-type-module/noext-esm'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, 'executed\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it('should import an extensionless JavaScript file within a "type": "module" scope', async () => { + const { default: defaultExport } = + await import(fixtures.fileURL('es-modules/package-type-module/noext-esm')); + strictEqual(defaultExport, 'module'); + }); + + it('should import an extensionless JavaScript file within a "type": "module" scope under node_modules', + async () => { + const { default: defaultExport } = + await import(fixtures.fileURL( + 'es-modules/package-type-module/node_modules/dep-with-package-json-type-module/noext-esm')); + strictEqual(defaultExport, 'module'); + }); + + it('should run as Wasm an extensionless Wasm file within a "type": "module" scope', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + '--experimental-wasm-modules', + '--no-warnings', + fixtures.path('es-modules/package-type-module/noext-wasm'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, 'executed\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it('should import as Wasm an extensionless Wasm file within a "type": "module" scope', async () => { + const { add } = await import(fixtures.fileURL('es-modules/package-type-module/noext-wasm')); + strictEqual(add(1, 2), 3); + }); + + it('should import an extensionless Wasm file within a "type": "module" scope under node_modules', + async () => { + const { add } = await import(fixtures.fileURL( + 'es-modules/package-type-module/node_modules/dep-with-package-json-type-module/noext-wasm')); + strictEqual(add(1, 2), 3); + }); + }); + +describe(`the type flag should change the interpretation of certain files within a package scope that lacks a +"type" field and is not under node_modules`, { concurrency: true }, () => { + it('should run as ESM a .js file within package scope that has no defined "type" and is not under node_modules', + async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + fixtures.path('es-modules/package-without-type/module.js'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, 'executed\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it(`should run as ESM an extensionless JavaScript file within a package scope that has no defined "type" and is not +under node_modules`, async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + fixtures.path('es-modules/package-without-type/noext-esm'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, 'executed\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it(`should run as Wasm an extensionless Wasm file within a package scope that has no defined "type" and is not under + node_modules`, async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + '--experimental-wasm-modules', + '--no-warnings', + fixtures.path('es-modules/noext-wasm'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, ''); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it('should import as ESM a .js file within package scope that has no defined "type" and is not under node_modules', + async () => { + const { default: defaultExport } = await import(fixtures.fileURL('es-modules/package-without-type/module.js')); + strictEqual(defaultExport, 'module'); + }); + + it(`should import as ESM an extensionless JavaScript file within a package scope that has no defined "type" and is + not under node_modules`, async () => { + const { default: defaultExport } = await import(fixtures.fileURL('es-modules/package-without-type/noext-esm')); + strictEqual(defaultExport, 'module'); + }); + + it(`should import as Wasm an extensionless Wasm file within a package scope that has no defined "type" and is not + under node_modules`, async () => { + const { add } = await import(fixtures.fileURL('es-modules/noext-wasm')); + strictEqual(add(1, 2), 3); + }); +}); + +describe(`the type flag should NOT change the interpretation of certain files within a package scope that lacks a +"type" field and is under node_modules`, { concurrency: true }, () => { + it('should run as CommonJS a .js file within package scope that has no defined "type" and is under node_modules', + async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + fixtures.path('es-modules/package-type-module/node_modules/dep-with-package-json-without-type/run.js'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, 'executed\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it(`should import as CommonJS a .js file within a package scope that has no defined "type" and is under + node_modules`, async () => { + const { default: defaultExport } = + await import(fixtures.fileURL( + 'es-modules/package-type-module/node_modules/dep-with-package-json-without-type/run.js')); + strictEqual(defaultExport, 42); + }); + + it(`should run as CommonJS an extensionless JavaScript file within a package scope that has no defined "type" and is + under node_modules`, async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + fixtures.path('es-modules/package-type-module/node_modules/dep-with-package-json-without-type/noext-cjs'), + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, 'executed\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + it(`should import as CommonJS an extensionless JavaScript file within a package scope that has no defined "type" and + is under node_modules`, async () => { + const { default: defaultExport } = + await import(fixtures.fileURL( + 'es-modules/package-type-module/node_modules/dep-with-package-json-without-type/noext-cjs')); + strictEqual(defaultExport, 42); + }); +}); diff --git a/test/es-module/test-esm-type-flag-string-input.mjs b/test/es-module/test-esm-type-flag-string-input.mjs new file mode 100644 index 00000000000000..c4236c00c4f28f --- /dev/null +++ b/test/es-module/test-esm-type-flag-string-input.mjs @@ -0,0 +1,44 @@ +import { spawnPromisified } from '../common/index.mjs'; +import { spawn } from 'node:child_process'; +import { describe, it } from 'node:test'; +import { strictEqual, match } from 'node:assert'; + +describe('the type flag should change the interpretation of string input', { concurrency: true }, () => { + it('should run as ESM input passed via --eval', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + '--eval', + 'import "data:text/javascript,console.log(42)"', + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, '42\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + // ESM is unsupported for --print via --input-type=module + + it('should run as ESM input passed via STDIN', async () => { + const child = spawn(process.execPath, [ + '--experimental-default-type=module', + ]); + child.stdin.end('console.log(typeof import.meta.resolve)'); + + match((await child.stdout.toArray()).toString(), /^function\r?\n$/); + }); + + it('should be overridden by --input-type', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-default-type=module', + '--input-type=commonjs', + '--eval', + 'console.log(require("process").version)', + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, `${process.version}\n`); + strictEqual(code, 0); + strictEqual(signal, null); + }); +}); diff --git a/test/es-module/test-esm-unknown-or-no-extension.js b/test/es-module/test-esm-unknown-extension.js similarity index 60% rename from test/es-module/test-esm-unknown-or-no-extension.js rename to test/es-module/test-esm-unknown-extension.js index 3f0660e5aa9225..dae9568c523fe5 100644 --- a/test/es-module/test-esm-unknown-or-no-extension.js +++ b/test/es-module/test-esm-unknown-extension.js @@ -7,14 +7,11 @@ const { execPath } = require('node:process'); const { describe, it } = require('node:test'); -// In a "type": "module" package scope, files with unknown extensions or no -// extensions should throw; both when used as a main entry point and also when -// referenced via `import`. -describe('ESM: extensionless and unknown specifiers', { concurrency: true }, () => { +// In a "type": "module" package scope, files with unknown extensions should throw; +// both when used as a main entry point and also when referenced via `import`. +describe('ESM: unknown specifiers', { concurrency: true }, () => { for ( const fixturePath of [ - '/es-modules/package-type-module/noext-esm', - '/es-modules/package-type-module/imports-noext.mjs', '/es-modules/package-type-module/extension.unknown', '/es-modules/package-type-module/imports-unknownext.mjs', ] @@ -26,11 +23,7 @@ describe('ESM: extensionless and unknown specifiers', { concurrency: true }, () assert.strictEqual(code, 1); assert.strictEqual(signal, null); assert.strictEqual(stdout, ''); - assert.ok(stderr.includes('ERR_UNKNOWN_FILE_EXTENSION')); - if (fixturePath.includes('noext')) { - // Check for explanation to users - assert.ok(stderr.includes('extensionless')); - } + assert.match(stderr, /ERR_UNKNOWN_FILE_EXTENSION/); }); } }); diff --git a/test/parallel/test-esm-url-extname.js b/test/es-module/test-esm-url-extname.js similarity index 100% rename from test/parallel/test-esm-url-extname.js rename to test/es-module/test-esm-url-extname.js diff --git a/test/es-module/test-esm-virtual-json.mjs b/test/es-module/test-esm-virtual-json.mjs new file mode 100644 index 00000000000000..8876eea013ced8 --- /dev/null +++ b/test/es-module/test-esm-virtual-json.mjs @@ -0,0 +1,30 @@ +import '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { register } from 'node:module'; +import assert from 'node:assert'; + +async function resolve(referrer, context, next) { + const result = await next(referrer, context); + const url = new URL(result.url); + url.searchParams.set('randomSeed', Math.random()); + result.url = url.href; + return result; +} + +function load(url, context, next) { + if (context.importAttributes.type === 'json') { + return { + shortCircuit: true, + format: 'json', + source: JSON.stringify({ data: Math.random() }), + }; + } + return next(url, context); +} + +register(`data:text/javascript,export ${encodeURIComponent(resolve)};export ${encodeURIComponent(load)}`); + +assert.notDeepStrictEqual( + await import(fixtures.fileURL('empty.json'), { assert: { type: 'json' } }), + await import(fixtures.fileURL('empty.json'), { assert: { type: 'json' } }), +); diff --git a/test/es-module/test-loaders-workers-spawned.mjs b/test/es-module/test-loaders-workers-spawned.mjs new file mode 100644 index 00000000000000..bcd651f5ad6c3f --- /dev/null +++ b/test/es-module/test-loaders-workers-spawned.mjs @@ -0,0 +1,83 @@ +import { spawnPromisified } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import assert from 'node:assert'; +import { execPath } from 'node:process'; +import { describe, it } from 'node:test'; + +describe('Worker threads do not spawn infinitely', { concurrency: true }, () => { + it('should not trigger an infinite loop when using a loader exports no recognized hooks', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + fixtures.fileURL('empty.js'), + '--eval', + 'setTimeout(() => console.log("hello"),99)', + ]); + + assert.strictEqual(stderr, ''); + assert.match(stdout, /^hello\r?\n$/); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should support a CommonJS entry point and a loader that imports a CommonJS module', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-loader', + fixtures.fileURL('es-module-loaders/loader-with-dep.mjs'), + fixtures.path('print-delayed.js'), + ]); + + assert.strictEqual(stderr, ''); + assert.match(stdout, /^delayed\r?\n$/); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should support --require and --import along with using a loader written in CJS and CJS entry point', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--eval', + 'setTimeout(() => console.log("D"),99)', + '--import', + fixtures.fileURL('printC.js'), + '--experimental-loader', + fixtures.fileURL('printB.js'), + '--require', + fixtures.path('printA.js'), + ]); + + assert.strictEqual(stderr, ''); + // We are validating that: + // 1. the `--require` flag is run first from the main thread (and A is printed). + // 2. the `--require` flag is then run on the loader thread (and A is printed). + // 3. the `--loader` module is executed (and B is printed). + // 4. the `--import` module is evaluated once, on the main thread (and C is printed). + // 5. the user code is finally executed (and D is printed). + // The worker code should always run before the --import, but the console.log might arrive late. + assert.match(stdout, /^A\r?\n(A\r?\nB\r?\nC|A\r?\nC\r?\nB|C\r?\nA\r?\nB)\r?\nD\r?\n$/); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should support --require and --import along with using a loader written in ESM and ESM entry point', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--require', + fixtures.path('printA.js'), + '--experimental-loader', + 'data:text/javascript,console.log("B")', + '--import', + fixtures.fileURL('printC.js'), + '--input-type=module', + '--eval', + 'setTimeout(() => console.log("D"),99)', + ]); + + assert.strictEqual(stderr, ''); + // The worker code should always run before the --import, but the console.log might arrive late. + assert.match(stdout, /^A\r?\nA\r?\n(B\r?\nC|C\r?\nB)\r?\nD\r?\n$/); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); +}); diff --git a/test/parallel/test-wasm-memory-out-of-bound.js b/test/es-module/test-wasm-memory-out-of-bound.js similarity index 100% rename from test/parallel/test-wasm-memory-out-of-bound.js rename to test/es-module/test-wasm-memory-out-of-bound.js diff --git a/test/parallel/test-wasm-simple.js b/test/es-module/test-wasm-simple.js similarity index 100% rename from test/parallel/test-wasm-simple.js rename to test/es-module/test-wasm-simple.js diff --git a/test/parallel/test-wasm-web-api.js b/test/es-module/test-wasm-web-api.js similarity index 100% rename from test/parallel/test-wasm-web-api.js rename to test/es-module/test-wasm-web-api.js diff --git a/test/fixtures/errors/force_colors.snapshot b/test/fixtures/errors/force_colors.snapshot index 08f0757ee0729c..3624d9f0804545 100644 --- a/test/fixtures/errors/force_colors.snapshot +++ b/test/fixtures/errors/force_colors.snapshot @@ -4,11 +4,11 @@ throw new Error('Should include grayed stack trace') Error: Should include grayed stack trace at Object. (/test*force_colors.js:1:7) - at Module._compile (node:internal*modules*cjs*loader:1245:14) - at Module._extensions..js (node:internal*modules*cjs*loader:1299:10) - at Module.load (node:internal*modules*cjs*loader:1103:32) - at Module._load (node:internal*modules*cjs*loader:950:12) - at Function.executeUserEntryPoint [as runMain] (node:internal*modules*run_main:88:12) - at node:internal*main*run_main_module:23:47 + at * + at * + at * + at * + at * + at * Node.js * diff --git a/test/fixtures/es-module-loaders/assertionless-json-import.mjs b/test/fixtures/es-module-loaders/assertionless-json-import.mjs index e7e7b20fd26798..3ffc4c1148fe69 100644 --- a/test/fixtures/es-module-loaders/assertionless-json-import.mjs +++ b/test/fixtures/es-module-loaders/assertionless-json-import.mjs @@ -2,20 +2,20 @@ const DATA_URL_PATTERN = /^data:application\/json(?:[^,]*?)(;base64)?,([\s\S]*)$ const JSON_URL_PATTERN = /^[^?]+\.json(\?[^#]*)?(#.*)?$/; export async function resolve(specifier, context, next) { - const noAssertionSpecified = context.importAssertions.type == null; + const noAttributesSpecified = context.importAttributes.type == null; // Mutation from resolve hook should be discarded. - context.importAssertions.type = 'whatever'; + context.importAttributes.type = 'whatever'; - // This fixture assumes that no other resolve hooks in the chain will error on invalid import assertions + // This fixture assumes that no other resolve hooks in the chain will error on invalid import attributes // (as defaultResolve doesn't). const result = await next(specifier, context); - if (noAssertionSpecified && + if (noAttributesSpecified && (DATA_URL_PATTERN.test(result.url) || JSON_URL_PATTERN.test(result.url))) { - // Clean new import assertions object to ensure that this test isn't passing due to mutation. - result.importAssertions = { - ...(result.importAssertions ?? context.importAssertions), + // Clean new import attributes object to ensure that this test isn't passing due to mutation. + result.importAttributes = { + ...(result.importAttributes ?? context.importAttributes), type: 'json', }; } diff --git a/test/fixtures/es-module-loaders/builtin-named-exports-loader.mjs b/test/fixtures/es-module-loaders/builtin-named-exports-loader.mjs index 8c317c1b7ce31e..7ee339b47029f9 100644 --- a/test/fixtures/es-module-loaders/builtin-named-exports-loader.mjs +++ b/test/fixtures/es-module-loaders/builtin-named-exports-loader.mjs @@ -1,16 +1,9 @@ -import module from 'module'; - -const GET_BUILTIN = `$__get_builtin_hole_${Date.now()}`; - -export function globalPreload() { - return `Object.defineProperty(globalThis, ${JSON.stringify(GET_BUILTIN)}, { - value: (builtinName) => { - return getBuiltin(builtinName); - }, - enumerable: false, - configurable: false, -}); -`; +import module from 'node:module'; + +/** @type {string} */ +let GET_BUILTIN; +export function initialize(data) { + GET_BUILTIN = data.GET_BUILTIN; } export async function resolve(specifier, context, next) { @@ -20,7 +13,7 @@ export async function resolve(specifier, context, next) { return { shortCircuit: true, url: `custom-${def.url}`, - importAssertions: context.importAssertions, + importAttributes: context.importAttributes, }; } return def; diff --git a/test/fixtures/es-module-loaders/builtin-named-exports.mjs b/test/fixtures/es-module-loaders/builtin-named-exports.mjs new file mode 100644 index 00000000000000..123b12c26bf0c9 --- /dev/null +++ b/test/fixtures/es-module-loaders/builtin-named-exports.mjs @@ -0,0 +1,17 @@ +import * as fixtures from '../../common/fixtures.mjs'; +import { createRequire, register } from 'node:module'; + +const require = createRequire(import.meta.url); + +const GET_BUILTIN = `$__get_builtin_hole_${Date.now()}`; +Object.defineProperty(globalThis, GET_BUILTIN, { + value: builtinName => require(builtinName), + enumerable: false, + configurable: false, +}); + +register(fixtures.fileURL('es-module-loaders/builtin-named-exports-loader.mjs'), { + data: { + GET_BUILTIN, + }, +}); diff --git a/test/fixtures/es-module-loaders/hook-resolve-type-loader.mjs b/test/fixtures/es-module-loaders/hook-resolve-type-loader.mjs new file mode 100644 index 00000000000000..c410401876e448 --- /dev/null +++ b/test/fixtures/es-module-loaders/hook-resolve-type-loader.mjs @@ -0,0 +1,25 @@ +/** @type {Uint8Array} */ +let data; +/** @type {number} */ +let ESM_MODULE_INDEX; +/** @type {number} */ +let CJS_MODULE_INDEX; + +export function initialize({ sab, ESM_MODULE_INDEX:e, CJS_MODULE_INDEX:c }) { + data = new Uint8Array(sab); + ESM_MODULE_INDEX = e; + CJS_MODULE_INDEX = c; +} + +export async function resolve(specifier, context, next) { + const nextResult = await next(specifier, context); + const { format } = nextResult; + + if (format === 'module' || specifier.endsWith('.mjs')) { + Atomics.add(data, ESM_MODULE_INDEX, 1); + } else if (format == null || format === 'commonjs') { + Atomics.add(data, CJS_MODULE_INDEX, 1); + } + + return nextResult; +} diff --git a/test/fixtures/es-module-loaders/hook-resolve-type.mjs b/test/fixtures/es-module-loaders/hook-resolve-type.mjs index a4d87938ad843f..7324a08e84b6c0 100644 --- a/test/fixtures/es-module-loaders/hook-resolve-type.mjs +++ b/test/fixtures/es-module-loaders/hook-resolve-type.mjs @@ -1,44 +1,18 @@ -let importedESM = 0; -let importedCJS = 0; +import * as fixtures from '../../common/fixtures.mjs'; +import { register } from 'node:module'; -export function globalPreload({ port }) { - port.on('message', (int32) => { - port.postMessage({ importedESM, importedCJS }); - Atomics.store(int32, 0, 1); - Atomics.notify(int32, 0); - }); - port.unref(); - return ` - const { receiveMessageOnPort } = getBuiltin('worker_threads'); - global.getModuleTypeStats = async function getModuleTypeStats() { - const sab = new SharedArrayBuffer(4); - const int32 = new Int32Array(sab); - port.postMessage(int32); - // Artificial timeout to keep the event loop alive. - // https://bugs.chromium.org/p/v8/issues/detail?id=13238 - // TODO(targos) Remove when V8 issue is resolved. - const timeout = setTimeout(() => { throw new Error('timeout'); }, 1_000); - await Atomics.waitAsync(int32, 0, 0).value; - clearTimeout(timeout); - return receiveMessageOnPort(port).message; - }; - `; -} - -export async function load(url, context, next) { - return next(url); -} - -export async function resolve(specifier, context, next) { - const nextResult = await next(specifier, context); - const { format } = nextResult; +const sab = new SharedArrayBuffer(2); +const data = new Uint8Array(sab); - if (format === 'module' || specifier.endsWith('.mjs')) { - importedESM++; - } else if (format == null || format === 'commonjs') { - importedCJS++; - } +const ESM_MODULE_INDEX = 0 +const CJS_MODULE_INDEX = 1 - return nextResult; +export function getModuleTypeStats() { + const importedESM = Atomics.load(data, ESM_MODULE_INDEX); + const importedCJS = Atomics.load(data, CJS_MODULE_INDEX); + return { importedESM, importedCJS }; } +register(fixtures.fileURL('es-module-loaders/hook-resolve-type-loader.mjs'), { + data: { sab, ESM_MODULE_INDEX, CJS_MODULE_INDEX }, +}); diff --git a/test/fixtures/es-module-loaders/hooks-custom.mjs b/test/fixtures/es-module-loaders/hooks-custom.mjs index ea2ffaf7e97070..5656f95232b856 100644 --- a/test/fixtures/es-module-loaders/hooks-custom.mjs +++ b/test/fixtures/es-module-loaders/hooks-custom.mjs @@ -6,7 +6,7 @@ import count from '../es-modules/stateful.mjs'; // used to assert node-land and user-land have different contexts count(); -export function resolve(specifier, { importAssertions }, next) { +export function resolve(specifier, { importAttributes }, next) { let format = ''; if (specifier === 'esmHook/format.false') { @@ -24,7 +24,7 @@ export function resolve(specifier, { importAssertions }, next) { format, shortCircuit: true, url: pathToFileURL(specifier).href, - importAssertions, + importAttributes, }; } diff --git a/test/fixtures/es-module-loaders/hooks-initialize-port.mjs b/test/fixtures/es-module-loaders/hooks-initialize-port.mjs new file mode 100644 index 00000000000000..cefe8854297c50 --- /dev/null +++ b/test/fixtures/es-module-loaders/hooks-initialize-port.mjs @@ -0,0 +1,16 @@ +let thePort = null; + +export async function initialize(port) { + port.postMessage('initialize'); + thePort = port; +} + +export async function resolve(specifier, context, next) { + if (specifier === 'node:fs' || specifier.includes('loader')) { + return next(specifier); + } + + thePort.postMessage(`resolve ${specifier}`); + + return next(specifier); +} diff --git a/test/fixtures/es-module-loaders/hooks-initialize.mjs b/test/fixtures/es-module-loaders/hooks-initialize.mjs new file mode 100644 index 00000000000000..7622d982a9d7c5 --- /dev/null +++ b/test/fixtures/es-module-loaders/hooks-initialize.mjs @@ -0,0 +1,7 @@ +import { writeFileSync } from 'node:fs'; + +let counter = 0; + +export async function initialize() { + writeFileSync(1, `hooks initialize ${++counter}\n`); +} diff --git a/test/fixtures/es-module-loaders/hooks-input.mjs b/test/fixtures/es-module-loaders/hooks-input.mjs index 6859cfc07d9b6a..1d3759f458224e 100644 --- a/test/fixtures/es-module-loaders/hooks-input.mjs +++ b/test/fixtures/es-module-loaders/hooks-input.mjs @@ -2,6 +2,7 @@ // node --loader ./test/fixtures/es-module-loaders/hooks-input.mjs ./test/fixtures/es-modules/json-modules.mjs import assert from 'assert'; +import { writeSync } from 'fs'; import { readFile } from 'fs/promises'; import { fileURLToPath } from 'url'; @@ -16,16 +17,17 @@ export async function resolve(specifier, context, next) { if (resolveCalls === 1) { url = new URL(specifier).href; assert.match(specifier, /json-modules\.mjs$/); - assert.strictEqual(context.parentURL, undefined); - assert.deepStrictEqual(context.importAssertions, { - __proto__: null, - }); + + if (!(/\[eval\d*\]$/).test(context.parentURL)) { + assert.strictEqual(context.parentURL, undefined); + } + + assert.deepStrictEqual(context.importAttributes, {}); } else if (resolveCalls === 2) { url = new URL(specifier, context.parentURL).href; assert.match(specifier, /experimental\.json$/); assert.match(context.parentURL, /json-modules\.mjs$/); - assert.deepStrictEqual(context.importAssertions, { - __proto__: null, + assert.deepStrictEqual(context.importAttributes, { type: 'json', }); } @@ -33,7 +35,7 @@ export async function resolve(specifier, context, next) { // Ensure `context` has all and only the properties it's supposed to assert.deepStrictEqual(Reflect.ownKeys(context), [ 'conditions', - 'importAssertions', + 'importAttributes', 'parentURL', ]); assert.ok(Array.isArray(context.conditions)); @@ -45,7 +47,7 @@ export async function resolve(specifier, context, next) { shortCircuit: true, } - console.log(JSON.stringify(returnValue)); // For the test to validate when it parses stdout + writeSync(1, JSON.stringify(returnValue) + '\n'); // For the test to validate when it parses stdout return returnValue; } @@ -57,14 +59,11 @@ export async function load(url, context, next) { if (loadCalls === 1) { assert.match(url, /json-modules\.mjs$/); - assert.deepStrictEqual(context.importAssertions, { - __proto__: null, - }); + assert.deepStrictEqual(context.importAttributes, {}); format = 'module'; } else if (loadCalls === 2) { assert.match(url, /experimental\.json$/); - assert.deepStrictEqual(context.importAssertions, { - __proto__: null, + assert.deepStrictEqual(context.importAttributes, { type: 'json', }); format = 'json'; @@ -74,7 +73,7 @@ export async function load(url, context, next) { // Ensure `context` has all and only the properties it's supposed to assert.deepStrictEqual(Object.keys(context), [ 'format', - 'importAssertions', + 'importAttributes', ]); assert.strictEqual(context.format, 'test'); assert.strictEqual(typeof next, 'function'); @@ -85,7 +84,7 @@ export async function load(url, context, next) { shortCircuit: true, }; - console.log(JSON.stringify(returnValue)); // For the test to validate when it parses stdout + writeSync(1, JSON.stringify(returnValue) + '\n'); // For the test to validate when it parses stdout return returnValue; } diff --git a/test/fixtures/es-module-loaders/hooks-obsolete.mjs b/test/fixtures/es-module-loaders/hooks-obsolete.mjs deleted file mode 100644 index bb10ef8ef4b29a..00000000000000 --- a/test/fixtures/es-module-loaders/hooks-obsolete.mjs +++ /dev/null @@ -1,22 +0,0 @@ -export function dynamicInstantiate() {} -export function getFormat() {} -export function getSource() {} -export function transformSource() {} - - -export function resolve(specifier, context, next) { - if (specifier === 'whatever') return { - url: specifier, - }; - - return next(specifier); -} - -export function load(url, context, next) { - if (url === 'whatever') return { - format: 'module', - source: '', - }; - - return next(url); -} diff --git a/test/fixtures/es-module-loaders/loader-edge-cases.mjs b/test/fixtures/es-module-loaders/loader-edge-cases.mjs index f50df0988194e4..19af0e2bc078c0 100644 --- a/test/fixtures/es-module-loaders/loader-edge-cases.mjs +++ b/test/fixtures/es-module-loaders/loader-edge-cases.mjs @@ -1,15 +1,13 @@ import { strictEqual } from "node:assert"; import { isMainThread, workerData, parentPort } from "node:worker_threads"; -// TODO(aduh95): switch this to `false` when loader hooks are run on a separate thread. -strictEqual(isMainThread, true); +strictEqual(isMainThread, false); // We want to make sure that internals are not leaked on the public module: strictEqual(workerData, null); strictEqual(parentPort, null); -// TODO(aduh95): switch to `"undefined"` when loader hooks are run on a separate thread. // We don't want `import.meta.resolve` being available from loaders // as the sync implementation is not compatible with calling async // functions on the same thread. -strictEqual(typeof import.meta.resolve, 'function'); +strictEqual(typeof import.meta.resolve, 'undefined'); diff --git a/test/fixtures/es-module-loaders/loader-invalid-format.mjs b/test/fixtures/es-module-loaders/loader-invalid-format.mjs index e7dd06c108ba1d..dc61a792b2be8b 100644 --- a/test/fixtures/es-module-loaders/loader-invalid-format.mjs +++ b/test/fixtures/es-module-loaders/loader-invalid-format.mjs @@ -1,4 +1,4 @@ -export async function resolve(specifier, { parentURL, importAssertions }, next) { +export async function resolve(specifier, { parentURL, importAttributes }, next) { if (parentURL && specifier === '../fixtures/es-modules/test-esm-ok.mjs') { return { shortCircuit: true, diff --git a/test/fixtures/es-module-loaders/loader-invalid-url.mjs b/test/fixtures/es-module-loaders/loader-invalid-url.mjs index a54f39521f29ac..aac2b16b6f58fe 100644 --- a/test/fixtures/es-module-loaders/loader-invalid-url.mjs +++ b/test/fixtures/es-module-loaders/loader-invalid-url.mjs @@ -1,9 +1,9 @@ -export async function resolve(specifier, { parentURL, importAssertions }, next) { +export async function resolve(specifier, { parentURL, importAttributes }, next) { if (parentURL && specifier === '../fixtures/es-modules/test-esm-ok.mjs') { return { shortCircuit: true, url: specifier, - importAssertions, + importAttributes, }; } return next(specifier); diff --git a/test/fixtures/es-module-loaders/loader-load-dynamic-import.mjs b/test/fixtures/es-module-loaders/loader-load-dynamic-import.mjs new file mode 100644 index 00000000000000..96af5507d17212 --- /dev/null +++ b/test/fixtures/es-module-loaders/loader-load-dynamic-import.mjs @@ -0,0 +1,14 @@ +import { writeSync } from 'node:fs'; + + +export async function load(url, context, next) { + if (url === 'node:fs' || url.includes('loader')) { + return next(url); + } + + // Here for asserting dynamic import + await import('xxx/loader-load-passthru.mjs'); + + writeSync(1, 'load dynamic import' + '\n'); // Signal that this specific hook ran + return next(url, context); +} diff --git a/test/fixtures/es-module-loaders/loader-load-foo-or-42.mjs b/test/fixtures/es-module-loaders/loader-load-foo-or-42.mjs index 8f850e82bef54b..285b81a910ef5d 100644 --- a/test/fixtures/es-module-loaders/loader-load-foo-or-42.mjs +++ b/test/fixtures/es-module-loaders/loader-load-foo-or-42.mjs @@ -3,7 +3,7 @@ export async function load(url, context, next) { // resolution from follow-up loaders. It wouldn't be a problem // in real life because loaders aren't supposed to break the // resolution, but the ones used in our tests do, for convenience. - if (url.includes('loader')) { + if (url === 'node:fs' || url.includes('loader')) { return next(url); } diff --git a/test/fixtures/es-module-loaders/loader-load-incomplete.mjs b/test/fixtures/es-module-loaders/loader-load-incomplete.mjs index a4bf3531f3d225..bf6d5ea254dcc6 100644 --- a/test/fixtures/es-module-loaders/loader-load-incomplete.mjs +++ b/test/fixtures/es-module-loaders/loader-load-incomplete.mjs @@ -3,7 +3,7 @@ export async function load(url, context, next) { // resolution from follow-up loaders. It wouldn't be a problem // in real life because loaders aren't supposed to break the // resolution, but the ones used in our tests do, for convenience. - if (url.includes('loader')) { + if (url === 'node:fs' || url.includes('loader')) { return next(url); } diff --git a/test/fixtures/es-module-loaders/loader-load-passthru.mjs b/test/fixtures/es-module-loaders/loader-load-passthru.mjs index 1c2f2ea4487cdf..72ff6b56122d77 100644 --- a/test/fixtures/es-module-loaders/loader-load-passthru.mjs +++ b/test/fixtures/es-module-loaders/loader-load-passthru.mjs @@ -1,12 +1,14 @@ +import { writeSync } from 'node:fs'; + export async function load(url, context, next) { // This check is needed to make sure that we don't prevent the // resolution from follow-up loaders. It wouldn't be a problem // in real life because loaders aren't supposed to break the // resolution, but the ones used in our tests do, for convenience. - if (url.includes('loader')) { + if (url === 'node:fs' || url.includes('loader')) { return next(url); } - console.log('load passthru'); // This log is deliberate + writeSync(1, 'load passthru' + '\n'); // Signal that this specific hook ran return next(url); } diff --git a/test/fixtures/es-module-loaders/loader-load-receiving-modified-context.mjs b/test/fixtures/es-module-loaders/loader-load-receiving-modified-context.mjs index 2d7bc350bd8775..6e5a1ee7193652 100644 --- a/test/fixtures/es-module-loaders/loader-load-receiving-modified-context.mjs +++ b/test/fixtures/es-module-loaders/loader-load-receiving-modified-context.mjs @@ -1,4 +1,7 @@ +import { writeSync } from 'node:fs'; + + export async function load(url, context, next) { - console.log(context.foo); // This log is deliberate + writeSync(1, context.foo + '\n'); // Expose actual value the hook was called with return next(url, context); } diff --git a/test/fixtures/es-module-loaders/loader-log-args.mjs b/test/fixtures/es-module-loaders/loader-log-args.mjs index 84ed373d6b4de4..b56fddbce5854d 100644 --- a/test/fixtures/es-module-loaders/loader-log-args.mjs +++ b/test/fixtures/es-module-loaders/loader-log-args.mjs @@ -1,10 +1,13 @@ +import { writeSync } from 'node:fs'; +import { inspect } from 'node:util' + export async function resolve(...args) { - console.log(`resolve arg count: ${args.length}`); - console.log({ + writeSync(1, `resolve arg count: ${args.length}\n`); + writeSync(1, inspect({ specifier: args[0], context: args[1], next: args[2], - }); + }) + '\n'); return { shortCircuit: true, @@ -13,12 +16,12 @@ export async function resolve(...args) { } export async function load(...args) { - console.log(`load arg count: ${args.length}`); - console.log({ + writeSync(1, `load arg count: ${args.length}\n`); + writeSync(1, inspect({ url: args[0], context: args[1], next: args[2], - }); + }) + '\n'); return { format: 'module', diff --git a/test/fixtures/es-module-loaders/loader-resolve-42.mjs b/test/fixtures/es-module-loaders/loader-resolve-42.mjs index 05ef2f68390bd1..b571a27a9787e9 100644 --- a/test/fixtures/es-module-loaders/loader-resolve-42.mjs +++ b/test/fixtures/es-module-loaders/loader-resolve-42.mjs @@ -1,14 +1,17 @@ +import { writeSync } from 'node:fs'; + + export async function resolve(specifier, context, next) { // This check is needed to make sure that we don't prevent the // resolution from follow-up loaders. It wouldn't be a problem // in real life because loaders aren't supposed to break the // resolution, but the ones used in our tests do, for convenience. - if (specifier.includes('loader')) { + if (specifier === 'node:fs' || specifier.includes('loader')) { return next(specifier); } - console.log('resolve 42'); // This log is deliberate - console.log('next:', next.name); // This log is deliberate + writeSync(1, 'resolve 42' + '\n'); // Signal that this specific hook ran + writeSync(1, `next: ${next.name}\n`); // Expose actual value the hook was called with return next('file:///42.mjs'); } diff --git a/test/fixtures/es-module-loaders/loader-resolve-dynamic-import.mjs b/test/fixtures/es-module-loaders/loader-resolve-dynamic-import.mjs new file mode 100644 index 00000000000000..edc2303ed9aa9e --- /dev/null +++ b/test/fixtures/es-module-loaders/loader-resolve-dynamic-import.mjs @@ -0,0 +1,14 @@ +import { writeSync } from 'node:fs'; + + +export async function resolve(specifier, context, next) { + if (specifier === 'node:fs' || specifier.includes('loader')) { + return next(specifier); + } + + // Here for asserting dynamic import + await import('xxx/loader-resolve-passthru.mjs'); + + writeSync(1, 'resolve dynamic import' + '\n'); // Signal that this specific hook ran + return next(specifier); +} diff --git a/test/fixtures/es-module-loaders/loader-resolve-foo.mjs b/test/fixtures/es-module-loaders/loader-resolve-foo.mjs index 09d30e952a2a92..b5e5a419b99d45 100644 --- a/test/fixtures/es-module-loaders/loader-resolve-foo.mjs +++ b/test/fixtures/es-module-loaders/loader-resolve-foo.mjs @@ -1,12 +1,15 @@ +import { writeSync } from 'node:fs'; + + export async function resolve(specifier, context, next) { // This check is needed to make sure that we don't prevent the // resolution from follow-up loaders. It wouldn't be a problem // in real life because loaders aren't supposed to break the // resolution, but the ones used in our tests do, for convenience. - if (specifier.includes('loader')) { + if (specifier === 'node:fs' || specifier.includes('loader')) { return next(specifier); } - console.log('resolve foo'); // This log is deliberate + writeSync(1, 'resolve foo' + '\n'); // Signal that this specific hook ran return next('file:///foo.mjs'); } diff --git a/test/fixtures/es-module-loaders/loader-resolve-incomplete.mjs b/test/fixtures/es-module-loaders/loader-resolve-incomplete.mjs index d00b8fc50134e1..cb37a43527186c 100644 --- a/test/fixtures/es-module-loaders/loader-resolve-incomplete.mjs +++ b/test/fixtures/es-module-loaders/loader-resolve-incomplete.mjs @@ -3,7 +3,7 @@ export async function resolve(specifier, context, next) { // resolution from follow-up loaders. It wouldn't be a problem // in real life because loaders aren't supposed to break the // resolution, but the ones used in our tests do, for convenience. - if (specifier.includes('loader')) { + if (specifier === 'node:fs' || specifier.includes('loader')) { return next(specifier); } diff --git a/test/fixtures/es-module-loaders/loader-resolve-next-modified.mjs b/test/fixtures/es-module-loaders/loader-resolve-next-modified.mjs index ac5ad5b1fe020d..a6b8e85455d31c 100644 --- a/test/fixtures/es-module-loaders/loader-resolve-next-modified.mjs +++ b/test/fixtures/es-module-loaders/loader-resolve-next-modified.mjs @@ -3,7 +3,7 @@ export async function resolve(url, context, next) { // resolution from follow-up loaders. It wouldn't be a problem // in real life because loaders aren't supposed to break the // resolution, but the ones used in our tests do, for convenience. - if (url.includes('loader')) { + if (url === 'node:fs' || url.includes('loader')) { return next(url); } diff --git a/test/fixtures/es-module-loaders/loader-resolve-passthru.mjs b/test/fixtures/es-module-loaders/loader-resolve-passthru.mjs index d4845acc9f5f71..f388f0db273d02 100644 --- a/test/fixtures/es-module-loaders/loader-resolve-passthru.mjs +++ b/test/fixtures/es-module-loaders/loader-resolve-passthru.mjs @@ -1,12 +1,14 @@ +import { writeSync } from 'node:fs'; + export async function resolve(specifier, context, next) { // This check is needed to make sure that we don't prevent the // resolution from follow-up loaders. It wouldn't be a problem // in real life because loaders aren't supposed to break the // resolution, but the ones used in our tests do, for convenience. - if (specifier.includes('loader')) { + if (specifier === 'node:fs' || specifier.includes('loader')) { return next(specifier); } - console.log('resolve passthru'); // This log is deliberate + writeSync(1, 'resolve passthru' + '\n'); // Signal that this specific hook ran return next(specifier); } diff --git a/test/fixtures/es-module-loaders/loader-resolve-receiving-modified-context.mjs b/test/fixtures/es-module-loaders/loader-resolve-receiving-modified-context.mjs index 83aa83104e96e4..f6a8fdad53ece3 100644 --- a/test/fixtures/es-module-loaders/loader-resolve-receiving-modified-context.mjs +++ b/test/fixtures/es-module-loaders/loader-resolve-receiving-modified-context.mjs @@ -1,4 +1,7 @@ +import { writeSync } from 'node:fs'; + + export async function resolve(specifier, context, next) { - console.log(context.foo); // This log is deliberate + writeSync(1, context.foo + '\n'); // Expose actual value the hook was called with return next(specifier, context); } diff --git a/test/fixtures/es-module-loaders/loader-resolve-shortcircuit.mjs b/test/fixtures/es-module-loaders/loader-resolve-shortcircuit.mjs index e1a357b4ab48f9..00c18e75e2c7c5 100644 --- a/test/fixtures/es-module-loaders/loader-resolve-shortcircuit.mjs +++ b/test/fixtures/es-module-loaders/loader-resolve-shortcircuit.mjs @@ -3,7 +3,7 @@ export async function resolve(specifier, context, next) { // resolution from follow-up loaders. It wouldn't be a problem // in real life because loaders aren't supposed to break the // resolution, but the ones used in our tests do, for convenience. - if (specifier.includes('loader')) { + if (specifier === 'node:fs' || specifier.includes('loader')) { return next(specifier); } diff --git a/test/fixtures/es-module-loaders/loader-resolve-strip-xxx.mjs b/test/fixtures/es-module-loaders/loader-resolve-strip-xxx.mjs index 58d7a1107994fe..996dfd041c0df6 100644 --- a/test/fixtures/es-module-loaders/loader-resolve-strip-xxx.mjs +++ b/test/fixtures/es-module-loaders/loader-resolve-strip-xxx.mjs @@ -1,4 +1,10 @@ +import { writeSync } from 'node:fs'; +import { inspect } from 'node:util'; + export async function resolve(specifier, context, nextResolve) { - console.log(`loader-a`, {specifier}); + if (specifier.startsWith('node:')) { + return nextResolve(specifier); + } + writeSync(1, `loader-a ${inspect({specifier})}\n`); return nextResolve(specifier.replace(/^xxx\//, `./`)); } diff --git a/test/fixtures/es-module-loaders/loader-resolve-strip-yyy.mjs b/test/fixtures/es-module-loaders/loader-resolve-strip-yyy.mjs index 3615926143c4c7..7746469bf817eb 100644 --- a/test/fixtures/es-module-loaders/loader-resolve-strip-yyy.mjs +++ b/test/fixtures/es-module-loaders/loader-resolve-strip-yyy.mjs @@ -1,4 +1,7 @@ +import { writeSync } from 'node:fs'; +import { inspect } from 'node:util'; + export async function resolve(specifier, context, nextResolve) { - console.log(`loader-b`, {specifier}); + writeSync(1, `loader-b ${inspect({specifier})}\n`); return nextResolve(specifier.replace(/^yyy\//, `./`)); } diff --git a/test/fixtures/es-module-loaders/loader-this-value-inside-hook-functions.mjs b/test/fixtures/es-module-loaders/loader-this-value-inside-hook-functions.mjs index c1c80622feea66..2be18c4969ef80 100644 --- a/test/fixtures/es-module-loaders/loader-this-value-inside-hook-functions.mjs +++ b/test/fixtures/es-module-loaders/loader-this-value-inside-hook-functions.mjs @@ -1,14 +1,21 @@ +export function initialize() { + if (this != null) { + throw new Error('hook function must not be bound to loader instance'); + } +} + export function resolve(url, _, next) { - if (this != null) throw new Error('hook function must not be bound to ESMLoader instance'); + if (this != null) { + throw new Error('hook function must not be bound to loader instance'); + } + return next(url); } export function load(url, _, next) { - if (this != null) throw new Error('hook function must not be bound to ESMLoader instance'); - return next(url); -} + if (this != null) { + throw new Error('hook function must not be bound to loader instance'); + } -export function globalPreload() { - if (this != null) throw new Error('hook function must not be bound to ESMLoader instance'); - return ""; + return next(url); } diff --git a/test/fixtures/es-module-loaders/loader-with-dep.mjs b/test/fixtures/es-module-loaders/loader-with-dep.mjs index 1b5fd6c3c1642a..625341eaed7eb2 100644 --- a/test/fixtures/es-module-loaders/loader-with-dep.mjs +++ b/test/fixtures/es-module-loaders/loader-with-dep.mjs @@ -3,9 +3,9 @@ import {createRequire} from '../../common/index.mjs'; const require = createRequire(import.meta.url); const dep = require('./loader-dep.js'); -export async function resolve(specifier, { parentURL, importAssertions }, defaultResolve) { +export async function resolve(specifier, { parentURL, importAttributes }, defaultResolve) { return { - url: (await defaultResolve(specifier, { parentURL, importAssertions }, defaultResolve)).url, + url: (await defaultResolve(specifier, { parentURL, importAttributes }, defaultResolve)).url, format: dep.format }; } diff --git a/test/fixtures/es-module-loaders/mock-loader.mjs b/test/fixtures/es-module-loaders/mock-loader.mjs new file mode 100644 index 00000000000000..3bb349b5385362 --- /dev/null +++ b/test/fixtures/es-module-loaders/mock-loader.mjs @@ -0,0 +1,133 @@ +import { receiveMessageOnPort } from 'node:worker_threads'; +const mockedModuleExports = new Map(); +let currentMockVersion = 0; + +// These hooks enable code running on the application thread to +// swap module resolution results for mocking purposes. It uses this instead +// of import.meta so that CommonJS can still use the functionality. +// +// It does so by allowing non-mocked modules to live in normal URL cache +// locations but creates 'mock-facade:' URL cache location for every time a +// module location is mocked. Since a single URL can be mocked multiple +// times but it cannot be removed from the cache, `mock-facade:` URLs have a +// form of mock-facade:$VERSION:$REPLACING_URL with the parameters being URL +// percent encoded every time a module is resolved. So if a module for +// 'file:///app.js' is mocked it might look like +// 'mock-facade:12:file%3A%2F%2F%2Fapp.js'. This encoding is done to prevent +// problems like mocking URLs with special URL characters like '#' or '?' from +// accidentally being picked up as part of the 'mock-facade:' URL containing +// the mocked URL. +// +// NOTE: due to ESM spec, once a specifier has been resolved in a source text +// it cannot be changed. So things like the following DO NOT WORK: +// +// ```mjs +// import mock from 'test-esm-loader-mock'; // See test-esm-loader-mock.mjs +// mock('file:///app.js', {x:1}); +// const namespace1 = await import('file:///app.js'); +// namespace1.x; // 1 +// mock('file:///app.js', {x:2}); +// const namespace2 = await import('file:///app.js'); +// namespace2.x; // STILL 1, because this source text already set the specifier +// // for 'file:///app.js', a different specifier that resolves +// // to that could still get a new namespace though +// assert(namespace1 === namespace2); +// ``` + +/** + * @param param0 message from the application context + */ +function onPreloadPortMessage({ + mockVersion, resolved, exports +}) { + currentMockVersion = mockVersion; + mockedModuleExports.set(resolved, exports); +} + +/** @type {URL['href']} */ +let mainImportURL; +/** @type {MessagePort} */ +let preloadPort; +export async function initialize(data) { + ({ mainImportURL, port: preloadPort } = data); + + data.port.on('message', onPreloadPortMessage); +} + +/** + * Because Node.js internals use a separate MessagePort for cross-thread + * communication, there could be some messages pending that we should handle + * before continuing. + */ +function doDrainPort() { + let msg; + while (msg = receiveMessageOnPort(preloadPort)) { + onPreloadPortMessage(msg.message); + } +} + +// Rewrites node: loading to mock-facade: so that it can be intercepted +export async function resolve(specifier, context, defaultResolve) { + doDrainPort(); + const def = await defaultResolve(specifier, context); + if (context.parentURL?.startsWith('mock-facade:')) { + // Do nothing, let it get the "real" module + } else if (mockedModuleExports.has(def.url)) { + return { + shortCircuit: true, + url: `mock-facade:${currentMockVersion}:${encodeURIComponent(def.url)}` + }; + }; + return { + shortCircuit: true, + url: def.url, + }; +} + +export async function load(url, context, defaultLoad) { + doDrainPort(); + /** + * Mocked fake module, not going to be handled in default way so it + * generates the source text, then short circuits + */ + if (url.startsWith('mock-facade:')) { + const encodedTargetURL = url.slice(url.lastIndexOf(':') + 1); + return { + shortCircuit: true, + source: generateModule(encodedTargetURL), + format: 'module', + }; + } + return defaultLoad(url, context); +} + +/** + * Generate the source code for a mocked module. + * @param {string} encodedTargetURL the module being mocked + * @returns {string} + */ +function generateModule(encodedTargetURL) { + const exports = mockedModuleExports.get( + decodeURIComponent(encodedTargetURL) + ); + let body = [ + `import { mockedModules } from ${JSON.stringify(mainImportURL)};`, + 'export {};', + 'let mapping = {__proto__: null};', + `const mock = mockedModules.get(${JSON.stringify(encodedTargetURL)});`, + ]; + for (const [i, name] of Object.entries(exports)) { + let key = JSON.stringify(name); + body.push(`var _${i} = mock.namespace[${key}];`); + body.push(`Object.defineProperty(mapping, ${key}, { enumerable: true, set(v) {_${i} = v;}, get() {return _${i};} });`); + body.push(`export {_${i} as ${name}};`); + } + body.push(`mock.listeners.push(${ + () => { + for (var k in mapping) { + mapping[k] = mock.namespace[k]; + } + } + });`); + return body.join('\n'); +} diff --git a/test/fixtures/es-module-loaders/mock.mjs b/test/fixtures/es-module-loaders/mock.mjs new file mode 100644 index 00000000000000..cb167f1d5204c7 --- /dev/null +++ b/test/fixtures/es-module-loaders/mock.mjs @@ -0,0 +1,70 @@ +import { register } from 'node:module'; +import { MessageChannel } from 'node:worker_threads'; + + +const { port1, port2 } = new MessageChannel(); + +register('./mock-loader.mjs', import.meta.url, { + data: { + port: port2, + mainImportURL: import.meta.url, + }, + transferList: [port2], +}); + +/** + * This is the Map that saves *all* the mocked URL -> replacement Module + * mappings + * @type {Map} + */ +export const mockedModules = new Map(); +let mockVersion = 0; + +/** + * @param {string} resolved an absolute URL HREF string + * @param {object} replacementProperties an object to pick properties from + * to act as a module namespace + * @returns {object} a mutator object that can update the module namespace + * since we can't do something like old Object.observe + */ +export function mock(resolved, replacementProperties) { + const exportNames = Object.keys(replacementProperties); + const namespace = { __proto__: null }; + /** + * @type {Array<(name: string)=>void>} functions to call whenever an + * export name is updated + */ + const listeners = []; + for (const name of exportNames) { + let currentValueForPropertyName = replacementProperties[name]; + Object.defineProperty(namespace, name, { + __proto__: null, + enumerable: true, + get() { + return currentValueForPropertyName; + }, + set(v) { + currentValueForPropertyName = v; + for (const fn of listeners) { + try { + fn(name); + } catch { + /* noop */ + } + } + }, + }); + } + mockedModules.set(encodeURIComponent(resolved), { + namespace, + listeners, + }); + mockVersion++; + // Inform the loader that the `resolved` URL should now use the specific + // `mockVersion` and has export names of `exportNames` + // + // This allows the loader to generate a fake module for that version + // and names the next time it resolves a specifier to equal `resolved` + port1.postMessage({ mockVersion, resolved, exports: exportNames }); + return namespace; +} diff --git a/test/fixtures/es-module-loaders/never-settling-resolve-step/import.meta.never-resolve.mjs b/test/fixtures/es-module-loaders/never-settling-resolve-step/import.meta.never-resolve.mjs index 51205a9475889c..fc3a077abe6ddc 100644 --- a/test/fixtures/es-module-loaders/never-settling-resolve-step/import.meta.never-resolve.mjs +++ b/test/fixtures/es-module-loaders/never-settling-resolve-step/import.meta.never-resolve.mjs @@ -1,5 +1,5 @@ console.log('should be output'); -await import.meta.resolve('never-settle-resolve'); +import.meta.resolve('never-settle-resolve'); console.log('should not be output'); diff --git a/test/fixtures/es-module-loaders/node_modules/load/index.mjs b/test/fixtures/es-module-loaders/node_modules/load/index.mjs new file mode 100644 index 00000000000000..db98e8d9f8781c --- /dev/null +++ b/test/fixtures/es-module-loaders/node_modules/load/index.mjs @@ -0,0 +1 @@ +export * from '../../loader-load-passthru.mjs' \ No newline at end of file diff --git a/test/fixtures/es-module-loaders/node_modules/load/package.json b/test/fixtures/es-module-loaders/node_modules/load/package.json new file mode 100644 index 00000000000000..b6629e247888a5 --- /dev/null +++ b/test/fixtures/es-module-loaders/node_modules/load/package.json @@ -0,0 +1,3 @@ +{ + "exports": "./index.mjs" +} diff --git a/test/fixtures/es-module-loaders/node_modules/resolve/index.mjs b/test/fixtures/es-module-loaders/node_modules/resolve/index.mjs new file mode 100644 index 00000000000000..b3769d961359a6 --- /dev/null +++ b/test/fixtures/es-module-loaders/node_modules/resolve/index.mjs @@ -0,0 +1 @@ +export * from '../../loader-resolve-passthru.mjs' \ No newline at end of file diff --git a/test/fixtures/es-module-loaders/node_modules/resolve/package.json b/test/fixtures/es-module-loaders/node_modules/resolve/package.json new file mode 100644 index 00000000000000..b6629e247888a5 --- /dev/null +++ b/test/fixtures/es-module-loaders/node_modules/resolve/package.json @@ -0,0 +1,3 @@ +{ + "exports": "./index.mjs" +} diff --git a/test/fixtures/es-module-loaders/not-found-assert-loader.mjs b/test/fixtures/es-module-loaders/not-found-assert-loader.mjs index ea4c73724298db..1786b7c04fc555 100644 --- a/test/fixtures/es-module-loaders/not-found-assert-loader.mjs +++ b/test/fixtures/es-module-loaders/not-found-assert-loader.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert'; // a loader that asserts that the defaultResolve will throw "not found" // (skipping the top-level main of course) let mainLoad = true; -export async function resolve(specifier, { importAssertions }, next) { +export async function resolve(specifier, { importAttributes }, next) { if (mainLoad) { mainLoad = false; return next(specifier); @@ -15,7 +15,7 @@ export async function resolve(specifier, { importAssertions }, next) { assert.strictEqual(e.code, 'ERR_MODULE_NOT_FOUND'); return { url: 'node:fs', - importAssertions, + importAttributes, }; } assert.fail(`Module resolution for ${specifier} should be throw ERR_MODULE_NOT_FOUND`); diff --git a/test/fixtures/es-module-loaders/register-loader.cjs b/test/fixtures/es-module-loaders/register-loader.cjs new file mode 100644 index 00000000000000..9e18dfa77e2867 --- /dev/null +++ b/test/fixtures/es-module-loaders/register-loader.cjs @@ -0,0 +1,4 @@ +const { register } = require('node:module'); +const fixtures = require('../../common/fixtures.js'); + +register(fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs')); diff --git a/test/fixtures/es-module-loaders/register-loader.mjs b/test/fixtures/es-module-loaders/register-loader.mjs new file mode 100644 index 00000000000000..f3cb2de8da3d0f --- /dev/null +++ b/test/fixtures/es-module-loaders/register-loader.mjs @@ -0,0 +1,4 @@ +import { register } from 'node:module'; +import fixtures from '../../common/fixtures.js'; + +register(fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs')); diff --git a/test/fixtures/es-module-loaders/register-programmatically-loader-load.mjs b/test/fixtures/es-module-loaders/register-programmatically-loader-load.mjs new file mode 100644 index 00000000000000..fe22dc7203e9d4 --- /dev/null +++ b/test/fixtures/es-module-loaders/register-programmatically-loader-load.mjs @@ -0,0 +1,4 @@ +import * as fixtures from '../../common/fixtures.mjs'; +import { register } from 'node:module'; + +register(fixtures.fileURL('es-module-loaders', 'loader-load-passthru.mjs')); diff --git a/test/fixtures/es-module-loaders/register-programmatically-loader-resolve.mjs b/test/fixtures/es-module-loaders/register-programmatically-loader-resolve.mjs new file mode 100644 index 00000000000000..3ae8841da8345a --- /dev/null +++ b/test/fixtures/es-module-loaders/register-programmatically-loader-resolve.mjs @@ -0,0 +1,3 @@ +import { register } from 'node:module'; + +register('./loader-resolve-passthru.mjs', import.meta.url); diff --git a/test/fixtures/es-module-loaders/string-sources.mjs b/test/fixtures/es-module-loaders/string-sources.mjs index 396d17cb17a75c..39ad32c465fa31 100644 --- a/test/fixtures/es-module-loaders/string-sources.mjs +++ b/test/fixtures/es-module-loaders/string-sources.mjs @@ -23,7 +23,7 @@ const SOURCES = { export function resolve(specifier, context, next) { if (specifier.startsWith('test:')) { return { - importAssertions: context.importAssertions, + importAttributes: context.importAttributes, shortCircuit: true, url: specifier, }; diff --git a/test/fixtures/es-modules/import-resolve-exports.mjs b/test/fixtures/es-modules/import-resolve-exports.mjs index 0bbce4fbc5efc0..e6a840ec996d96 100644 --- a/test/fixtures/es-modules/import-resolve-exports.mjs +++ b/test/fixtures/es-modules/import-resolve-exports.mjs @@ -1,10 +1,4 @@ import { strictEqual } from 'assert'; -(async () => { - const resolved = await import.meta.resolve('pkgexports-sugar'); - strictEqual(typeof resolved, 'string'); -})() -.catch((e) => { - console.error(e); - process.exit(1); -}); +const resolved = import.meta.resolve('pkgexports-sugar'); +strictEqual(typeof resolved, 'string'); diff --git a/test/fixtures/es-modules/imports-loose.mjs b/test/fixtures/es-modules/imports-loose.mjs new file mode 100644 index 00000000000000..13831e5db03e82 --- /dev/null +++ b/test/fixtures/es-modules/imports-loose.mjs @@ -0,0 +1 @@ +import './loose.js'; diff --git a/test/fixtures/es-modules/imports-noext.mjs b/test/fixtures/es-modules/imports-noext.mjs new file mode 100644 index 00000000000000..96eca54521b9d3 --- /dev/null +++ b/test/fixtures/es-modules/imports-noext.mjs @@ -0,0 +1 @@ +import './noext-esm'; diff --git a/test/fixtures/es-modules/invalid-posix-host.mjs b/test/fixtures/es-modules/invalid-posix-host.mjs new file mode 100644 index 00000000000000..65ebb2c0496c15 --- /dev/null +++ b/test/fixtures/es-modules/invalid-posix-host.mjs @@ -0,0 +1 @@ +import "file://hmm.js"; diff --git a/test/fixtures/es-modules/loose.js b/test/fixtures/es-modules/loose.js new file mode 100644 index 00000000000000..69147a3b8ca027 --- /dev/null +++ b/test/fixtures/es-modules/loose.js @@ -0,0 +1,3 @@ +// This file can be run or imported only if `--experimental-default-type=module` is set. +export default 'module'; +console.log('executed'); diff --git a/test/fixtures/es-modules/noext-esm b/test/fixtures/es-modules/noext-esm new file mode 100644 index 00000000000000..251d6e538a1fcf --- /dev/null +++ b/test/fixtures/es-modules/noext-esm @@ -0,0 +1,2 @@ +export default 'module'; +console.log('executed'); diff --git a/test/fixtures/es-modules/noext-wasm b/test/fixtures/es-modules/noext-wasm new file mode 100644 index 00000000000000..9e035904b2e4d0 Binary files /dev/null and b/test/fixtures/es-modules/noext-wasm differ diff --git a/test/fixtures/es-modules/package-type-module/index.js b/test/fixtures/es-modules/package-type-module/index.js index e8f4db3e164302..86d88056422197 100644 --- a/test/fixtures/es-modules/package-type-module/index.js +++ b/test/fixtures/es-modules/package-type-module/index.js @@ -1,4 +1,4 @@ -import 'dep/dep.js'; +import 'dep-without-package-json/dep.js'; const identifier = 'package-type-module'; console.log(identifier); export default identifier; diff --git a/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-type-module/noext-esm b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-type-module/noext-esm new file mode 100644 index 00000000000000..251d6e538a1fcf --- /dev/null +++ b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-type-module/noext-esm @@ -0,0 +1,2 @@ +export default 'module'; +console.log('executed'); diff --git a/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-type-module/noext-wasm b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-type-module/noext-wasm new file mode 100644 index 00000000000000..9e035904b2e4d0 Binary files /dev/null and b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-type-module/noext-wasm differ diff --git a/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-type-module/package.json b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-type-module/package.json new file mode 100644 index 00000000000000..8d155c74efe78a --- /dev/null +++ b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-type-module/package.json @@ -0,0 +1,8 @@ +{ + "name": "dep-with-package-json-type-module", + "type": "module", + "version": "1.0.0", + "exports": { + "./*": "./*" + } +} diff --git a/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-type-module/wasm-dep.mjs b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-type-module/wasm-dep.mjs new file mode 100644 index 00000000000000..a0e28aa17b6bd9 --- /dev/null +++ b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-type-module/wasm-dep.mjs @@ -0,0 +1,15 @@ +import { strictEqual } from 'assert'; + +export function jsFn () { + state = 'WASM JS Function Executed'; + return 42; +} + +export let state = 'JS Function Executed'; + +export function jsInitFn () { + strictEqual(state, 'JS Function Executed'); + state = 'WASM Start Executed'; +} + +console.log('executed'); diff --git a/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-without-type/dep.js b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-without-type/dep.js new file mode 100644 index 00000000000000..0d702867cd5ccf --- /dev/null +++ b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-without-type/dep.js @@ -0,0 +1,2 @@ +// Controlling package.json has no "type" field -> should still be CommonJS as it is in node_modules +module.exports = 42; diff --git a/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-without-type/noext-cjs b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-without-type/noext-cjs new file mode 100644 index 00000000000000..7712b3bad54497 --- /dev/null +++ b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-without-type/noext-cjs @@ -0,0 +1,3 @@ +// No package.json -> should still be CommonJS as it is in node_modules +module.exports = 42; +console.log('executed'); diff --git a/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-without-type/package.json b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-without-type/package.json new file mode 100644 index 00000000000000..1b83367ebe3f5f --- /dev/null +++ b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-without-type/package.json @@ -0,0 +1,7 @@ +{ + "name": "dep-with-package-json-without-type", + "version": "1.0.0", + "exports": { + "./*": "./*" + } +} diff --git a/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-without-type/run.js b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-without-type/run.js new file mode 100644 index 00000000000000..7712b3bad54497 --- /dev/null +++ b/test/fixtures/es-modules/package-type-module/node_modules/dep-with-package-json-without-type/run.js @@ -0,0 +1,3 @@ +// No package.json -> should still be CommonJS as it is in node_modules +module.exports = 42; +console.log('executed'); diff --git a/test/fixtures/es-modules/package-type-module/node_modules/dep/dep.js b/test/fixtures/es-modules/package-type-module/node_modules/dep-without-package-json/dep.js similarity index 100% rename from test/fixtures/es-modules/package-type-module/node_modules/dep/dep.js rename to test/fixtures/es-modules/package-type-module/node_modules/dep-without-package-json/dep.js diff --git a/test/fixtures/es-modules/package-type-module/node_modules/dep-without-package-json/noext-cjs b/test/fixtures/es-modules/package-type-module/node_modules/dep-without-package-json/noext-cjs new file mode 100644 index 00000000000000..7712b3bad54497 --- /dev/null +++ b/test/fixtures/es-modules/package-type-module/node_modules/dep-without-package-json/noext-cjs @@ -0,0 +1,3 @@ +// No package.json -> should still be CommonJS as it is in node_modules +module.exports = 42; +console.log('executed'); diff --git a/test/fixtures/es-modules/package-type-module/node_modules/dep-without-package-json/run.js b/test/fixtures/es-modules/package-type-module/node_modules/dep-without-package-json/run.js new file mode 100644 index 00000000000000..7712b3bad54497 --- /dev/null +++ b/test/fixtures/es-modules/package-type-module/node_modules/dep-without-package-json/run.js @@ -0,0 +1,3 @@ +// No package.json -> should still be CommonJS as it is in node_modules +module.exports = 42; +console.log('executed'); diff --git a/test/fixtures/es-modules/package-type-module/noext-wasm b/test/fixtures/es-modules/package-type-module/noext-wasm new file mode 100644 index 00000000000000..9e035904b2e4d0 Binary files /dev/null and b/test/fixtures/es-modules/package-type-module/noext-wasm differ diff --git a/test/fixtures/es-modules/package-type-module/wasm-dep.mjs b/test/fixtures/es-modules/package-type-module/wasm-dep.mjs new file mode 100644 index 00000000000000..a0e28aa17b6bd9 --- /dev/null +++ b/test/fixtures/es-modules/package-type-module/wasm-dep.mjs @@ -0,0 +1,15 @@ +import { strictEqual } from 'assert'; + +export function jsFn () { + state = 'WASM JS Function Executed'; + return 42; +} + +export let state = 'JS Function Executed'; + +export function jsInitFn () { + strictEqual(state, 'JS Function Executed'); + state = 'WASM Start Executed'; +} + +console.log('executed'); diff --git a/test/fixtures/es-modules/package-without-type/file#1.js b/test/fixtures/es-modules/package-without-type/file#1.js new file mode 100644 index 00000000000000..6ab97dbf4b58cf --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/file#1.js @@ -0,0 +1 @@ +console.log('file#1'); diff --git a/test/fixtures/es-modules/package-without-type/module.js b/test/fixtures/es-modules/package-without-type/module.js new file mode 100644 index 00000000000000..69147a3b8ca027 --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/module.js @@ -0,0 +1,3 @@ +// This file can be run or imported only if `--experimental-default-type=module` is set. +export default 'module'; +console.log('executed'); diff --git a/test/fixtures/es-modules/package-without-type/noext-esm b/test/fixtures/es-modules/package-without-type/noext-esm new file mode 100644 index 00000000000000..69147a3b8ca027 --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/noext-esm @@ -0,0 +1,3 @@ +// This file can be run or imported only if `--experimental-default-type=module` is set. +export default 'module'; +console.log('executed'); diff --git a/test/fixtures/es-modules/runmain.mjs b/test/fixtures/es-modules/runmain.mjs index 5ceb86b66c76ce..ee71ff42a81368 100644 --- a/test/fixtures/es-modules/runmain.mjs +++ b/test/fixtures/es-modules/runmain.mjs @@ -1,7 +1,7 @@ import { runMain } from 'node:module'; -try { await import.meta.resolve('doesnt-matter.mjs') } catch {} +try { import.meta.resolve('doesnt-matter.mjs') } catch {} runMain(); -try { await import.meta.resolve('doesnt-matter.mjs') } catch {} +try { import.meta.resolve('doesnt-matter.mjs') } catch {} diff --git a/test/fixtures/print-delayed.js b/test/fixtures/print-delayed.js new file mode 100644 index 00000000000000..42eb45615b2a67 --- /dev/null +++ b/test/fixtures/print-delayed.js @@ -0,0 +1,3 @@ +setTimeout(() => { + console.log('delayed'); +}, 100); diff --git a/test/parallel/test-bootstrap-modules.js b/test/parallel/test-bootstrap-modules.js index 3c4b44609c34a3..1ce0effe4fb02d 100644 --- a/test/parallel/test-bootstrap-modules.js +++ b/test/parallel/test-bootstrap-modules.js @@ -48,7 +48,6 @@ const expectedModules = new Set([ 'NativeModule internal/event_target', 'NativeModule internal/fixed_queue', 'NativeModule internal/fs/utils', - 'NativeModule internal/idna', 'NativeModule internal/linkedlist', 'NativeModule internal/modules/cjs/loader', 'NativeModule internal/modules/esm/utils', @@ -84,12 +83,16 @@ const expectedModules = new Set([ 'NativeModule path', 'NativeModule querystring', 'NativeModule timers', - 'NativeModule url', 'NativeModule internal/v8/startup_snapshot', 'NativeModule util', ]); -if (!common.isMainThread) { +if (common.isMainThread) { + [ + 'NativeModule internal/idna', + 'NativeModule url', + ].forEach(expectedModules.add.bind(expectedModules)); +} else { [ 'Internal Binding messaging', 'Internal Binding performance', @@ -132,8 +135,6 @@ if (common.isWindows) { if (common.hasIntl) { expectedModules.add('Internal Binding icu'); -} else { - expectedModules.add('NativeModule url'); } if (process.features.inspector) { diff --git a/test/parallel/test-child-process-cwd.js b/test/parallel/test-child-process-cwd.js index 869db83db3902e..b527b7f9ea8012 100644 --- a/test/parallel/test-child-process-cwd.js +++ b/test/parallel/test-child-process-cwd.js @@ -27,7 +27,6 @@ tmpdir.refresh(); const assert = require('assert'); const { spawn } = require('child_process'); -const { pathToFileURL, URL } = require('url'); // Spawns 'pwd' with given options, then test // - whether the child pid is undefined or number, @@ -88,7 +87,7 @@ function testCwd(options, expectPidType, expectCode = 0, expectData) { testCwd({ cwd: tmpdir.path }, 'number', 0, tmpdir.path); const shouldExistDir = common.isWindows ? process.env.windir : '/dev'; testCwd({ cwd: shouldExistDir }, 'number', 0, shouldExistDir); -testCwd({ cwd: pathToFileURL(tmpdir.path) }, 'number', 0, tmpdir.path); +testCwd({ cwd: tmpdir.fileURL() }, 'number', 0, tmpdir.path); // Spawn() shouldn't try to chdir() to invalid arg, so this should just work testCwd({ cwd: '' }, 'number'); diff --git a/test/parallel/test-fs-mkdtemp.js b/test/parallel/test-fs-mkdtemp.js index 950a524368c00b..9c5b952cfc9e79 100644 --- a/test/parallel/test-fs-mkdtemp.js +++ b/test/parallel/test-fs-mkdtemp.js @@ -8,29 +8,100 @@ const path = require('path'); const tmpdir = require('../common/tmpdir'); tmpdir.refresh(); -const tmpFolder = fs.mkdtempSync(path.join(tmpdir.path, 'foo.')); - -assert.strictEqual(path.basename(tmpFolder).length, 'foo.XXXXXX'.length); -assert(fs.existsSync(tmpFolder)); - -const utf8 = fs.mkdtempSync(path.join(tmpdir.path, '\u0222abc.')); -assert.strictEqual(Buffer.byteLength(path.basename(utf8)), - Buffer.byteLength('\u0222abc.XXXXXX')); -assert(fs.existsSync(utf8)); - function handler(err, folder) { assert.ifError(err); assert(fs.existsSync(folder)); assert.strictEqual(this, undefined); } -fs.mkdtemp(path.join(tmpdir.path, 'bar.'), common.mustCall(handler)); +// Test with plain string +{ + const tmpFolder = fs.mkdtempSync(path.join(tmpdir.path, 'foo.')); + + assert.strictEqual(path.basename(tmpFolder).length, 'foo.XXXXXX'.length); + assert(fs.existsSync(tmpFolder)); -// Same test as above, but making sure that passing an options object doesn't -// affect the way the callback function is handled. -fs.mkdtemp(path.join(tmpdir.path, 'bar.'), {}, common.mustCall(handler)); + const utf8 = fs.mkdtempSync(path.join(tmpdir.path, '\u0222abc.')); + assert.strictEqual(Buffer.byteLength(path.basename(utf8)), + Buffer.byteLength('\u0222abc.XXXXXX')); + assert(fs.existsSync(utf8)); + + fs.mkdtemp(path.join(tmpdir.path, 'bar.'), common.mustCall(handler)); + + // Same test as above, but making sure that passing an options object doesn't + // affect the way the callback function is handled. + fs.mkdtemp(path.join(tmpdir.path, 'bar.'), {}, common.mustCall(handler)); + + const warningMsg = 'mkdtemp() templates ending with X are not portable. ' + + 'For details see: https://nodejs.org/api/fs.html'; + common.expectWarning('Warning', warningMsg); + fs.mkdtemp(path.join(tmpdir.path, 'bar.X'), common.mustCall(handler)); +} + +// Test with URL object +{ + const tmpFolder = fs.mkdtempSync(tmpdir.fileURL('foo.')); + + assert.strictEqual(path.basename(tmpFolder).length, 'foo.XXXXXX'.length); + assert(fs.existsSync(tmpFolder)); + + const utf8 = fs.mkdtempSync(tmpdir.fileURL('\u0222abc.')); + assert.strictEqual(Buffer.byteLength(path.basename(utf8)), + Buffer.byteLength('\u0222abc.XXXXXX')); + assert(fs.existsSync(utf8)); + + fs.mkdtemp(tmpdir.fileURL('bar.'), common.mustCall(handler)); + + // Same test as above, but making sure that passing an options object doesn't + // affect the way the callback function is handled. + fs.mkdtemp(tmpdir.fileURL('bar.'), {}, common.mustCall(handler)); + + // Warning fires only once + fs.mkdtemp(tmpdir.fileURL('bar.X'), common.mustCall(handler)); +} -const warningMsg = 'mkdtemp() templates ending with X are not portable. ' + - 'For details see: https://nodejs.org/api/fs.html'; -common.expectWarning('Warning', warningMsg); -fs.mkdtemp(path.join(tmpdir.path, 'bar.X'), common.mustCall(handler)); +// Test with Buffer +{ + const tmpFolder = fs.mkdtempSync(Buffer.from(path.join(tmpdir.path, 'foo.'))); + + assert.strictEqual(path.basename(tmpFolder).length, 'foo.XXXXXX'.length); + assert(fs.existsSync(tmpFolder)); + + const utf8 = fs.mkdtempSync(Buffer.from(path.join(tmpdir.path, '\u0222abc.'))); + assert.strictEqual(Buffer.byteLength(path.basename(utf8)), + Buffer.byteLength('\u0222abc.XXXXXX')); + assert(fs.existsSync(utf8)); + + fs.mkdtemp(Buffer.from(path.join(tmpdir.path, 'bar.')), common.mustCall(handler)); + + // Same test as above, but making sure that passing an options object doesn't + // affect the way the callback function is handled. + fs.mkdtemp(Buffer.from(path.join(tmpdir.path, 'bar.')), {}, common.mustCall(handler)); + + // Warning fires only once + fs.mkdtemp(Buffer.from(path.join(tmpdir.path, 'bar.X')), common.mustCall(handler)); +} + +// Test with Uint8Array +{ + const encoder = new TextEncoder(); + + const tmpFolder = fs.mkdtempSync(encoder.encode(path.join(tmpdir.path, 'foo.'))); + + assert.strictEqual(path.basename(tmpFolder).length, 'foo.XXXXXX'.length); + assert(fs.existsSync(tmpFolder)); + + const utf8 = fs.mkdtempSync(encoder.encode(path.join(tmpdir.path, '\u0222abc.'))); + assert.strictEqual(Buffer.byteLength(path.basename(utf8)), + Buffer.byteLength('\u0222abc.XXXXXX')); + assert(fs.existsSync(utf8)); + + fs.mkdtemp(encoder.encode(path.join(tmpdir.path, 'bar.')), common.mustCall(handler)); + + // Same test as above, but making sure that passing an options object doesn't + // affect the way the callback function is handled. + fs.mkdtemp(encoder.encode(path.join(tmpdir.path, 'bar.')), {}, common.mustCall(handler)); + + // Warning fires only once + fs.mkdtemp(encoder.encode(path.join(tmpdir.path, 'bar.X')), common.mustCall(handler)); +} diff --git a/test/parallel/test-fs-rm.js b/test/parallel/test-fs-rm.js index e6bc47038b8d92..93f59544a87e99 100644 --- a/test/parallel/test-fs-rm.js +++ b/test/parallel/test-fs-rm.js @@ -270,7 +270,7 @@ if (isGitPresent) { } // Should accept URL - const fileURL = pathToFileURL(path.join(tmpdir.path, 'rm-file.txt')); + const fileURL = tmpdir.fileURL('rm-file.txt'); fs.writeFileSync(fileURL, ''); try { @@ -376,7 +376,7 @@ if (isGitPresent) { } // Should accept URL - const fileURL = pathToFileURL(path.join(tmpdir.path, 'rm-promises-file.txt')); + const fileURL = tmpdir.fileURL('rm-promises-file.txt'); fs.writeFileSync(fileURL, ''); try { diff --git a/test/parallel/test-fs-whatwg-url.js b/test/parallel/test-fs-whatwg-url.js index 829cfa92fafebd..5e9ea98a5820ed 100644 --- a/test/parallel/test-fs-whatwg-url.js +++ b/test/parallel/test-fs-whatwg-url.js @@ -3,20 +3,10 @@ const common = require('../common'); const fixtures = require('../common/fixtures'); const assert = require('assert'); -const path = require('path'); const fs = require('fs'); const os = require('os'); -function pathToFileURL(p) { - if (!path.isAbsolute(p)) - throw new Error('Path must be absolute'); - if (common.isWindows && p.startsWith('\\\\')) - p = p.slice(2); - return new URL(`file://${p}`); -} - -const p = path.resolve(fixtures.fixturesDir, 'a.js'); -const url = pathToFileURL(p); +const url = fixtures.fileURL('a.js'); assert(url instanceof URL); diff --git a/test/parallel/test-internal-util-decorate-error-stack.js b/test/parallel/test-internal-util-decorate-error-stack.js index 3566d9375fb81c..f3034fbb05ae54 100644 --- a/test/parallel/test-internal-util-decorate-error-stack.js +++ b/test/parallel/test-internal-util-decorate-error-stack.js @@ -58,7 +58,7 @@ checkStack(err.stack); // Verify that the stack is only decorated once for uncaught exceptions. const args = [ '-e', - `require('${badSyntaxPath}')`, + `require(${JSON.stringify(badSyntaxPath)})`, ]; const result = spawnSync(process.argv[0], args, { encoding: 'utf8' }); checkStack(result.stderr); diff --git a/test/parallel/test-module-binding.js b/test/parallel/test-module-binding.js index a0fa0a038990f0..47170785434099 100644 --- a/test/parallel/test-module-binding.js +++ b/test/parallel/test-module-binding.js @@ -3,35 +3,27 @@ require('../common'); const fixtures = require('../common/fixtures'); const { internalBinding } = require('internal/test/binding'); +const { filterOwnProperties } = require('internal/util'); const { internalModuleReadJSON } = internalBinding('fs'); const { readFileSync } = require('fs'); -const { strictEqual } = require('assert'); +const { strictEqual, deepStrictEqual } = require('assert'); + { - const [string, containsKeys] = internalModuleReadJSON('nosuchfile'); - strictEqual(string, undefined); - strictEqual(containsKeys, undefined); + strictEqual(internalModuleReadJSON('nosuchfile')[0], undefined); } { - const [string, containsKeys] = - internalModuleReadJSON(fixtures.path('empty.txt')); - strictEqual(string, ''); - strictEqual(containsKeys, false); + strictEqual(internalModuleReadJSON(fixtures.path('empty.txt'))[0], ''); } { - const [string, containsKeys] = - internalModuleReadJSON(fixtures.path('empty.txt')); - strictEqual(string, ''); - strictEqual(containsKeys, false); -} -{ - const [string, containsKeys] = - internalModuleReadJSON(fixtures.path('empty-with-bom.txt')); - strictEqual(string, ''); - strictEqual(containsKeys, false); + strictEqual(internalModuleReadJSON(fixtures.path('empty-with-bom.txt'))[0], ''); } { const filename = fixtures.path('require-bin/package.json'); - const [string, containsKeys] = internalModuleReadJSON(filename); - strictEqual(string, readFileSync(filename, 'utf8')); - strictEqual(containsKeys, true); + const returnValue = JSON.parse(internalModuleReadJSON(filename)[0]); + const file = JSON.parse(readFileSync(filename, 'utf-8')); + const expectedValue = filterOwnProperties(file, ['name', 'main', 'exports', 'imports', 'type']); + deepStrictEqual({ + __proto__: null, + ...returnValue, + }, expectedValue); } diff --git a/test/parallel/test-module-create-require.js b/test/parallel/test-module-create-require.js index e0e34e9f127bd3..30ebf96652390d 100644 --- a/test/parallel/test-module-create-require.js +++ b/test/parallel/test-module-create-require.js @@ -1,13 +1,12 @@ 'use strict'; require('../common'); +const fixtures = require('../common/fixtures'); const assert = require('assert'); -const path = require('path'); const { createRequire } = require('module'); -const p = path.resolve(__dirname, '..', 'fixtures', 'fake.js'); -const u = new URL(`file://${p}`); +const u = fixtures.fileURL('fake.js'); const reqToo = createRequire(u); assert.deepStrictEqual(reqToo('./experimental'), { ofLife: 42 }); diff --git a/test/parallel/test-node-output-errors.mjs b/test/parallel/test-node-output-errors.mjs index fca2149fea3212..5be920627ad70e 100644 --- a/test/parallel/test-node-output-errors.mjs +++ b/test/parallel/test-node-output-errors.mjs @@ -3,6 +3,7 @@ import * as fixtures from '../common/fixtures.mjs'; import * as snapshot from '../common/assertSnapshot.js'; import * as os from 'node:os'; import { describe, it } from 'node:test'; +import { pathToFileURL } from 'node:url'; const skipForceColors = process.config.variables.icu_gyp_path !== 'tools/icu/icu-generic.gyp' || @@ -10,17 +11,24 @@ const skipForceColors = (common.isWindows && (Number(os.release().split('.')[0]) !== 10 || Number(os.release().split('.')[2]) < 14393)); // See https://github.com/nodejs/node/pull/33132 -function replaceNodeVersion(str) { - return str.replaceAll(process.version, '*'); -} - function replaceStackTrace(str) { return snapshot.replaceStackTrace(str, '$1at *$7\n'); } +function replaceForceColorsStackTrace(str) { + // eslint-disable-next-line no-control-regex + return str.replaceAll(/(\[90m\W+)at .*node:.*/g, '$1at *'); +} + describe('errors output', { concurrency: true }, () => { function normalize(str) { - return str.replaceAll(snapshot.replaceWindowsPaths(process.cwd()), '').replaceAll('//', '*').replaceAll(/\/(\w)/g, '*$1').replaceAll('*test*', '*').replaceAll('*fixtures*errors*', '*').replaceAll('file:**', 'file:*/'); + return str.replaceAll(snapshot.replaceWindowsPaths(process.cwd()), '') + .replaceAll(pathToFileURL(process.cwd()).pathname, '') + .replaceAll('//', '*') + .replaceAll(/\/(\w)/g, '*$1') + .replaceAll('*test*', '*') + .replaceAll('*fixtures*errors*', '*') + .replaceAll('file:**', 'file:*/'); } function normalizeNoNumbers(str) { @@ -28,9 +36,12 @@ describe('errors output', { concurrency: true }, () => { } const common = snapshot .transform(snapshot.replaceWindowsLineEndings, snapshot.replaceWindowsPaths); - const defaultTransform = snapshot.transform(common, normalize, replaceNodeVersion); - const errTransform = snapshot.transform(common, normalizeNoNumbers, replaceNodeVersion); - const promiseTransform = snapshot.transform(common, replaceStackTrace, normalizeNoNumbers, replaceNodeVersion); + const defaultTransform = snapshot.transform(common, normalize, snapshot.replaceNodeVersion); + const errTransform = snapshot.transform(common, normalizeNoNumbers, snapshot.replaceNodeVersion); + const promiseTransform = snapshot.transform(common, replaceStackTrace, + normalizeNoNumbers, snapshot.replaceNodeVersion); + const forceColorsTransform = snapshot.transform(common, normalize, + replaceForceColorsStackTrace, snapshot.replaceNodeVersion); const tests = [ { name: 'errors/async_error_eval_cjs.js' }, @@ -50,11 +61,12 @@ describe('errors output', { concurrency: true }, () => { { name: 'errors/throw_in_line_with_tabs.js', transform: errTransform }, { name: 'errors/throw_non_error.js', transform: errTransform }, { name: 'errors/promise_always_throw_unhandled.js', transform: promiseTransform }, - !skipForceColors ? { name: 'errors/force_colors.js', env: { FORCE_COLOR: 1 } } : null, - ].filter(Boolean); - for (const { name, transform, env } of tests) { - it(name, async () => { - await snapshot.spawnAndAssert(fixtures.path(name), transform ?? defaultTransform, { env }); + { skip: skipForceColors, name: 'errors/force_colors.js', + transform: forceColorsTransform, env: { FORCE_COLOR: 1 } }, + ]; + for (const { name, transform = defaultTransform, env, skip = false } of tests) { + it(name, { skip }, async () => { + await snapshot.spawnAndAssert(fixtures.path(name), transform, { env }); }); } }); diff --git a/test/parallel/test-node-output-sourcemaps.mjs b/test/parallel/test-node-output-sourcemaps.mjs index 8e43947ab2188f..c53a0598958e4e 100644 --- a/test/parallel/test-node-output-sourcemaps.mjs +++ b/test/parallel/test-node-output-sourcemaps.mjs @@ -4,10 +4,6 @@ import * as snapshot from '../common/assertSnapshot.js'; import * as path from 'node:path'; import { describe, it } from 'node:test'; -function replaceNodeVersion(str) { - return str.replaceAll(process.version, '*'); -} - describe('sourcemaps output', { concurrency: true }, () => { function normalize(str) { const result = str @@ -16,7 +12,8 @@ describe('sourcemaps output', { concurrency: true }, () => { .replaceAll('/Users/bencoe/oss/coffee-script-test', '') .replaceAll(/\/(\w)/g, '*$1') .replaceAll('*test*', '*') - .replaceAll('*fixtures*source-map*', '*'); + .replaceAll('*fixtures*source-map*', '*') + .replaceAll(/(\W+).*node:internal\*modules.*/g, '$1*'); if (common.isWindows) { const currentDeviceLetter = path.parse(process.cwd()).root.substring(0, 1).toLowerCase(); const regex = new RegExp(`${currentDeviceLetter}:/?`, 'gi'); @@ -25,7 +22,8 @@ describe('sourcemaps output', { concurrency: true }, () => { return result; } const defaultTransform = snapshot - .transform(snapshot.replaceWindowsLineEndings, snapshot.replaceWindowsPaths, normalize, replaceNodeVersion); + .transform(snapshot.replaceWindowsLineEndings, snapshot.replaceWindowsPaths, + normalize, snapshot.replaceNodeVersion); const tests = [ { name: 'source-map/output/source_map_disabled_by_api.js' }, diff --git a/test/parallel/test-repl-require-context.js b/test/parallel/test-repl-require-context.js index 750235818b8bfc..af09249c2de919 100644 --- a/test/parallel/test-repl-require-context.js +++ b/test/parallel/test-repl-require-context.js @@ -19,6 +19,6 @@ child.on('exit', common.mustCall(() => { child.stdin.write('const isObject = (obj) => obj.constructor === Object;\n'); child.stdin.write('isObject({});\n'); -child.stdin.write(`require('${fixture}').isObject({});\n`); +child.stdin.write(`require(${JSON.stringify(fixture)}).isObject({});\n`); child.stdin.write('.exit'); child.stdin.end(); diff --git a/test/parallel/test-runner-inspect.mjs b/test/parallel/test-runner-inspect.mjs index bdff1ce7ceb84f..ef893ab899b874 100644 --- a/test/parallel/test-runner-inspect.mjs +++ b/test/parallel/test-runner-inspect.mjs @@ -1,6 +1,6 @@ import * as common from '../common/index.mjs'; -import * as tmpdir from '../common/tmpdir.js'; import * as fixtures from '../common/fixtures.mjs'; +import tmpdir from '../common/tmpdir.js'; import assert from 'node:assert'; import path from 'node:path'; import fs from 'node:fs/promises'; diff --git a/test/parallel/test-stdio-pipe-stderr.js b/test/parallel/test-stdio-pipe-stderr.js index 9ec41b4159fdf6..1737424bb049fc 100644 --- a/test/parallel/test-stdio-pipe-stderr.js +++ b/test/parallel/test-stdio-pipe-stderr.js @@ -22,7 +22,7 @@ fs.writeFileSync(fakeModulePath, '', 'utf8'); stream.on('open', () => { spawnSync(process.execPath, { - input: `require("${fakeModulePath.replace(/\\/g, '/')}")`, + input: `require(${JSON.stringify(fakeModulePath)})`, stdio: ['pipe', 'pipe', stream] }); const stderr = fs.readFileSync(stderrOutputPath, 'utf8').trim(); diff --git a/test/parallel/test-trace-events-worker-metadata-with-name.js b/test/parallel/test-trace-events-worker-metadata-with-name.js index 6c3a44f9566d9c..bf6e1005aa458f 100644 --- a/test/parallel/test-trace-events-worker-metadata-with-name.js +++ b/test/parallel/test-trace-events-worker-metadata-with-name.js @@ -7,7 +7,7 @@ const { isMainThread } = require('worker_threads'); if (isMainThread) { const CODE = 'const { Worker } = require(\'worker_threads\'); ' + - `new Worker('${__filename.replace(/\\/g, '/')}', { name: 'foo' })`; + `new Worker(${JSON.stringify(__filename)}, { name: 'foo' })`; const FILE_NAME = 'node_trace.1.log'; const tmpdir = require('../common/tmpdir'); tmpdir.refresh(); diff --git a/test/parallel/test-trace-events-worker-metadata.js b/test/parallel/test-trace-events-worker-metadata.js index 8b4d0be9c60713..6a8702ccadbc7b 100644 --- a/test/parallel/test-trace-events-worker-metadata.js +++ b/test/parallel/test-trace-events-worker-metadata.js @@ -7,7 +7,7 @@ const { isMainThread } = require('worker_threads'); if (isMainThread) { const CODE = 'const { Worker } = require(\'worker_threads\'); ' + - `new Worker('${__filename.replace(/\\/g, '/')}')`; + `new Worker(${JSON.stringify(__filename)})`; const FILE_NAME = 'node_trace.1.log'; const tmpdir = require('../common/tmpdir'); tmpdir.refresh(); diff --git a/test/parallel/test-vm-module-dynamic-import.js b/test/parallel/test-vm-module-dynamic-import.js index cd318511401412..5bca08b8c9c3bb 100644 --- a/test/parallel/test-vm-module-dynamic-import.js +++ b/test/parallel/test-vm-module-dynamic-import.js @@ -59,10 +59,10 @@ async function test() { { const s = new Script('import("foo", { assert: { key: "value" } })', { - importModuleDynamically: common.mustCall((specifier, wrap, assertion) => { + importModuleDynamically: common.mustCall((specifier, wrap, attributes) => { assert.strictEqual(specifier, 'foo'); assert.strictEqual(wrap, s); - assert.deepStrictEqual(assertion, { __proto__: null, key: 'value' }); + assert.deepStrictEqual(attributes, { __proto__: null, key: 'value' }); return foo; }), }); diff --git a/test/parallel/test-vm-module-link.js b/test/parallel/test-vm-module-link.js index 16694d5d846075..1edd6a0ba01bb5 100644 --- a/test/parallel/test-vm-module-link.js +++ b/test/parallel/test-vm-module-link.js @@ -131,7 +131,9 @@ async function asserts() { await m.link((s, r, p) => { assert.strictEqual(s, 'foo'); assert.strictEqual(r.identifier, 'm'); + assert.strictEqual(p.attributes.n1, 'v1'); assert.strictEqual(p.assert.n1, 'v1'); + assert.strictEqual(p.attributes.n2, 'v2'); assert.strictEqual(p.assert.n2, 'v2'); return new SourceTextModule(''); }); diff --git a/test/sequential/test-watch-mode.mjs b/test/sequential/test-watch-mode.mjs index 610391863e6de0..383e15e73dd3e1 100644 --- a/test/sequential/test-watch-mode.mjs +++ b/test/sequential/test-watch-mode.mjs @@ -7,6 +7,7 @@ import { describe, it } from 'node:test'; import { spawn } from 'node:child_process'; import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'; import { inspect } from 'node:util'; +import { pathToFileURL } from 'node:url'; import { createInterface } from 'node:readline'; if (common.isIBMi) @@ -188,7 +189,7 @@ console.log("don't show me");`); it('should watch changes to dependencies - cjs', async () => { const dependency = createTmpFile('module.exports = {};'); const file = createTmpFile(` -const dependency = require('${dependency.replace(/\\/g, '/')}'); +const dependency = require(${JSON.stringify(dependency)}); console.log(dependency); `); const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: dependency }); @@ -206,7 +207,7 @@ console.log(dependency); it('should watch changes to dependencies - esm', async () => { const dependency = createTmpFile('module.exports = {};'); const file = createTmpFile(` -import dependency from 'file://${dependency.replace(/\\/g, '/')}'; +import dependency from ${JSON.stringify(pathToFileURL(dependency))}; console.log(dependency); `, '.mjs'); const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: dependency }); @@ -278,7 +279,7 @@ console.log(values.random); skip: 'enable once --import is backported', }, async () => { const file = createTmpFile(); - const imported = `file://${createTmpFile('setImmediate(() => process.exit(0));')}`; + const imported = pathToFileURL(createTmpFile('setImmediate(() => process.exit(0));')); const args = ['--import', imported, file]; const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, args }); @@ -320,9 +321,9 @@ console.log(values.random); it('should watch changes to previously missing ESM dependency', { skip: !supportsRecursive }, async () => { - const dependency = path.join(tmpdir.path, `${tmpFiles++}.mjs`); - const relativeDependencyPath = `./${path.basename(dependency)}`; - const dependant = createTmpFile(`import '${relativeDependencyPath}'`, '.mjs'); + const relativeDependencyPath = `./${tmpFiles++}.mjs`; + const dependency = path.join(tmpdir.path, relativeDependencyPath); + const dependant = createTmpFile(`import ${JSON.stringify(relativeDependencyPath)}`, '.mjs'); await failWriteSucceed({ file: dependant, watchedFile: dependency }); }); diff --git a/tools/dep_updaters/update-eslint.sh b/tools/dep_updaters/update-eslint.sh index 3142d4f2d7f0d4..75fa36ce9fcef7 100755 --- a/tools/dep_updaters/update-eslint.sh +++ b/tools/dep_updaters/update-eslint.sh @@ -48,7 +48,7 @@ rm -rf ../node_modules/eslint eslint-plugin-markdown \ @babel/core \ @babel/eslint-parser \ - @babel/plugin-syntax-import-assertions + @babel/plugin-syntax-import-attributes ) ( cd node_modules/eslint @@ -63,7 +63,7 @@ rm -rf ../node_modules/eslint eslint-plugin-markdown \ @babel/core \ @babel/eslint-parser \ - @babel/plugin-syntax-import-assertions + @babel/plugin-syntax-import-attributes ) # Use dmn to remove some unneeded files. "$NODE" "$NPM" exec --package=dmn@2.2.2 --yes -- dmn -f clean diff --git a/tools/node_modules/eslint/bin/eslint.js b/tools/node_modules/eslint/bin/eslint.js index 7094ac77bc4bda..5c7972cc086eb7 100755 --- a/tools/node_modules/eslint/bin/eslint.js +++ b/tools/node_modules/eslint/bin/eslint.js @@ -92,6 +92,14 @@ function getErrorMessage(error) { return util.format("%o", error); } +/** + * Tracks error messages that are shown to the user so we only ever show the + * same message once. + * @type {Set} + */ + +const displayedErrors = new Set(); + /** * Catch and report unexpected error. * @param {any} error The thrown error object. @@ -101,14 +109,17 @@ function onFatalError(error) { process.exitCode = 2; const { version } = require("../package.json"); - const message = getErrorMessage(error); - - console.error(` + const message = ` Oops! Something went wrong! :( ESLint: ${version} -${message}`); +${getErrorMessage(error)}`; + + if (!displayedErrors.has(message)) { + console.error(message); + displayedErrors.add(message); + } } //------------------------------------------------------------------------------ diff --git a/tools/node_modules/eslint/lib/cli.js b/tools/node_modules/eslint/lib/cli.js index a14930e9b0f244..807d28a0d1bc59 100644 --- a/tools/node_modules/eslint/lib/cli.js +++ b/tools/node_modules/eslint/lib/cli.js @@ -91,7 +91,8 @@ async function translateOptions({ reportUnusedDisableDirectives, resolvePluginsRelativeTo, rule, - rulesdir + rulesdir, + warnIgnored }, configType) { let overrideConfig, overrideConfigFile; @@ -182,6 +183,7 @@ async function translateOptions({ if (configType === "flat") { options.ignorePatterns = ignorePattern; + options.warnIgnored = warnIgnored; } else { options.resolvePluginsRelativeTo = resolvePluginsRelativeTo; options.rulePaths = rulesdir; @@ -385,7 +387,9 @@ const cli = { if (useStdin) { results = await engine.lintText(text, { filePath: options.stdinFilename, - warnIgnored: true + + // flatConfig respects CLI flag and constructor warnIgnored, eslintrc forces true for backwards compatibility + warnIgnored: usingFlatConfig ? void 0 : true }); } else { results = await engine.lintFiles(files); diff --git a/tools/node_modules/eslint/lib/config/flat-config-schema.js b/tools/node_modules/eslint/lib/config/flat-config-schema.js index 10d6b50ef1ff44..df850995d87ff0 100644 --- a/tools/node_modules/eslint/lib/config/flat-config-schema.js +++ b/tools/node_modules/eslint/lib/config/flat-config-schema.js @@ -179,9 +179,7 @@ class InvalidRuleSeverityError extends Error { * @throws {InvalidRuleSeverityError} If the value isn't a valid rule severity. */ function assertIsRuleSeverity(ruleId, value) { - const severity = typeof value === "string" - ? ruleSeverities.get(value.toLowerCase()) - : ruleSeverities.get(value); + const severity = ruleSeverities.get(value); if (typeof severity === "undefined") { throw new InvalidRuleSeverityError(ruleId, value); @@ -212,6 +210,38 @@ function assertIsObject(value) { } } +/** + * The error type when there's an eslintrc-style options in a flat config. + */ +class IncompatibleKeyError extends Error { + + /** + * @param {string} key The invalid key. + */ + constructor(key) { + super("This appears to be in eslintrc format rather than flat config format."); + this.messageTemplate = "eslintrc-incompat"; + this.messageData = { key }; + } +} + +/** + * The error type when there's an eslintrc-style plugins array found. + */ +class IncompatiblePluginsError extends Error { + + /** + * Creates a new instance. + * @param {Array} plugins The plugins array. + */ + constructor(plugins) { + super("This appears to be in eslintrc format (array of strings) rather than flat config format (object)."); + this.messageTemplate = "eslintrc-plugins"; + this.messageData = { plugins }; + } +} + + //----------------------------------------------------------------------------- // Low-Level Schemas //----------------------------------------------------------------------------- @@ -303,6 +333,11 @@ const pluginsSchema = { throw new TypeError("Expected an object."); } + // make sure it's not an array, which would mean eslintrc-style is used + if (Array.isArray(value)) { + throw new IncompatiblePluginsError(value); + } + // second check the keys to make sure they are objects for (const key of Object.keys(value)) { @@ -438,11 +473,44 @@ const sourceTypeSchema = { } }; +/** + * Creates a schema that always throws an error. Useful for warning + * about eslintrc-style keys. + * @param {string} key The eslintrc key to create a schema for. + * @returns {ObjectPropertySchema} The schema. + */ +function createEslintrcErrorSchema(key) { + return { + merge: "replace", + validate() { + throw new IncompatibleKeyError(key); + } + }; +} + +const eslintrcKeys = [ + "env", + "extends", + "globals", + "ignorePatterns", + "noInlineConfig", + "overrides", + "parser", + "parserOptions", + "reportUnusedDisableDirectives", + "root" +]; + //----------------------------------------------------------------------------- // Full schema //----------------------------------------------------------------------------- -exports.flatConfigSchema = { +const flatConfigSchema = { + + // eslintrc-style keys that should always error + ...Object.fromEntries(eslintrcKeys.map(key => [key, createEslintrcErrorSchema(key)])), + + // flat config keys settings: deepObjectAssignSchema, linterOptions: { schema: { @@ -463,3 +531,13 @@ exports.flatConfigSchema = { plugins: pluginsSchema, rules: rulesSchema }; + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +module.exports = { + flatConfigSchema, + assertIsRuleSeverity, + assertIsRuleOptions +}; diff --git a/tools/node_modules/eslint/lib/config/rule-validator.js b/tools/node_modules/eslint/lib/config/rule-validator.js index 0b5858fb30f38b..eee5b40bd07b0d 100644 --- a/tools/node_modules/eslint/lib/config/rule-validator.js +++ b/tools/node_modules/eslint/lib/config/rule-validator.js @@ -9,7 +9,8 @@ // Requirements //----------------------------------------------------------------------------- -const ajv = require("../shared/ajv")(); +const ajvImport = require("../shared/ajv"); +const ajv = ajvImport(); const { parseRuleId, getRuleFromConfig, diff --git a/tools/node_modules/eslint/lib/eslint/eslint-helpers.js b/tools/node_modules/eslint/lib/eslint/eslint-helpers.js index e25b10e8bc4e10..72828363c3da3d 100644 --- a/tools/node_modules/eslint/lib/eslint/eslint-helpers.js +++ b/tools/node_modules/eslint/lib/eslint/eslint-helpers.js @@ -594,9 +594,9 @@ function createIgnoreResult(filePath, baseDir) { const isInNodeModules = baseDir && path.dirname(path.relative(baseDir, filePath)).split(path.sep).includes("node_modules"); if (isInNodeModules) { - message = "File ignored by default because it is located under the node_modules directory. Use ignore pattern \"!**/node_modules/\" to override."; + message = "File ignored by default because it is located under the node_modules directory. Use ignore pattern \"!**/node_modules/\" to disable file ignore settings or use \"--no-warn-ignored\" to suppress this warning."; } else { - message = "File ignored because of a matching ignore pattern. Use \"--no-ignore\" to override."; + message = "File ignored because of a matching ignore pattern. Use \"--no-ignore\" to disable file ignore settings or use \"--no-warn-ignored\" to suppress this warning."; } return { @@ -676,6 +676,7 @@ function processOptions({ overrideConfigFile = null, plugins = {}, reportUnusedDisableDirectives = null, // ← should be null by default because if it's a string then it overrides the 'reportUnusedDisableDirectives' setting in config files. And we cannot use `overrideConfig.reportUnusedDisableDirectives` instead because we cannot configure the `error` severity with that. + warnIgnored = true, ...unknownOptions }) { const errors = []; @@ -781,6 +782,9 @@ function processOptions({ ) { errors.push("'reportUnusedDisableDirectives' must be any of \"error\", \"warn\", \"off\", and null."); } + if (typeof warnIgnored !== "boolean") { + errors.push("'warnIgnored' must be a boolean."); + } if (errors.length > 0) { throw new ESLintInvalidOptionsError(errors); } @@ -802,7 +806,8 @@ function processOptions({ globInputPaths, ignore, ignorePatterns, - reportUnusedDisableDirectives + reportUnusedDisableDirectives, + warnIgnored }; } diff --git a/tools/node_modules/eslint/lib/eslint/flat-eslint.js b/tools/node_modules/eslint/lib/eslint/flat-eslint.js index 9d511aab2e8489..306c80de1d659e 100644 --- a/tools/node_modules/eslint/lib/eslint/flat-eslint.js +++ b/tools/node_modules/eslint/lib/eslint/flat-eslint.js @@ -84,6 +84,7 @@ const LintResultCache = require("../cli-engine/lint-result-cache"); * when a string. * @property {Record} [plugins] An array of plugin implementations. * @property {"error" | "warn" | "off"} [reportUnusedDisableDirectives] the severity to report unused eslint-disable directives. + * @property {boolean} warnIgnored Show warnings when the file list includes ignored files */ //------------------------------------------------------------------------------ @@ -576,7 +577,6 @@ class FlatESLint { cacheFilePath, lintResultCache, defaultConfigs, - defaultIgnores: () => false, configs: null }); @@ -715,12 +715,10 @@ class FlatESLint { } const rule = getRuleFromConfig(ruleId, config); - // ensure the rule exists - if (!rule) { - throw new TypeError(`Could not find the rule "${ruleId}".`); + // ignore unknown rules + if (rule) { + resultRules.set(ruleId, rule); } - - resultRules.set(ruleId, rule); } } @@ -752,7 +750,8 @@ class FlatESLint { fixTypes, reportUnusedDisableDirectives, globInputPaths, - errorOnUnmatchedPattern + errorOnUnmatchedPattern, + warnIgnored } = eslintOptions; const startTime = Date.now(); const fixTypesSet = fixTypes ? new Set(fixTypes) : null; @@ -798,7 +797,11 @@ class FlatESLint { * pattern, then notify the user. */ if (ignored) { - return createIgnoreResult(filePath, cwd); + if (warnIgnored) { + return createIgnoreResult(filePath, cwd); + } + + return void 0; } const config = configs.getConfig(filePath); @@ -911,7 +914,7 @@ class FlatESLint { const { filePath, - warnIgnored = false, + warnIgnored, ...unknownOptions } = options || {}; @@ -925,7 +928,7 @@ class FlatESLint { throw new Error("'options.filePath' must be a non-empty string or undefined"); } - if (typeof warnIgnored !== "boolean") { + if (typeof warnIgnored !== "boolean" && typeof warnIgnored !== "undefined") { throw new Error("'options.warnIgnored' must be a boolean or undefined"); } @@ -940,7 +943,8 @@ class FlatESLint { allowInlineConfig, cwd, fix, - reportUnusedDisableDirectives + reportUnusedDisableDirectives, + warnIgnored: constructorWarnIgnored } = eslintOptions; const results = []; const startTime = Date.now(); @@ -948,7 +952,9 @@ class FlatESLint { // Clear the last used config arrays. if (resolvedFilename && await this.isPathIgnored(resolvedFilename)) { - if (warnIgnored) { + const shouldWarnIgnored = typeof warnIgnored === "boolean" ? warnIgnored : constructorWarnIgnored; + + if (shouldWarnIgnored) { results.push(createIgnoreResult(resolvedFilename, cwd)); } } else { diff --git a/tools/node_modules/eslint/lib/linter/apply-disable-directives.js b/tools/node_modules/eslint/lib/linter/apply-disable-directives.js index 13ced990ff485d..55f7683f3f53ac 100644 --- a/tools/node_modules/eslint/lib/linter/apply-disable-directives.js +++ b/tools/node_modules/eslint/lib/linter/apply-disable-directives.js @@ -87,7 +87,7 @@ function createIndividualDirectivesRemoval(directives, commentToken) { return directives.map(directive => { const { ruleId } = directive; - const regex = new RegExp(String.raw`(?:^|\s*,\s*)${escapeRegExp(ruleId)}(?:\s*,\s*|$)`, "u"); + const regex = new RegExp(String.raw`(?:^|\s*,\s*)(?['"]?)${escapeRegExp(ruleId)}\k(?:\s*,\s*|$)`, "u"); const match = regex.exec(listText); const matchedText = match[0]; const matchStartOffset = listStartOffset + match.index; diff --git a/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path-analyzer.js b/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path-analyzer.js index 2dcc2734884459..b60e55c16dedcf 100644 --- a/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path-analyzer.js +++ b/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path-analyzer.js @@ -192,15 +192,18 @@ function forwardCurrentToHead(analyzer, node) { headSegment = headSegments[i]; if (currentSegment !== headSegment && currentSegment) { - debug.dump(`onCodePathSegmentEnd ${currentSegment.id}`); - if (currentSegment.reachable) { - analyzer.emitter.emit( - "onCodePathSegmentEnd", - currentSegment, - node - ); - } + const eventName = currentSegment.reachable + ? "onCodePathSegmentEnd" + : "onUnreachableCodePathSegmentEnd"; + + debug.dump(`${eventName} ${currentSegment.id}`); + + analyzer.emitter.emit( + eventName, + currentSegment, + node + ); } } @@ -213,16 +216,19 @@ function forwardCurrentToHead(analyzer, node) { headSegment = headSegments[i]; if (currentSegment !== headSegment && headSegment) { - debug.dump(`onCodePathSegmentStart ${headSegment.id}`); + + const eventName = headSegment.reachable + ? "onCodePathSegmentStart" + : "onUnreachableCodePathSegmentStart"; + + debug.dump(`${eventName} ${headSegment.id}`); CodePathSegment.markUsed(headSegment); - if (headSegment.reachable) { - analyzer.emitter.emit( - "onCodePathSegmentStart", - headSegment, - node - ); - } + analyzer.emitter.emit( + eventName, + headSegment, + node + ); } } @@ -241,15 +247,17 @@ function leaveFromCurrentSegment(analyzer, node) { for (let i = 0; i < currentSegments.length; ++i) { const currentSegment = currentSegments[i]; + const eventName = currentSegment.reachable + ? "onCodePathSegmentEnd" + : "onUnreachableCodePathSegmentEnd"; - debug.dump(`onCodePathSegmentEnd ${currentSegment.id}`); - if (currentSegment.reachable) { - analyzer.emitter.emit( - "onCodePathSegmentEnd", - currentSegment, - node - ); - } + debug.dump(`${eventName} ${currentSegment.id}`); + + analyzer.emitter.emit( + eventName, + currentSegment, + node + ); } state.currentSegments = []; diff --git a/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path-segment.js b/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path-segment.js index fd2726a9937a75..3b8dbb41be64d5 100644 --- a/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path-segment.js +++ b/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path-segment.js @@ -1,5 +1,5 @@ /** - * @fileoverview A class of the code path segment. + * @fileoverview The CodePathSegment class. * @author Toru Nagashima */ @@ -30,10 +30,22 @@ function isReachable(segment) { /** * A code path segment. + * + * Each segment is arranged in a series of linked lists (implemented by arrays) + * that keep track of the previous and next segments in a code path. In this way, + * you can navigate between all segments in any code path so long as you have a + * reference to any segment in that code path. + * + * When first created, the segment is in a detached state, meaning that it knows the + * segments that came before it but those segments don't know that this new segment + * follows it. Only when `CodePathSegment#markUsed()` is called on a segment does it + * officially become part of the code path by updating the previous segments to know + * that this new segment follows. */ class CodePathSegment { /** + * Creates a new instance. * @param {string} id An identifier. * @param {CodePathSegment[]} allPrevSegments An array of the previous segments. * This array includes unreachable segments. @@ -49,27 +61,25 @@ class CodePathSegment { this.id = id; /** - * An array of the next segments. + * An array of the next reachable segments. * @type {CodePathSegment[]} */ this.nextSegments = []; /** - * An array of the previous segments. + * An array of the previous reachable segments. * @type {CodePathSegment[]} */ this.prevSegments = allPrevSegments.filter(isReachable); /** - * An array of the next segments. - * This array includes unreachable segments. + * An array of all next segments including reachable and unreachable. * @type {CodePathSegment[]} */ this.allNextSegments = []; /** - * An array of the previous segments. - * This array includes unreachable segments. + * An array of all previous segments including reachable and unreachable. * @type {CodePathSegment[]} */ this.allPrevSegments = allPrevSegments; @@ -83,7 +93,11 @@ class CodePathSegment { // Internal data. Object.defineProperty(this, "internal", { value: { + + // determines if the segment has been attached to the code path used: false, + + // array of previous segments coming from the end of a loop loopedPrevSegments: [] } }); @@ -113,9 +127,10 @@ class CodePathSegment { } /** - * Creates a segment that follows given segments. + * Creates a new segment and appends it after the given segments. * @param {string} id An identifier. - * @param {CodePathSegment[]} allPrevSegments An array of the previous segments. + * @param {CodePathSegment[]} allPrevSegments An array of the previous segments + * to append to. * @returns {CodePathSegment} The created segment. */ static newNext(id, allPrevSegments) { @@ -127,7 +142,7 @@ class CodePathSegment { } /** - * Creates an unreachable segment that follows given segments. + * Creates an unreachable segment and appends it after the given segments. * @param {string} id An identifier. * @param {CodePathSegment[]} allPrevSegments An array of the previous segments. * @returns {CodePathSegment} The created segment. @@ -137,7 +152,7 @@ class CodePathSegment { /* * In `if (a) return a; foo();` case, the unreachable segment preceded by - * the return statement is not used but must not be remove. + * the return statement is not used but must not be removed. */ CodePathSegment.markUsed(segment); @@ -157,7 +172,7 @@ class CodePathSegment { } /** - * Makes a given segment being used. + * Marks a given segment as used. * * And this function registers the segment into the previous segments as a next. * @param {CodePathSegment} segment A segment to mark. @@ -172,6 +187,13 @@ class CodePathSegment { let i; if (segment.reachable) { + + /* + * If the segment is reachable, then it's officially part of the + * code path. This loops through all previous segments to update + * their list of next segments. Because the segment is reachable, + * it's added to both `nextSegments` and `allNextSegments`. + */ for (i = 0; i < segment.allPrevSegments.length; ++i) { const prevSegment = segment.allPrevSegments[i]; @@ -179,6 +201,13 @@ class CodePathSegment { prevSegment.nextSegments.push(segment); } } else { + + /* + * If the segment is not reachable, then it's not officially part of the + * code path. This loops through all previous segments to update + * their list of next segments. Because the segment is not reachable, + * it's added only to `allNextSegments`. + */ for (i = 0; i < segment.allPrevSegments.length; ++i) { segment.allPrevSegments[i].allNextSegments.push(segment); } @@ -196,19 +225,20 @@ class CodePathSegment { } /** - * Replaces unused segments with the previous segments of each unused segment. - * @param {CodePathSegment[]} segments An array of segments to replace. - * @returns {CodePathSegment[]} The replaced array. + * Creates a new array based on an array of segments. If any segment in the + * array is unused, then it is replaced by all of its previous segments. + * All used segments are returned as-is without replacement. + * @param {CodePathSegment[]} segments The array of segments to flatten. + * @returns {CodePathSegment[]} The flattened array. */ static flattenUnusedSegments(segments) { - const done = Object.create(null); - const retv = []; + const done = new Set(); for (let i = 0; i < segments.length; ++i) { const segment = segments[i]; // Ignores duplicated. - if (done[segment.id]) { + if (done.has(segment)) { continue; } @@ -217,18 +247,16 @@ class CodePathSegment { for (let j = 0; j < segment.allPrevSegments.length; ++j) { const prevSegment = segment.allPrevSegments[j]; - if (!done[prevSegment.id]) { - done[prevSegment.id] = true; - retv.push(prevSegment); + if (!done.has(prevSegment)) { + done.add(prevSegment); } } } else { - done[segment.id] = true; - retv.push(segment); + done.add(segment); } } - return retv; + return [...done]; } } diff --git a/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path-state.js b/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path-state.js index d187297d32b0d3..2b0dc2bfca0403 100644 --- a/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path-state.js +++ b/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path-state.js @@ -12,13 +12,622 @@ const CodePathSegment = require("./code-path-segment"), ForkContext = require("./fork-context"); +//----------------------------------------------------------------------------- +// Contexts +//----------------------------------------------------------------------------- + +/** + * Represents the context in which a `break` statement can be used. + * + * A `break` statement without a label is only valid in a few places in + * JavaScript: any type of loop or a `switch` statement. Otherwise, `break` + * without a label causes a syntax error. For these contexts, `breakable` is + * set to `true` to indicate that a `break` without a label is valid. + * + * However, a `break` statement with a label is also valid inside of a labeled + * statement. For example, this is valid: + * + * a : { + * break a; + * } + * + * The `breakable` property is set false for labeled statements to indicate + * that `break` without a label is invalid. + */ +class BreakContext { + + /** + * Creates a new instance. + * @param {BreakContext} upperContext The previous `BreakContext`. + * @param {boolean} breakable Indicates if we are inside a statement where + * `break` without a label will exit the statement. + * @param {string|null} label The label for the statement. + * @param {ForkContext} forkContext The current fork context. + */ + constructor(upperContext, breakable, label, forkContext) { + + /** + * The previous `BreakContext` + * @type {BreakContext} + */ + this.upper = upperContext; + + /** + * Indicates if we are inside a statement where `break` without a label + * will exit the statement. + * @type {boolean} + */ + this.breakable = breakable; + + /** + * The label associated with the statement. + * @type {string|null} + */ + this.label = label; + + /** + * The fork context for the `break`. + * @type {ForkContext} + */ + this.brokenForkContext = ForkContext.newEmpty(forkContext); + } +} + +/** + * Represents the context for `ChainExpression` nodes. + */ +class ChainContext { + + /** + * Creates a new instance. + * @param {ChainContext} upperContext The previous `ChainContext`. + */ + constructor(upperContext) { + + /** + * The previous `ChainContext` + * @type {ChainContext} + */ + this.upper = upperContext; + + /** + * The number of choice contexts inside of the `ChainContext`. + * @type {number} + */ + this.choiceContextCount = 0; + + } +} + +/** + * Represents a choice in the code path. + * + * Choices are created by logical operators such as `&&`, loops, conditionals, + * and `if` statements. This is the point at which the code path has a choice of + * which direction to go. + * + * The result of a choice might be in the left (test) expression of another choice, + * and in that case, may create a new fork. For example, `a || b` is a choice + * but does not create a new fork because the result of the expression is + * not used as the test expression in another expression. In this case, + * `isForkingAsResult` is false. In the expression `a || b || c`, the `a || b` + * expression appears as the test expression for `|| c`, so the + * result of `a || b` creates a fork because execution may or may not + * continue to `|| c`. `isForkingAsResult` for `a || b` in this case is true + * while `isForkingAsResult` for `|| c` is false. (`isForkingAsResult` is always + * false for `if` statements, conditional expressions, and loops.) + * + * All of the choices except one (`??`) operate on a true/false fork, meaning if + * true go one way and if false go the other (tracked by `trueForkContext` and + * `falseForkContext`). The `??` operator doesn't operate on true/false because + * the left expression is evaluated to be nullish or not, so only if nullish do + * we fork to the right expression (tracked by `nullishForkContext`). + */ +class ChoiceContext { + + /** + * Creates a new instance. + * @param {ChoiceContext} upperContext The previous `ChoiceContext`. + * @param {string} kind The kind of choice. If it's a logical or assignment expression, this + * is `"&&"` or `"||"` or `"??"`; if it's an `if` statement or + * conditional expression, this is `"test"`; otherwise, this is `"loop"`. + * @param {boolean} isForkingAsResult Indicates if the result of the choice + * creates a fork. + * @param {ForkContext} forkContext The containing `ForkContext`. + */ + constructor(upperContext, kind, isForkingAsResult, forkContext) { + + /** + * The previous `ChoiceContext` + * @type {ChoiceContext} + */ + this.upper = upperContext; + + /** + * The kind of choice. If it's a logical or assignment expression, this + * is `"&&"` or `"||"` or `"??"`; if it's an `if` statement or + * conditional expression, this is `"test"`; otherwise, this is `"loop"`. + * @type {string} + */ + this.kind = kind; + + /** + * Indicates if the result of the choice forks the code path. + * @type {boolean} + */ + this.isForkingAsResult = isForkingAsResult; + + /** + * The fork context for the `true` path of the choice. + * @type {ForkContext} + */ + this.trueForkContext = ForkContext.newEmpty(forkContext); + + /** + * The fork context for the `false` path of the choice. + * @type {ForkContext} + */ + this.falseForkContext = ForkContext.newEmpty(forkContext); + + /** + * The fork context for when the choice result is `null` or `undefined`. + * @type {ForkContext} + */ + this.nullishForkContext = ForkContext.newEmpty(forkContext); + + /** + * Indicates if any of `trueForkContext`, `falseForkContext`, or + * `nullishForkContext` have been updated with segments from a child context. + * @type {boolean} + */ + this.processed = false; + } + +} + +/** + * Base class for all loop contexts. + */ +class LoopContextBase { + + /** + * Creates a new instance. + * @param {LoopContext|null} upperContext The previous `LoopContext`. + * @param {string} type The AST node's `type` for the loop. + * @param {string|null} label The label for the loop from an enclosing `LabeledStatement`. + * @param {BreakContext} breakContext The context for breaking the loop. + */ + constructor(upperContext, type, label, breakContext) { + + /** + * The previous `LoopContext`. + * @type {LoopContext} + */ + this.upper = upperContext; + + /** + * The AST node's `type` for the loop. + * @type {string} + */ + this.type = type; + + /** + * The label for the loop from an enclosing `LabeledStatement`. + * @type {string|null} + */ + this.label = label; + + /** + * The fork context for when `break` is encountered. + * @type {ForkContext} + */ + this.brokenForkContext = breakContext.brokenForkContext; + } +} + +/** + * Represents the context for a `while` loop. + */ +class WhileLoopContext extends LoopContextBase { + + /** + * Creates a new instance. + * @param {LoopContext|null} upperContext The previous `LoopContext`. + * @param {string|null} label The label for the loop from an enclosing `LabeledStatement`. + * @param {BreakContext} breakContext The context for breaking the loop. + */ + constructor(upperContext, label, breakContext) { + super(upperContext, "WhileStatement", label, breakContext); + + /** + * The hardcoded literal boolean test condition for + * the loop. Used to catch infinite or skipped loops. + * @type {boolean|undefined} + */ + this.test = void 0; + + /** + * The segments representing the test condition where `continue` will + * jump to. The test condition will typically have just one segment but + * it's possible for there to be more than one. + * @type {Array|null} + */ + this.continueDestSegments = null; + } +} + +/** + * Represents the context for a `do-while` loop. + */ +class DoWhileLoopContext extends LoopContextBase { + + /** + * Creates a new instance. + * @param {LoopContext|null} upperContext The previous `LoopContext`. + * @param {string|null} label The label for the loop from an enclosing `LabeledStatement`. + * @param {BreakContext} breakContext The context for breaking the loop. + * @param {ForkContext} forkContext The enclosing fork context. + */ + constructor(upperContext, label, breakContext, forkContext) { + super(upperContext, "DoWhileStatement", label, breakContext); + + /** + * The hardcoded literal boolean test condition for + * the loop. Used to catch infinite or skipped loops. + * @type {boolean|undefined} + */ + this.test = void 0; + + /** + * The segments at the start of the loop body. This is the only loop + * where the test comes at the end, so the first iteration always + * happens and we need a reference to the first statements. + * @type {Array|null} + */ + this.entrySegments = null; + + /** + * The fork context to follow when a `continue` is found. + * @type {ForkContext} + */ + this.continueForkContext = ForkContext.newEmpty(forkContext); + } +} + +/** + * Represents the context for a `for` loop. + */ +class ForLoopContext extends LoopContextBase { + + /** + * Creates a new instance. + * @param {LoopContext|null} upperContext The previous `LoopContext`. + * @param {string|null} label The label for the loop from an enclosing `LabeledStatement`. + * @param {BreakContext} breakContext The context for breaking the loop. + */ + constructor(upperContext, label, breakContext) { + super(upperContext, "ForStatement", label, breakContext); + + /** + * The hardcoded literal boolean test condition for + * the loop. Used to catch infinite or skipped loops. + * @type {boolean|undefined} + */ + this.test = void 0; + + /** + * The end of the init expression. This may change during the lifetime + * of the instance as we traverse the loop because some loops don't have + * an init expression. + * @type {Array|null} + */ + this.endOfInitSegments = null; + + /** + * The start of the test expression. This may change during the lifetime + * of the instance as we traverse the loop because some loops don't have + * a test expression. + * @type {Array|null} + */ + this.testSegments = null; + + /** + * The end of the test expression. This may change during the lifetime + * of the instance as we traverse the loop because some loops don't have + * a test expression. + * @type {Array|null} + */ + this.endOfTestSegments = null; + + /** + * The start of the update expression. This may change during the lifetime + * of the instance as we traverse the loop because some loops don't have + * an update expression. + * @type {Array|null} + */ + this.updateSegments = null; + + /** + * The end of the update expresion. This may change during the lifetime + * of the instance as we traverse the loop because some loops don't have + * an update expression. + * @type {Array|null} + */ + this.endOfUpdateSegments = null; + + /** + * The segments representing the test condition where `continue` will + * jump to. The test condition will typically have just one segment but + * it's possible for there to be more than one. This may change during the + * lifetime of the instance as we traverse the loop because some loops + * don't have an update expression. When there is an update expression, this + * will end up pointing to that expression; otherwise it will end up pointing + * to the test expression. + * @type {Array|null} + */ + this.continueDestSegments = null; + } +} + +/** + * Represents the context for a `for-in` loop. + * + * Terminology: + * - "left" means the part of the loop to the left of the `in` keyword. For + * example, in `for (var x in y)`, the left is `var x`. + * - "right" means the part of the loop to the right of the `in` keyword. For + * example, in `for (var x in y)`, the right is `y`. + */ +class ForInLoopContext extends LoopContextBase { + + /** + * Creates a new instance. + * @param {LoopContext|null} upperContext The previous `LoopContext`. + * @param {string|null} label The label for the loop from an enclosing `LabeledStatement`. + * @param {BreakContext} breakContext The context for breaking the loop. + */ + constructor(upperContext, label, breakContext) { + super(upperContext, "ForInStatement", label, breakContext); + + /** + * The segments that came immediately before the start of the loop. + * This allows you to traverse backwards out of the loop into the + * surrounding code. This is necessary to evaluate the right expression + * correctly, as it must be evaluated in the same way as the left + * expression, but the pointer to these segments would otherwise be + * lost if not stored on the instance. Once the right expression has + * been evaluated, this property is no longer used. + * @type {Array|null} + */ + this.prevSegments = null; + + /** + * Segments representing the start of everything to the left of the + * `in` keyword. This can be used to move forward towards + * `endOfLeftSegments`. `leftSegments` and `endOfLeftSegments` are + * effectively the head and tail of a doubly-linked list. + * @type {Array|null} + */ + this.leftSegments = null; + + /** + * Segments representing the end of everything to the left of the + * `in` keyword. This can be used to move backward towards `leftSegments`. + * `leftSegments` and `endOfLeftSegments` are effectively the head + * and tail of a doubly-linked list. + * @type {Array|null} + */ + this.endOfLeftSegments = null; + + /** + * The segments representing the left expression where `continue` will + * jump to. In `for-in` loops, `continue` must always re-execute the + * left expression each time through the loop. This contains the same + * segments as `leftSegments`, but is duplicated here so each loop + * context has the same property pointing to where `continue` should + * end up. + * @type {Array|null} + */ + this.continueDestSegments = null; + } +} + +/** + * Represents the context for a `for-of` loop. + */ +class ForOfLoopContext extends LoopContextBase { + + /** + * Creates a new instance. + * @param {LoopContext|null} upperContext The previous `LoopContext`. + * @param {string|null} label The label for the loop from an enclosing `LabeledStatement`. + * @param {BreakContext} breakContext The context for breaking the loop. + */ + constructor(upperContext, label, breakContext) { + super(upperContext, "ForOfStatement", label, breakContext); + + /** + * The segments that came immediately before the start of the loop. + * This allows you to traverse backwards out of the loop into the + * surrounding code. This is necessary to evaluate the right expression + * correctly, as it must be evaluated in the same way as the left + * expression, but the pointer to these segments would otherwise be + * lost if not stored on the instance. Once the right expression has + * been evaluated, this property is no longer used. + * @type {Array|null} + */ + this.prevSegments = null; + + /** + * Segments representing the start of everything to the left of the + * `of` keyword. This can be used to move forward towards + * `endOfLeftSegments`. `leftSegments` and `endOfLeftSegments` are + * effectively the head and tail of a doubly-linked list. + * @type {Array|null} + */ + this.leftSegments = null; + + /** + * Segments representing the end of everything to the left of the + * `of` keyword. This can be used to move backward towards `leftSegments`. + * `leftSegments` and `endOfLeftSegments` are effectively the head + * and tail of a doubly-linked list. + * @type {Array|null} + */ + this.endOfLeftSegments = null; + + /** + * The segments representing the left expression where `continue` will + * jump to. In `for-in` loops, `continue` must always re-execute the + * left expression each time through the loop. This contains the same + * segments as `leftSegments`, but is duplicated here so each loop + * context has the same property pointing to where `continue` should + * end up. + * @type {Array|null} + */ + this.continueDestSegments = null; + } +} + +/** + * Represents the context for any loop. + * @typedef {WhileLoopContext|DoWhileLoopContext|ForLoopContext|ForInLoopContext|ForOfLoopContext} LoopContext + */ + +/** + * Represents the context for a `switch` statement. + */ +class SwitchContext { + + /** + * Creates a new instance. + * @param {SwitchContext} upperContext The previous context. + * @param {boolean} hasCase Indicates if there is at least one `case` statement. + * `default` doesn't count. + */ + constructor(upperContext, hasCase) { + + /** + * The previous context. + * @type {SwitchContext} + */ + this.upper = upperContext; + + /** + * Indicates if there is at least one `case` statement. `default` doesn't count. + * @type {boolean} + */ + this.hasCase = hasCase; + + /** + * The `default` keyword. + * @type {Array|null} + */ + this.defaultSegments = null; + + /** + * The default case body starting segments. + * @type {Array|null} + */ + this.defaultBodySegments = null; + + /** + * Indicates if a `default` case and is empty exists. + * @type {boolean} + */ + this.foundEmptyDefault = false; + + /** + * Indicates that a `default` exists and is the last case. + * @type {boolean} + */ + this.lastIsDefault = false; + + /** + * The number of fork contexts created. This is equivalent to the + * number of `case` statements plus a `default` statement (if present). + * @type {number} + */ + this.forkCount = 0; + } +} + +/** + * Represents the context for a `try` statement. + */ +class TryContext { + + /** + * Creates a new instance. + * @param {TryContext} upperContext The previous context. + * @param {boolean} hasFinalizer Indicates if the `try` statement has a + * `finally` block. + * @param {ForkContext} forkContext The enclosing fork context. + */ + constructor(upperContext, hasFinalizer, forkContext) { + + /** + * The previous context. + * @type {TryContext} + */ + this.upper = upperContext; + + /** + * Indicates if the `try` statement has a `finally` block. + * @type {boolean} + */ + this.hasFinalizer = hasFinalizer; + + /** + * Tracks the traversal position inside of the `try` statement. This is + * used to help determine the context necessary to create paths because + * a `try` statement may or may not have `catch` or `finally` blocks, + * and code paths behave differently in those blocks. + * @type {"try"|"catch"|"finally"} + */ + this.position = "try"; + + /** + * If the `try` statement has a `finally` block, this affects how a + * `return` statement behaves in the `try` block. Without `finally`, + * `return` behaves as usual and doesn't require a fork; with `finally`, + * `return` forks into the `finally` block, so we need a fork context + * to track it. + * @type {ForkContext|null} + */ + this.returnedForkContext = hasFinalizer + ? ForkContext.newEmpty(forkContext) + : null; + + /** + * When a `throw` occurs inside of a `try` block, the code path forks + * into the `catch` or `finally` blocks, and this fork context tracks + * that path. + * @type {ForkContext} + */ + this.thrownForkContext = ForkContext.newEmpty(forkContext); + + /** + * Indicates if the last segment in the `try` block is reachable. + * @type {boolean} + */ + this.lastOfTryIsReachable = false; + + /** + * Indicates if the last segment in the `catch` block is reachable. + * @type {boolean} + */ + this.lastOfCatchIsReachable = false; + } +} + //------------------------------------------------------------------------------ // Helpers //------------------------------------------------------------------------------ /** * Adds given segments into the `dest` array. - * If the `others` array does not includes the given segments, adds to the `all` + * If the `others` array does not include the given segments, adds to the `all` * array as well. * * This adds only reachable and used segments. @@ -40,9 +649,9 @@ function addToReturnedOrThrown(dest, others, all, segments) { } /** - * Gets a loop-context for a `continue` statement. - * @param {CodePathState} state A state to get. - * @param {string} label The label of a `continue` statement. + * Gets a loop context for a `continue` statement based on a given label. + * @param {CodePathState} state The state to search within. + * @param {string|null} label The label of a `continue` statement. * @returns {LoopContext} A loop-context for a `continue` statement. */ function getContinueContext(state, label) { @@ -65,9 +674,9 @@ function getContinueContext(state, label) { /** * Gets a context for a `break` statement. - * @param {CodePathState} state A state to get. - * @param {string} label The label of a `break` statement. - * @returns {LoopContext|SwitchContext} A context for a `break` statement. + * @param {CodePathState} state The state to search within. + * @param {string|null} label The label of a `break` statement. + * @returns {BreakContext} A context for a `break` statement. */ function getBreakContext(state, label) { let context = state.breakContext; @@ -84,8 +693,10 @@ function getBreakContext(state, label) { } /** - * Gets a context for a `return` statement. - * @param {CodePathState} state A state to get. + * Gets a context for a `return` statement. There is just one special case: + * if there is a `try` statement with a `finally` block, because that alters + * how `return` behaves; otherwise, this just passes through the given state. + * @param {CodePathState} state The state to search within * @returns {TryContext|CodePathState} A context for a `return` statement. */ function getReturnContext(state) { @@ -102,8 +713,11 @@ function getReturnContext(state) { } /** - * Gets a context for a `throw` statement. - * @param {CodePathState} state A state to get. + * Gets a context for a `throw` statement. There is just one special case: + * if there is a `try` statement with a `finally` block and we are inside of + * a `catch` because that changes how `throw` behaves; otherwise, this just + * passes through the given state. + * @param {CodePathState} state The state to search within. * @returns {TryContext|CodePathState} A context for a `throw` statement. */ function getThrowContext(state) { @@ -122,13 +736,13 @@ function getThrowContext(state) { } /** - * Removes a given element from a given array. - * @param {any[]} xs An array to remove the specific element. - * @param {any} x An element to be removed. + * Removes a given value from a given array. + * @param {any[]} elements An array to remove the specific element. + * @param {any} value The value to be removed. * @returns {void} */ -function remove(xs, x) { - xs.splice(xs.indexOf(x), 1); +function removeFromArray(elements, value) { + elements.splice(elements.indexOf(value), 1); } /** @@ -141,48 +755,77 @@ function remove(xs, x) { * @param {CodePathSegment[]} nextSegments Backward segments to disconnect. * @returns {void} */ -function removeConnection(prevSegments, nextSegments) { +function disconnectSegments(prevSegments, nextSegments) { for (let i = 0; i < prevSegments.length; ++i) { const prevSegment = prevSegments[i]; const nextSegment = nextSegments[i]; - remove(prevSegment.nextSegments, nextSegment); - remove(prevSegment.allNextSegments, nextSegment); - remove(nextSegment.prevSegments, prevSegment); - remove(nextSegment.allPrevSegments, prevSegment); + removeFromArray(prevSegment.nextSegments, nextSegment); + removeFromArray(prevSegment.allNextSegments, nextSegment); + removeFromArray(nextSegment.prevSegments, prevSegment); + removeFromArray(nextSegment.allPrevSegments, prevSegment); } } /** - * Creates looping path. - * @param {CodePathState} state The instance. + * Creates looping path between two arrays of segments, ensuring that there are + * paths going between matching segments in the arrays. + * @param {CodePathState} state The state to operate on. * @param {CodePathSegment[]} unflattenedFromSegments Segments which are source. * @param {CodePathSegment[]} unflattenedToSegments Segments which are destination. * @returns {void} */ function makeLooped(state, unflattenedFromSegments, unflattenedToSegments) { + const fromSegments = CodePathSegment.flattenUnusedSegments(unflattenedFromSegments); const toSegments = CodePathSegment.flattenUnusedSegments(unflattenedToSegments); - const end = Math.min(fromSegments.length, toSegments.length); + /* + * This loop effectively updates a doubly-linked list between two collections + * of segments making sure that segments in the same array indices are + * combined to create a path. + */ for (let i = 0; i < end; ++i) { + + // get the segments in matching array indices const fromSegment = fromSegments[i]; const toSegment = toSegments[i]; + /* + * If the destination segment is reachable, then create a path from the + * source segment to the destination segment. + */ if (toSegment.reachable) { fromSegment.nextSegments.push(toSegment); } + + /* + * If the source segment is reachable, then create a path from the + * destination segment back to the source segment. + */ if (fromSegment.reachable) { toSegment.prevSegments.push(fromSegment); } + + /* + * Also update the arrays that don't care if the segments are reachable + * or not. This should always happen regardless of anything else. + */ fromSegment.allNextSegments.push(toSegment); toSegment.allPrevSegments.push(fromSegment); + /* + * If the destination segment has at least two previous segments in its + * path then that means there was one previous segment before this iteration + * of the loop was executed. So, we need to mark the source segment as + * looped. + */ if (toSegment.allPrevSegments.length >= 2) { CodePathSegment.markPrevSegmentAsLooped(toSegment, fromSegment); } + // let the code path analyzer know that there's been a loop created state.notifyLooped(fromSegment, toSegment); } } @@ -198,15 +841,27 @@ function makeLooped(state, unflattenedFromSegments, unflattenedToSegments) { * @returns {void} */ function finalizeTestSegmentsOfFor(context, choiceContext, head) { + + /* + * If this choice context doesn't already contain paths from a + * child context, then add the current head to each potential path. + */ if (!choiceContext.processed) { choiceContext.trueForkContext.add(head); choiceContext.falseForkContext.add(head); - choiceContext.qqForkContext.add(head); + choiceContext.nullishForkContext.add(head); } + /* + * If the test condition isn't a hardcoded truthy value, then `break` + * must follow the same path as if the test condition is false. To represent + * that, we append the path for when the loop test is false (represented by + * `falseForkContext`) to the `brokenForkContext`. + */ if (context.test !== true) { context.brokenForkContext.addAll(choiceContext.falseForkContext); } + context.endOfTestSegments = choiceContext.trueForkContext.makeNext(0, -1); } @@ -220,35 +875,124 @@ function finalizeTestSegmentsOfFor(context, choiceContext, head) { class CodePathState { /** + * Creates a new instance. * @param {IdGenerator} idGenerator An id generator to generate id for code * path segments. * @param {Function} onLooped A callback function to notify looping. */ constructor(idGenerator, onLooped) { + + /** + * The ID generator to use when creating new segments. + * @type {IdGenerator} + */ this.idGenerator = idGenerator; + + /** + * A callback function to call when there is a loop. + * @type {Function} + */ this.notifyLooped = onLooped; + + /** + * The root fork context for this state. + * @type {ForkContext} + */ this.forkContext = ForkContext.newRoot(idGenerator); + + /** + * Context for logical expressions, conditional expressions, `if` statements, + * and loops. + * @type {ChoiceContext} + */ this.choiceContext = null; + + /** + * Context for `switch` statements. + * @type {SwitchContext} + */ this.switchContext = null; + + /** + * Context for `try` statements. + * @type {TryContext} + */ this.tryContext = null; + + /** + * Context for loop statements. + * @type {LoopContext} + */ this.loopContext = null; + + /** + * Context for `break` statements. + * @type {BreakContext} + */ this.breakContext = null; + + /** + * Context for `ChainExpression` nodes. + * @type {ChainContext} + */ this.chainContext = null; + /** + * An array that tracks the current segments in the state. The array + * starts empty and segments are added with each `onCodePathSegmentStart` + * event and removed with each `onCodePathSegmentEnd` event. Effectively, + * this is tracking the code path segment traversal as the state is + * modified. + * @type {Array} + */ this.currentSegments = []; + + /** + * Tracks the starting segment for this path. This value never changes. + * @type {CodePathSegment} + */ this.initialSegment = this.forkContext.head[0]; - // returnedSegments and thrownSegments push elements into finalSegments also. - const final = this.finalSegments = []; - const returned = this.returnedForkContext = []; - const thrown = this.thrownForkContext = []; + /** + * The final segments of the code path which are either `return` or `throw`. + * This is a union of the segments in `returnedForkContext` and `thrownForkContext`. + * @type {Array} + */ + this.finalSegments = []; + + /** + * The final segments of the code path which are `return`. These + * segments are also contained in `finalSegments`. + * @type {Array} + */ + this.returnedForkContext = []; + + /** + * The final segments of the code path which are `throw`. These + * segments are also contained in `finalSegments`. + * @type {Array} + */ + this.thrownForkContext = []; + + /* + * We add an `add` method so that these look more like fork contexts and + * can be used interchangeably when a fork context is needed to add more + * segments to a path. + * + * Ultimately, we want anything added to `returned` or `thrown` to also + * be added to `final`. We only add reachable and used segments to these + * arrays. + */ + const final = this.finalSegments; + const returned = this.returnedForkContext; + const thrown = this.thrownForkContext; returned.add = addToReturnedOrThrown.bind(null, returned, thrown, final); thrown.add = addToReturnedOrThrown.bind(null, thrown, returned, final); } /** - * The head segments. + * A passthrough property exposing the current pointer as part of the API. * @type {CodePathSegment[]} */ get headSegments() { @@ -341,77 +1085,72 @@ class CodePathState { * If the new context is LogicalExpression's or AssignmentExpression's, this is `"&&"` or `"||"` or `"??"`. * If it's IfStatement's or ConditionalExpression's, this is `"test"`. * Otherwise, this is `"loop"`. - * @param {boolean} isForkingAsResult A flag that shows that goes different - * paths between `true` and `false`. + * @param {boolean} isForkingAsResult Indicates if the result of the choice + * creates a fork. * @returns {void} */ pushChoiceContext(kind, isForkingAsResult) { - this.choiceContext = { - upper: this.choiceContext, - kind, - isForkingAsResult, - trueForkContext: ForkContext.newEmpty(this.forkContext), - falseForkContext: ForkContext.newEmpty(this.forkContext), - qqForkContext: ForkContext.newEmpty(this.forkContext), - processed: false - }; + this.choiceContext = new ChoiceContext(this.choiceContext, kind, isForkingAsResult, this.forkContext); } /** * Pops the last choice context and finalizes it. + * This is called upon leaving a node that represents a choice. * @throws {Error} (Unreachable.) * @returns {ChoiceContext} The popped context. */ popChoiceContext() { - const context = this.choiceContext; - - this.choiceContext = context.upper; - + const poppedChoiceContext = this.choiceContext; const forkContext = this.forkContext; - const headSegments = forkContext.head; + const head = forkContext.head; + + this.choiceContext = poppedChoiceContext.upper; - switch (context.kind) { + switch (poppedChoiceContext.kind) { case "&&": case "||": case "??": /* - * If any result were not transferred from child contexts, - * this sets the head segments to both cases. - * The head segments are the path of the right-hand operand. + * The `head` are the path of the right-hand operand. + * If we haven't previously added segments from child contexts, + * then we add these segments to all possible forks. */ - if (!context.processed) { - context.trueForkContext.add(headSegments); - context.falseForkContext.add(headSegments); - context.qqForkContext.add(headSegments); + if (!poppedChoiceContext.processed) { + poppedChoiceContext.trueForkContext.add(head); + poppedChoiceContext.falseForkContext.add(head); + poppedChoiceContext.nullishForkContext.add(head); } /* - * Transfers results to upper context if this context is in - * test chunk. + * If this context is the left (test) expression for another choice + * context, such as `a || b` in the expression `a || b || c`, + * then we take the segments for this context and move them up + * to the parent context. */ - if (context.isForkingAsResult) { + if (poppedChoiceContext.isForkingAsResult) { const parentContext = this.choiceContext; - parentContext.trueForkContext.addAll(context.trueForkContext); - parentContext.falseForkContext.addAll(context.falseForkContext); - parentContext.qqForkContext.addAll(context.qqForkContext); + parentContext.trueForkContext.addAll(poppedChoiceContext.trueForkContext); + parentContext.falseForkContext.addAll(poppedChoiceContext.falseForkContext); + parentContext.nullishForkContext.addAll(poppedChoiceContext.nullishForkContext); parentContext.processed = true; - return context; + // Exit early so we don't collapse all paths into one. + return poppedChoiceContext; } break; case "test": - if (!context.processed) { + if (!poppedChoiceContext.processed) { /* * The head segments are the path of the `if` block here. * Updates the `true` path with the end of the `if` block. */ - context.trueForkContext.clear(); - context.trueForkContext.add(headSegments); + poppedChoiceContext.trueForkContext.clear(); + poppedChoiceContext.trueForkContext.add(head); } else { /* @@ -419,8 +1158,8 @@ class CodePathState { * Updates the `false` path with the end of the `else` * block. */ - context.falseForkContext.clear(); - context.falseForkContext.add(headSegments); + poppedChoiceContext.falseForkContext.clear(); + poppedChoiceContext.falseForkContext.add(head); } break; @@ -428,82 +1167,129 @@ class CodePathState { case "loop": /* - * Loops are addressed in popLoopContext(). - * This is called from popLoopContext(). + * Loops are addressed in `popLoopContext()` so just return + * the context without modification. */ - return context; + return poppedChoiceContext; /* c8 ignore next */ default: throw new Error("unreachable"); } - // Merges all paths. - const prevForkContext = context.trueForkContext; + /* + * Merge the true path with the false path to create a single path. + */ + const combinedForkContext = poppedChoiceContext.trueForkContext; - prevForkContext.addAll(context.falseForkContext); - forkContext.replaceHead(prevForkContext.makeNext(0, -1)); + combinedForkContext.addAll(poppedChoiceContext.falseForkContext); + forkContext.replaceHead(combinedForkContext.makeNext(0, -1)); - return context; + return poppedChoiceContext; } /** - * Makes a code path segment of the right-hand operand of a logical + * Creates a code path segment to represent right-hand operand of a logical * expression. + * This is called in the preprocessing phase when entering a node. * @throws {Error} (Unreachable.) * @returns {void} */ makeLogicalRight() { - const context = this.choiceContext; + const currentChoiceContext = this.choiceContext; const forkContext = this.forkContext; - if (context.processed) { + if (currentChoiceContext.processed) { /* - * This got segments already from the child choice context. - * Creates the next path from own true/false fork context. + * This context was already assigned segments from a child + * choice context. In this case, we are concerned only about + * the path that does not short-circuit and so ends up on the + * right-hand operand of the logical expression. */ let prevForkContext; - switch (context.kind) { + switch (currentChoiceContext.kind) { case "&&": // if true then go to the right-hand side. - prevForkContext = context.trueForkContext; + prevForkContext = currentChoiceContext.trueForkContext; break; case "||": // if false then go to the right-hand side. - prevForkContext = context.falseForkContext; + prevForkContext = currentChoiceContext.falseForkContext; break; - case "??": // Both true/false can short-circuit, so needs the third path to go to the right-hand side. That's qqForkContext. - prevForkContext = context.qqForkContext; + case "??": // Both true/false can short-circuit, so needs the third path to go to the right-hand side. That's nullishForkContext. + prevForkContext = currentChoiceContext.nullishForkContext; break; default: throw new Error("unreachable"); } + /* + * Create the segment for the right-hand operand of the logical expression + * and adjust the fork context pointer to point there. The right-hand segment + * is added at the end of all segments in `prevForkContext`. + */ forkContext.replaceHead(prevForkContext.makeNext(0, -1)); + + /* + * We no longer need this list of segments. + * + * Reset `processed` because we've removed the segments from the child + * choice context. This allows `popChoiceContext()` to continue adding + * segments later. + */ prevForkContext.clear(); - context.processed = false; + currentChoiceContext.processed = false; + } else { /* - * This did not get segments from the child choice context. - * So addresses the head segments. - * The head segments are the path of the left-hand operand. + * This choice context was not assigned segments from a child + * choice context, which means that it's a terminal logical + * expression. + * + * `head` is the segments for the left-hand operand of the + * logical expression. + * + * Each of the fork contexts below are empty at this point. We choose + * the path(s) that will short-circuit and add the segment for the + * left-hand operand to it. Ultimately, this will be the only segment + * in that path due to the short-circuting, so we are just seeding + * these paths to start. */ - switch (context.kind) { - case "&&": // the false path can short-circuit. - context.falseForkContext.add(forkContext.head); + switch (currentChoiceContext.kind) { + case "&&": + + /* + * In most contexts, when a && expression evaluates to false, + * it short circuits, so we need to account for that by setting + * the `falseForkContext` to the left operand. + * + * When a && expression is the left-hand operand for a ?? + * expression, such as `(a && b) ?? c`, a nullish value will + * also short-circuit in a different way than a false value, + * so we also set the `nullishForkContext` to the left operand. + * This path is only used with a ?? expression and is thrown + * away for any other type of logical expression, so it's safe + * to always add. + */ + currentChoiceContext.falseForkContext.add(forkContext.head); + currentChoiceContext.nullishForkContext.add(forkContext.head); break; case "||": // the true path can short-circuit. - context.trueForkContext.add(forkContext.head); + currentChoiceContext.trueForkContext.add(forkContext.head); break; case "??": // both can short-circuit. - context.trueForkContext.add(forkContext.head); - context.falseForkContext.add(forkContext.head); + currentChoiceContext.trueForkContext.add(forkContext.head); + currentChoiceContext.falseForkContext.add(forkContext.head); break; default: throw new Error("unreachable"); } + /* + * Create the segment for the right-hand operand of the logical expression + * and adjust the fork context pointer to point there. + */ forkContext.replaceHead(forkContext.makeNext(-1, -1)); } } @@ -524,7 +1310,7 @@ class CodePathState { if (!context.processed) { context.trueForkContext.add(forkContext.head); context.falseForkContext.add(forkContext.head); - context.qqForkContext.add(forkContext.head); + context.nullishForkContext.add(forkContext.head); } context.processed = false; @@ -562,22 +1348,20 @@ class CodePathState { //-------------------------------------------------------------------------- /** - * Push a new `ChainExpression` context to the stack. - * This method is called on entering to each `ChainExpression` node. - * This context is used to count forking in the optional chain then merge them on the exiting from the `ChainExpression` node. + * Pushes a new `ChainExpression` context to the stack. This method is + * called when entering a `ChainExpression` node. A chain context is used to + * count forking in the optional chain then merge them on the exiting from the + * `ChainExpression` node. * @returns {void} */ pushChainContext() { - this.chainContext = { - upper: this.chainContext, - countChoiceContexts: 0 - }; + this.chainContext = new ChainContext(this.chainContext); } /** - * Pop a `ChainExpression` context from the stack. - * This method is called on exiting from each `ChainExpression` node. - * This merges all forks of the last optional chaining. + * Pop a `ChainExpression` context from the stack. This method is called on + * exiting from each `ChainExpression` node. This merges all forks of the + * last optional chaining. * @returns {void} */ popChainContext() { @@ -586,7 +1370,7 @@ class CodePathState { this.chainContext = context.upper; // pop all choice contexts of this. - for (let i = context.countChoiceContexts; i > 0; --i) { + for (let i = context.choiceContextCount; i > 0; --i) { this.popChoiceContext(); } } @@ -599,7 +1383,7 @@ class CodePathState { */ makeOptionalNode() { if (this.chainContext) { - this.chainContext.countChoiceContexts += 1; + this.chainContext.choiceContextCount += 1; this.pushChoiceContext("??", false); } } @@ -627,16 +1411,7 @@ class CodePathState { * @returns {void} */ pushSwitchContext(hasCase, label) { - this.switchContext = { - upper: this.switchContext, - hasCase, - defaultSegments: null, - defaultBodySegments: null, - foundDefault: false, - lastIsDefault: false, - countForks: 0 - }; - + this.switchContext = new SwitchContext(this.switchContext, hasCase); this.pushBreakContext(true, label); } @@ -657,7 +1432,7 @@ class CodePathState { const forkContext = this.forkContext; const brokenForkContext = this.popBreakContext().brokenForkContext; - if (context.countForks === 0) { + if (context.forkCount === 0) { /* * When there is only one `default` chunk and there is one or more @@ -684,47 +1459,54 @@ class CodePathState { brokenForkContext.add(lastSegments); /* - * A path which is failed in all case test should be connected to path - * of `default` chunk. + * Any value that doesn't match a `case` test should flow to the default + * case. That happens normally when the default case is last in the `switch`, + * but if it's not, we need to rewire some of the paths to be correct. */ if (!context.lastIsDefault) { if (context.defaultBodySegments) { /* - * Remove a link from `default` label to its chunk. - * It's false route. + * There is a non-empty default case, so remove the path from the `default` + * label to its body for an accurate representation. + */ + disconnectSegments(context.defaultSegments, context.defaultBodySegments); + + /* + * Connect the path from the last non-default case to the body of the + * default case. */ - removeConnection(context.defaultSegments, context.defaultBodySegments); makeLooped(this, lastCaseSegments, context.defaultBodySegments); + } else { /* - * It handles the last case body as broken if `default` chunk - * does not exist. + * There is no default case, so we treat this as if the last case + * had a `break` in it. */ brokenForkContext.add(lastCaseSegments); } } - // Pops the segment context stack until the entry segment. - for (let i = 0; i < context.countForks; ++i) { + // Traverse up to the original fork context for the `switch` statement + for (let i = 0; i < context.forkCount; ++i) { this.forkContext = this.forkContext.upper; } /* - * Creates a path from all brokenForkContext paths. - * This is a path after switch statement. + * Creates a path from all `brokenForkContext` paths. + * This is a path after `switch` statement. */ this.forkContext.replaceHead(brokenForkContext.makeNext(0, -1)); } /** * Makes a code path segment for a `SwitchCase` node. - * @param {boolean} isEmpty `true` if the body is empty. - * @param {boolean} isDefault `true` if the body is the default case. + * @param {boolean} isCaseBodyEmpty `true` if the body is empty. + * @param {boolean} isDefaultCase `true` if the body is the default case. * @returns {void} */ - makeSwitchCaseBody(isEmpty, isDefault) { + makeSwitchCaseBody(isCaseBodyEmpty, isDefaultCase) { const context = this.switchContext; if (!context.hasCase) { @@ -734,7 +1516,7 @@ class CodePathState { /* * Merge forks. * The parent fork context has two segments. - * Those are from the current case and the body of the previous case. + * Those are from the current `case` and the body of the previous case. */ const parentForkContext = this.forkContext; const forkContext = this.pushForkContext(); @@ -742,26 +1524,53 @@ class CodePathState { forkContext.add(parentForkContext.makeNext(0, -1)); /* - * Save `default` chunk info. - * If the `default` label is not at the last, we must make a path from - * the last `case` to the `default` chunk. + * Add information about the default case. + * + * The purpose of this is to identify the starting segments for the + * default case to make sure there is a path there. */ - if (isDefault) { + if (isDefaultCase) { + + /* + * This is the default case in the `switch`. + * + * We first save the current pointer as `defaultSegments` to point + * to the `default` keyword. + */ context.defaultSegments = parentForkContext.head; - if (isEmpty) { - context.foundDefault = true; + + /* + * If the body of the case is empty then we just set + * `foundEmptyDefault` to true; otherwise, we save a reference + * to the current pointer as `defaultBodySegments`. + */ + if (isCaseBodyEmpty) { + context.foundEmptyDefault = true; } else { context.defaultBodySegments = forkContext.head; } + } else { - if (!isEmpty && context.foundDefault) { - context.foundDefault = false; + + /* + * This is not the default case in the `switch`. + * + * If it's not empty and there is already an empty default case found, + * that means the default case actually comes before this case, + * and that it will fall through to this case. So, we can now + * ignore the previous default case (reset `foundEmptyDefault` to false) + * and set `defaultBodySegments` to the current segments because this is + * effectively the new default case. + */ + if (!isCaseBodyEmpty && context.foundEmptyDefault) { + context.foundEmptyDefault = false; context.defaultBodySegments = forkContext.head; } } - context.lastIsDefault = isDefault; - context.countForks += 1; + // keep track if the default case ends up last + context.lastIsDefault = isDefaultCase; + context.forkCount += 1; } //-------------------------------------------------------------------------- @@ -775,19 +1584,7 @@ class CodePathState { * @returns {void} */ pushTryContext(hasFinalizer) { - this.tryContext = { - upper: this.tryContext, - position: "try", - hasFinalizer, - - returnedForkContext: hasFinalizer - ? ForkContext.newEmpty(this.forkContext) - : null, - - thrownForkContext: ForkContext.newEmpty(this.forkContext), - lastOfTryIsReachable: false, - lastOfCatchIsReachable: false - }; + this.tryContext = new TryContext(this.tryContext, hasFinalizer, this.forkContext); } /** @@ -799,25 +1596,35 @@ class CodePathState { this.tryContext = context.upper; + /* + * If we're inside the `catch` block, that means there is no `finally`, + * so we can process the `try` and `catch` blocks the simple way and + * merge their two paths. + */ if (context.position === "catch") { - - // Merges two paths from the `try` block and `catch` block merely. this.popForkContext(); return; } /* - * The following process is executed only when there is the `finally` + * The following process is executed only when there is a `finally` * block. */ - const returned = context.returnedForkContext; - const thrown = context.thrownForkContext; + const originalReturnedForkContext = context.returnedForkContext; + const originalThrownForkContext = context.thrownForkContext; - if (returned.empty && thrown.empty) { + // no `return` or `throw` in `try` or `catch` so there's nothing left to do + if (originalReturnedForkContext.empty && originalThrownForkContext.empty) { return; } + /* + * The following process is executed only when there is a `finally` + * block and there was a `return` or `throw` in the `try` or `catch` + * blocks. + */ + // Separate head to normal paths and leaving paths. const headSegments = this.forkContext.head; @@ -826,10 +1633,10 @@ class CodePathState { const leavingSegments = headSegments.slice(headSegments.length / 2 | 0); // Forwards the leaving path to upper contexts. - if (!returned.empty) { + if (!originalReturnedForkContext.empty) { getReturnContext(this).returnedForkContext.add(leavingSegments); } - if (!thrown.empty) { + if (!originalThrownForkContext.empty) { getThrowContext(this).thrownForkContext.add(leavingSegments); } @@ -852,16 +1659,20 @@ class CodePathState { makeCatchBlock() { const context = this.tryContext; const forkContext = this.forkContext; - const thrown = context.thrownForkContext; + const originalThrownForkContext = context.thrownForkContext; - // Update state. + /* + * We are now in a catch block so we need to update the context + * with that information. This includes creating a new fork + * context in case we encounter any `throw` statements here. + */ context.position = "catch"; context.thrownForkContext = ForkContext.newEmpty(forkContext); context.lastOfTryIsReachable = forkContext.reachable; - // Merge thrown paths. - thrown.add(forkContext.head); - const thrownSegments = thrown.makeNext(0, -1); + // Merge the thrown paths from the `try` and `catch` blocks + originalThrownForkContext.add(forkContext.head); + const thrownSegments = originalThrownForkContext.makeNext(0, -1); // Fork to a bypass and the merged thrown path. this.pushForkContext(); @@ -880,8 +1691,8 @@ class CodePathState { makeFinallyBlock() { const context = this.tryContext; let forkContext = this.forkContext; - const returned = context.returnedForkContext; - const thrown = context.thrownForkContext; + const originalReturnedForkContext = context.returnedForkContext; + const originalThrownForContext = context.thrownForkContext; const headOfLeavingSegments = forkContext.head; // Update state. @@ -895,9 +1706,15 @@ class CodePathState { } else { context.lastOfTryIsReachable = forkContext.reachable; } + + context.position = "finally"; - if (returned.empty && thrown.empty) { + /* + * If there was no `return` or `throw` in either the `try` or `catch` + * blocks, then there's no further code paths to create for `finally`. + */ + if (originalReturnedForkContext.empty && originalThrownForContext.empty) { // This path does not leave. return; @@ -905,18 +1722,18 @@ class CodePathState { /* * Create a parallel segment from merging returned and thrown. - * This segment will leave at the end of this finally block. + * This segment will leave at the end of this `finally` block. */ const segments = forkContext.makeNext(-1, -1); for (let i = 0; i < forkContext.count; ++i) { const prevSegsOfLeavingSegment = [headOfLeavingSegments[i]]; - for (let j = 0; j < returned.segmentsList.length; ++j) { - prevSegsOfLeavingSegment.push(returned.segmentsList[j][i]); + for (let j = 0; j < originalReturnedForkContext.segmentsList.length; ++j) { + prevSegsOfLeavingSegment.push(originalReturnedForkContext.segmentsList[j][i]); } - for (let j = 0; j < thrown.segmentsList.length; ++j) { - prevSegsOfLeavingSegment.push(thrown.segmentsList[j][i]); + for (let j = 0; j < originalThrownForContext.segmentsList.length; ++j) { + prevSegsOfLeavingSegment.push(originalThrownForContext.segmentsList[j][i]); } segments.push( @@ -971,63 +1788,32 @@ class CodePathState { */ pushLoopContext(type, label) { const forkContext = this.forkContext; + + // All loops need a path to account for `break` statements const breakContext = this.pushBreakContext(true, label); switch (type) { case "WhileStatement": this.pushChoiceContext("loop", false); - this.loopContext = { - upper: this.loopContext, - type, - label, - test: void 0, - continueDestSegments: null, - brokenForkContext: breakContext.brokenForkContext - }; + this.loopContext = new WhileLoopContext(this.loopContext, label, breakContext); break; case "DoWhileStatement": this.pushChoiceContext("loop", false); - this.loopContext = { - upper: this.loopContext, - type, - label, - test: void 0, - entrySegments: null, - continueForkContext: ForkContext.newEmpty(forkContext), - brokenForkContext: breakContext.brokenForkContext - }; + this.loopContext = new DoWhileLoopContext(this.loopContext, label, breakContext, forkContext); break; case "ForStatement": this.pushChoiceContext("loop", false); - this.loopContext = { - upper: this.loopContext, - type, - label, - test: void 0, - endOfInitSegments: null, - testSegments: null, - endOfTestSegments: null, - updateSegments: null, - endOfUpdateSegments: null, - continueDestSegments: null, - brokenForkContext: breakContext.brokenForkContext - }; + this.loopContext = new ForLoopContext(this.loopContext, label, breakContext); break; case "ForInStatement": + this.loopContext = new ForInLoopContext(this.loopContext, label, breakContext); + break; + case "ForOfStatement": - this.loopContext = { - upper: this.loopContext, - type, - label, - prevSegments: null, - leftSegments: null, - endOfLeftSegments: null, - continueDestSegments: null, - brokenForkContext: breakContext.brokenForkContext - }; + this.loopContext = new ForOfLoopContext(this.loopContext, label, breakContext); break; /* c8 ignore next */ @@ -1054,6 +1840,11 @@ class CodePathState { case "WhileStatement": case "ForStatement": this.popChoiceContext(); + + /* + * Creates the path from the end of the loop body up to the + * location where `continue` would jump to. + */ makeLooped( this, forkContext.head, @@ -1068,11 +1859,21 @@ class CodePathState { choiceContext.trueForkContext.add(forkContext.head); choiceContext.falseForkContext.add(forkContext.head); } + + /* + * If this isn't a hardcoded `true` condition, then `break` + * should continue down the path as if the condition evaluated + * to false. + */ if (context.test !== true) { brokenForkContext.addAll(choiceContext.falseForkContext); } - // `true` paths go to looping. + /* + * When the condition is true, the loop continues back to the top, + * so create a path from each possible true condition back to the + * top of the loop. + */ const segmentsList = choiceContext.trueForkContext.segmentsList; for (let i = 0; i < segmentsList.length; ++i) { @@ -1088,6 +1889,11 @@ class CodePathState { case "ForInStatement": case "ForOfStatement": brokenForkContext.add(forkContext.head); + + /* + * Creates the path from the end of the loop body up to the + * left expression (left of `in` or `of`) of the loop. + */ makeLooped( this, forkContext.head, @@ -1100,7 +1906,14 @@ class CodePathState { throw new Error("unreachable"); } - // Go next. + /* + * If there wasn't a `break` statement in the loop, then we're at + * the end of the loop's path, so we make an unreachable segment + * to mark that. + * + * If there was a `break` statement, then we continue on into the + * `brokenForkContext`. + */ if (brokenForkContext.empty) { forkContext.replaceHead(forkContext.makeUnreachable(-1, -1)); } else { @@ -1138,7 +1951,11 @@ class CodePathState { choiceContext.falseForkContext.add(forkContext.head); } - // Update state. + /* + * If this isn't a hardcoded `true` condition, then `break` + * should continue down the path as if the condition evaluated + * to false. + */ if (context.test !== true) { context.brokenForkContext.addAll(choiceContext.falseForkContext); } @@ -1170,7 +1987,11 @@ class CodePathState { context.test = test; - // Creates paths of `continue` statements. + /* + * If there is a `continue` statement in the loop then `continueForkContext` + * won't be empty. We wire up the path from `continue` to the loop + * test condition and then continue the traversal in the root fork context. + */ if (!context.continueForkContext.empty) { context.continueForkContext.add(forkContext.head); const testSegments = context.continueForkContext.makeNext(0, -1); @@ -1190,7 +2011,14 @@ class CodePathState { const endOfInitSegments = forkContext.head; const testSegments = forkContext.makeNext(-1, -1); - // Update state. + /* + * Update the state. + * + * The `continueDestSegments` are set to `testSegments` because we + * don't yet know if there is an update expression in this loop. So, + * from what we already know at this point, a `continue` statement + * will jump back to the test expression. + */ context.test = test; context.endOfInitSegments = endOfInitSegments; context.continueDestSegments = context.testSegments = testSegments; @@ -1217,7 +2045,14 @@ class CodePathState { context.endOfInitSegments = forkContext.head; } - // Update state. + /* + * Update the state. + * + * The `continueDestSegments` are now set to `updateSegments` because we + * know there is an update expression in this loop. So, a `continue` statement + * in the loop will jump to the update expression first, and then to any + * test expression the loop might have. + */ const updateSegments = forkContext.makeDisconnected(-1, -1); context.continueDestSegments = context.updateSegments = updateSegments; @@ -1233,11 +2068,30 @@ class CodePathState { const choiceContext = this.choiceContext; const forkContext = this.forkContext; - // Update state. + /* + * Determine what to do based on which part of the `for` loop are present. + * 1. If there is an update expression, then `updateSegments` is not null and + * we need to assign `endOfUpdateSegments`, and if there is a test + * expression, we then need to create the looped path to get back to + * the test condition. + * 2. If there is no update expression but there is a test expression, + * then we only need to update the test segment information. + * 3. If there is no update expression and no test expression, then we + * just save `endOfInitSegments`. + */ if (context.updateSegments) { context.endOfUpdateSegments = forkContext.head; - // `update` -> `test` + /* + * In a `for` loop that has both an update expression and a test + * condition, execution flows from the test expression into the + * loop body, to the update expression, and then back to the test + * expression to determine if the loop should continue. + * + * To account for that, we need to make a path from the end of the + * update expression to the start of the test expression. This is + * effectively what creates the loop in the code path. + */ if (context.testSegments) { makeLooped( this, @@ -1257,12 +2111,18 @@ class CodePathState { let bodySegments = context.endOfTestSegments; + /* + * If there is a test condition, then there `endOfTestSegments` is also + * the start of the loop body. If there isn't a test condition then + * `bodySegments` will be null and we need to look elsewhere to find + * the start of the body. + * + * The body starts at the end of the init expression and ends at the end + * of the update expression, so we use those locations to determine the + * body segments. + */ if (!bodySegments) { - /* - * If there is not the `test` part, the `body` path comes from the - * `init` part and the `update` part. - */ const prevForkContext = ForkContext.newEmpty(forkContext); prevForkContext.add(context.endOfInitSegments); @@ -1272,7 +2132,16 @@ class CodePathState { bodySegments = prevForkContext.makeNext(0, -1); } + + /* + * If there was no test condition and no update expression, then + * `continueDestSegments` will be null. In that case, a + * `continue` should skip directly to the body of the loop. + * Otherwise, we want to keep the current `continueDestSegments`. + */ context.continueDestSegments = context.continueDestSegments || bodySegments; + + // move pointer to the body forkContext.replaceHead(bodySegments); } @@ -1336,19 +2205,15 @@ class CodePathState { //-------------------------------------------------------------------------- /** - * Creates new context for BreakStatement. - * @param {boolean} breakable The flag to indicate it can break by - * an unlabeled BreakStatement. - * @param {string|null} label The label of this context. - * @returns {Object} The new context. + * Creates new context in which a `break` statement can be used. This occurs inside of a loop, + * labeled statement, or switch statement. + * @param {boolean} breakable Indicates if we are inside a statement where + * `break` without a label will exit the statement. + * @param {string|null} label The label associated with the statement. + * @returns {BreakContext} The new context. */ pushBreakContext(breakable, label) { - this.breakContext = { - upper: this.breakContext, - breakable, - label, - brokenForkContext: ForkContext.newEmpty(this.forkContext) - }; + this.breakContext = new BreakContext(this.breakContext, breakable, label, this.forkContext); return this.breakContext; } @@ -1380,7 +2245,7 @@ class CodePathState { * * It registers the head segment to a context of `break`. * It makes new unreachable segment, then it set the head with the segment. - * @param {string} label A label of the break statement. + * @param {string|null} label A label of the break statement. * @returns {void} */ makeBreak(label) { @@ -1406,7 +2271,7 @@ class CodePathState { * * It makes a looping path. * It makes new unreachable segment, then it set the head with the segment. - * @param {string} label A label of the continue statement. + * @param {string|null} label A label of the continue statement. * @returns {void} */ makeContinue(label) { @@ -1422,7 +2287,7 @@ class CodePathState { if (context.continueDestSegments) { makeLooped(this, forkContext.head, context.continueDestSegments); - // If the context is a for-in/of loop, this effects a break also. + // If the context is a for-in/of loop, this affects a break also. if (context.type === "ForInStatement" || context.type === "ForOfStatement" ) { diff --git a/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path.js b/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path.js index a028ca69481c09..3bf570d754bfa9 100644 --- a/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path.js +++ b/tools/node_modules/eslint/lib/linter/code-path-analysis/code-path.js @@ -80,7 +80,9 @@ class CodePath { } /** - * The initial code path segment. + * The initial code path segment. This is the segment that is at the head + * of the code path. + * This is a passthrough to the underlying `CodePathState`. * @type {CodePathSegment} */ get initialSegment() { @@ -88,8 +90,10 @@ class CodePath { } /** - * Final code path segments. - * This array is a mix of `returnedSegments` and `thrownSegments`. + * Final code path segments. These are the terminal (tail) segments in the + * code path, which is the combination of `returnedSegments` and `thrownSegments`. + * All segments in this array are reachable. + * This is a passthrough to the underlying `CodePathState`. * @type {CodePathSegment[]} */ get finalSegments() { @@ -97,9 +101,14 @@ class CodePath { } /** - * Final code path segments which is with `return` statements. - * This array contains the last path segment if it's reachable. - * Since the reachable last path returns `undefined`. + * Final code path segments that represent normal completion of the code path. + * For functions, this means both explicit `return` statements and implicit returns, + * such as the last reachable segment in a function that does not have an + * explicit `return` as this implicitly returns `undefined`. For scripts, + * modules, class field initializers, and class static blocks, this means + * all lines of code have been executed. + * These segments are also present in `finalSegments`. + * This is a passthrough to the underlying `CodePathState`. * @type {CodePathSegment[]} */ get returnedSegments() { @@ -107,7 +116,9 @@ class CodePath { } /** - * Final code path segments which is with `throw` statements. + * Final code path segments that represent `throw` statements. + * This is a passthrough to the underlying `CodePathState`. + * These segments are also present in `finalSegments`. * @type {CodePathSegment[]} */ get thrownSegments() { @@ -115,8 +126,14 @@ class CodePath { } /** - * Current code path segments. + * Tracks the traversal of the code path through each segment. This array + * starts empty and segments are added or removed as the code path is + * traversed. This array always ends up empty at the end of a code path + * traversal. The `CodePathState` uses this to track its progress through + * the code path. + * This is a passthrough to the underlying `CodePathState`. * @type {CodePathSegment[]} + * @deprecated */ get currentSegments() { return this.internal.currentSegments; @@ -125,46 +142,70 @@ class CodePath { /** * Traverses all segments in this code path. * - * codePath.traverseSegments(function(segment, controller) { + * codePath.traverseSegments((segment, controller) => { * // do something. * }); * * This method enumerates segments in order from the head. * - * The `controller` object has two methods. + * The `controller` argument has two methods: * - * - `controller.skip()` - Skip the following segments in this branch. - * - `controller.break()` - Skip all following segments. - * @param {Object} [options] Omittable. - * @param {CodePathSegment} [options.first] The first segment to traverse. - * @param {CodePathSegment} [options.last] The last segment to traverse. + * - `skip()` - skips the following segments in this branch + * - `break()` - skips all following segments in the traversal + * + * A note on the parameters: the `options` argument is optional. This means + * the first argument might be an options object or the callback function. + * @param {Object} [optionsOrCallback] Optional first and last segments to traverse. + * @param {CodePathSegment} [optionsOrCallback.first] The first segment to traverse. + * @param {CodePathSegment} [optionsOrCallback.last] The last segment to traverse. * @param {Function} callback A callback function. * @returns {void} */ - traverseSegments(options, callback) { + traverseSegments(optionsOrCallback, callback) { + + // normalize the arguments into a callback and options let resolvedOptions; let resolvedCallback; - if (typeof options === "function") { - resolvedCallback = options; + if (typeof optionsOrCallback === "function") { + resolvedCallback = optionsOrCallback; resolvedOptions = {}; } else { - resolvedOptions = options || {}; + resolvedOptions = optionsOrCallback || {}; resolvedCallback = callback; } + // determine where to start traversing from based on the options const startSegment = resolvedOptions.first || this.internal.initialSegment; const lastSegment = resolvedOptions.last; - let item = null; + // set up initial location information + let record = null; let index = 0; let end = 0; let segment = null; - const visited = Object.create(null); + + // segments that have already been visited during traversal + const visited = new Set(); + + // tracks the traversal steps const stack = [[startSegment, 0]]; + + // tracks the last skipped segment during traversal let skippedSegment = null; + + // indicates if we exited early from the traversal let broken = false; + + /** + * Maintains traversal state. + */ const controller = { + + /** + * Skip the following segments in this branch. + * @returns {void} + */ skip() { if (stack.length <= 1) { broken = true; @@ -172,32 +213,52 @@ class CodePath { skippedSegment = stack[stack.length - 2][0]; } }, + + /** + * Stop traversal completely - do not traverse to any + * other segments. + * @returns {void} + */ break() { broken = true; } }; /** - * Checks a given previous segment has been visited. + * Checks if a given previous segment has been visited. * @param {CodePathSegment} prevSegment A previous segment to check. * @returns {boolean} `true` if the segment has been visited. */ function isVisited(prevSegment) { return ( - visited[prevSegment.id] || + visited.has(prevSegment) || segment.isLoopedPrevSegment(prevSegment) ); } + // the traversal while (stack.length > 0) { - item = stack[stack.length - 1]; - segment = item[0]; - index = item[1]; + + /* + * This isn't a pure stack. We use the top record all the time + * but don't always pop it off. The record is popped only if + * one of the following is true: + * + * 1) We have already visited the segment. + * 2) We have not visited *all* of the previous segments. + * 3) We have traversed past the available next segments. + * + * Otherwise, we just read the value and sometimes modify the + * record as we traverse. + */ + record = stack[stack.length - 1]; + segment = record[0]; + index = record[1]; if (index === 0) { // Skip if this segment has been visited already. - if (visited[segment.id]) { + if (visited.has(segment)) { stack.pop(); continue; } @@ -211,18 +272,29 @@ class CodePath { continue; } - // Reset the flag of skipping if all branches have been skipped. + // Reset the skipping flag if all branches have been skipped. if (skippedSegment && segment.prevSegments.includes(skippedSegment)) { skippedSegment = null; } - visited[segment.id] = true; + visited.add(segment); - // Call the callback when the first time. + /* + * If the most recent segment hasn't been skipped, then we call + * the callback, passing in the segment and the controller. + */ if (!skippedSegment) { resolvedCallback.call(this, segment, controller); + + // exit if we're at the last segment if (segment === lastSegment) { controller.skip(); } + + /* + * If the previous statement was executed, or if the callback + * called a method on the controller, we might need to exit the + * loop, so check for that and break accordingly. + */ if (broken) { break; } @@ -232,12 +304,35 @@ class CodePath { // Update the stack. end = segment.nextSegments.length - 1; if (index < end) { - item[1] += 1; + + /* + * If we haven't yet visited all of the next segments, update + * the current top record on the stack to the next index to visit + * and then push a record for the current segment on top. + * + * Setting the current top record's index lets us know how many + * times we've been here and ensures that the segment won't be + * reprocessed (because we only process segments with an index + * of 0). + */ + record[1] += 1; stack.push([segment.nextSegments[index], 0]); } else if (index === end) { - item[0] = segment.nextSegments[index]; - item[1] = 0; + + /* + * If we are at the last next segment, then reset the top record + * in the stack to next segment and set its index to 0 so it will + * be processed next. + */ + record[0] = segment.nextSegments[index]; + record[1] = 0; } else { + + /* + * If index > end, that means we have no more segments that need + * processing. So, we pop that record off of the stack in order to + * continue traversing at the next level up. + */ stack.pop(); } } diff --git a/tools/node_modules/eslint/lib/linter/code-path-analysis/fork-context.js b/tools/node_modules/eslint/lib/linter/code-path-analysis/fork-context.js index 04c59b5e417253..33140272f53b42 100644 --- a/tools/node_modules/eslint/lib/linter/code-path-analysis/fork-context.js +++ b/tools/node_modules/eslint/lib/linter/code-path-analysis/fork-context.js @@ -21,8 +21,8 @@ const assert = require("assert"), //------------------------------------------------------------------------------ /** - * Gets whether or not a given segment is reachable. - * @param {CodePathSegment} segment A segment to get. + * Determines whether or not a given segment is reachable. + * @param {CodePathSegment} segment The segment to check. * @returns {boolean} `true` if the segment is reachable. */ function isReachable(segment) { @@ -30,32 +30,64 @@ function isReachable(segment) { } /** - * Creates new segments from the specific range of `context.segmentsList`. + * Creates a new segment for each fork in the given context and appends it + * to the end of the specified range of segments. Ultimately, this ends up calling + * `new CodePathSegment()` for each of the forks using the `create` argument + * as a wrapper around special behavior. + * + * The `startIndex` and `endIndex` arguments specify a range of segments in + * `context` that should become `allPrevSegments` for the newly created + * `CodePathSegment` objects. * * When `context.segmentsList` is `[[a, b], [c, d], [e, f]]`, `begin` is `0`, and - * `end` is `-1`, this creates `[g, h]`. This `g` is from `a`, `c`, and `e`. - * This `h` is from `b`, `d`, and `f`. - * @param {ForkContext} context An instance. - * @param {number} begin The first index of the previous segments. - * @param {number} end The last index of the previous segments. - * @param {Function} create A factory function of new segments. - * @returns {CodePathSegment[]} New segments. + * `end` is `-1`, this creates two new segments, `[g, h]`. This `g` is appended to + * the end of the path from `a`, `c`, and `e`. This `h` is appended to the end of + * `b`, `d`, and `f`. + * @param {ForkContext} context An instance from which the previous segments + * will be obtained. + * @param {number} startIndex The index of the first segment in the context + * that should be specified as previous segments for the newly created segments. + * @param {number} endIndex The index of the last segment in the context + * that should be specified as previous segments for the newly created segments. + * @param {Function} create A function that creates new `CodePathSegment` + * instances in a particular way. See the `CodePathSegment.new*` methods. + * @returns {Array} An array of the newly-created segments. */ -function makeSegments(context, begin, end, create) { +function createSegments(context, startIndex, endIndex, create) { + + /** @type {Array>} */ const list = context.segmentsList; - const normalizedBegin = begin >= 0 ? begin : list.length + begin; - const normalizedEnd = end >= 0 ? end : list.length + end; + /* + * Both `startIndex` and `endIndex` work the same way: if the number is zero + * or more, then the number is used as-is. If the number is negative, + * then that number is added to the length of the segments list to + * determine the index to use. That means -1 for either argument + * is the last element, -2 is the second to last, and so on. + * + * So if `startIndex` is 0, `endIndex` is -1, and `list.length` is 3, the + * effective `startIndex` is 0 and the effective `endIndex` is 2, so this function + * will include items at indices 0, 1, and 2. + * + * Therefore, if `startIndex` is -1 and `endIndex` is -1, that means we'll only + * be using the last segment in `list`. + */ + const normalizedBegin = startIndex >= 0 ? startIndex : list.length + startIndex; + const normalizedEnd = endIndex >= 0 ? endIndex : list.length + endIndex; + /** @type {Array} */ const segments = []; for (let i = 0; i < context.count; ++i) { + + // this is passed into `new CodePathSegment` to add to code path. const allPrevSegments = []; for (let j = normalizedBegin; j <= normalizedEnd; ++j) { allPrevSegments.push(list[j][i]); } + // note: `create` is just a wrapper that augments `new CodePathSegment`. segments.push(create(context.idGenerator.next(), allPrevSegments)); } @@ -63,28 +95,57 @@ function makeSegments(context, begin, end, create) { } /** - * `segments` becomes doubly in a `finally` block. Then if a code path exits by a - * control statement (such as `break`, `continue`) from the `finally` block, the - * destination's segments may be half of the source segments. In that case, this - * merges segments. - * @param {ForkContext} context An instance. - * @param {CodePathSegment[]} segments Segments to merge. - * @returns {CodePathSegment[]} The merged segments. + * Inside of a `finally` block we end up with two parallel paths. If the code path + * exits by a control statement (such as `break` or `continue`) from the `finally` + * block, then we need to merge the remaining parallel paths back into one. + * @param {ForkContext} context The fork context to work on. + * @param {Array} segments Segments to merge. + * @returns {Array} The merged segments. */ function mergeExtraSegments(context, segments) { let currentSegments = segments; + /* + * We need to ensure that the array returned from this function contains no more + * than the number of segments that the context allows. `context.count` indicates + * how many items should be in the returned array to ensure that the new segment + * entries will line up with the already existing segment entries. + */ while (currentSegments.length > context.count) { const merged = []; - for (let i = 0, length = currentSegments.length / 2 | 0; i < length; ++i) { + /* + * Because `context.count` is a factor of 2 inside of a `finally` block, + * we can divide the segment count by 2 to merge the paths together. + * This loops through each segment in the list and creates a new `CodePathSegment` + * that has the segment and the segment two slots away as previous segments. + * + * If `currentSegments` is [a,b,c,d], this will create new segments e and f, such + * that: + * + * When `i` is 0: + * a->e + * c->e + * + * When `i` is 1: + * b->f + * d->f + */ + for (let i = 0, length = Math.floor(currentSegments.length / 2); i < length; ++i) { merged.push(CodePathSegment.newNext( context.idGenerator.next(), [currentSegments[i], currentSegments[i + length]] )); } + + /* + * Go through the loop condition one more time to see if we have the + * number of segments for the context. If not, we'll keep merging paths + * of the merged segments until we get there. + */ currentSegments = merged; } + return currentSegments; } @@ -93,25 +154,55 @@ function mergeExtraSegments(context, segments) { //------------------------------------------------------------------------------ /** - * A class to manage forking. + * Manages the forking of code paths. */ class ForkContext { /** + * Creates a new instance. * @param {IdGenerator} idGenerator An identifier generator for segments. - * @param {ForkContext|null} upper An upper fork context. - * @param {number} count A number of parallel segments. + * @param {ForkContext|null} upper The preceding fork context. + * @param {number} count The number of parallel segments in each element + * of `segmentsList`. */ constructor(idGenerator, upper, count) { + + /** + * The ID generator that will generate segment IDs for any new + * segments that are created. + * @type {IdGenerator} + */ this.idGenerator = idGenerator; + + /** + * The preceding fork context. + * @type {ForkContext|null} + */ this.upper = upper; + + /** + * The number of elements in each element of `segmentsList`. In most + * cases, this is 1 but can be 2 when there is a `finally` present, + * which forks the code path outside of normal flow. In the case of nested + * `finally` blocks, this can be a multiple of 2. + * @type {number} + */ this.count = count; + + /** + * The segments within this context. Each element in this array has + * `count` elements that represent one step in each fork. For example, + * when `segmentsList` is `[[a, b], [c, d], [e, f]]`, there is one path + * a->c->e and one path b->d->f, and `count` is 2 because each element + * is an array with two elements. + * @type {Array>} + */ this.segmentsList = []; } /** - * The head segments. - * @type {CodePathSegment[]} + * The segments that begin this fork context. + * @type {Array} */ get head() { const list = this.segmentsList; @@ -120,7 +211,7 @@ class ForkContext { } /** - * A flag which shows empty. + * Indicates if the context contains no segments. * @type {boolean} */ get empty() { @@ -128,7 +219,7 @@ class ForkContext { } /** - * A flag which shows reachable. + * Indicates if there are any segments that are reachable. * @type {boolean} */ get reachable() { @@ -138,75 +229,82 @@ class ForkContext { } /** - * Creates new segments from this context. - * @param {number} begin The first index of previous segments. - * @param {number} end The last index of previous segments. - * @returns {CodePathSegment[]} New segments. + * Creates new segments in this context and appends them to the end of the + * already existing `CodePathSegment`s specified by `startIndex` and + * `endIndex`. + * @param {number} startIndex The index of the first segment in the context + * that should be specified as previous segments for the newly created segments. + * @param {number} endIndex The index of the last segment in the context + * that should be specified as previous segments for the newly created segments. + * @returns {Array} An array of the newly created segments. */ - makeNext(begin, end) { - return makeSegments(this, begin, end, CodePathSegment.newNext); + makeNext(startIndex, endIndex) { + return createSegments(this, startIndex, endIndex, CodePathSegment.newNext); } /** - * Creates new segments from this context. - * The new segments is always unreachable. - * @param {number} begin The first index of previous segments. - * @param {number} end The last index of previous segments. - * @returns {CodePathSegment[]} New segments. + * Creates new unreachable segments in this context and appends them to the end of the + * already existing `CodePathSegment`s specified by `startIndex` and + * `endIndex`. + * @param {number} startIndex The index of the first segment in the context + * that should be specified as previous segments for the newly created segments. + * @param {number} endIndex The index of the last segment in the context + * that should be specified as previous segments for the newly created segments. + * @returns {Array} An array of the newly created segments. */ - makeUnreachable(begin, end) { - return makeSegments(this, begin, end, CodePathSegment.newUnreachable); + makeUnreachable(startIndex, endIndex) { + return createSegments(this, startIndex, endIndex, CodePathSegment.newUnreachable); } /** - * Creates new segments from this context. - * The new segments don't have connections for previous segments. - * But these inherit the reachable flag from this context. - * @param {number} begin The first index of previous segments. - * @param {number} end The last index of previous segments. - * @returns {CodePathSegment[]} New segments. + * Creates new segments in this context and does not append them to the end + * of the already existing `CodePathSegment`s specified by `startIndex` and + * `endIndex`. The `startIndex` and `endIndex` are only used to determine if + * the new segments should be reachable. If any of the segments in this range + * are reachable then the new segments are also reachable; otherwise, the new + * segments are unreachable. + * @param {number} startIndex The index of the first segment in the context + * that should be considered for reachability. + * @param {number} endIndex The index of the last segment in the context + * that should be considered for reachability. + * @returns {Array} An array of the newly created segments. */ - makeDisconnected(begin, end) { - return makeSegments(this, begin, end, CodePathSegment.newDisconnected); + makeDisconnected(startIndex, endIndex) { + return createSegments(this, startIndex, endIndex, CodePathSegment.newDisconnected); } /** - * Adds segments into this context. - * The added segments become the head. - * @param {CodePathSegment[]} segments Segments to add. + * Adds segments to the head of this context. + * @param {Array} segments The segments to add. * @returns {void} */ add(segments) { assert(segments.length >= this.count, `${segments.length} >= ${this.count}`); - this.segmentsList.push(mergeExtraSegments(this, segments)); } /** - * Replaces the head segments with given segments. + * Replaces the head segments with the given segments. * The current head segments are removed. - * @param {CodePathSegment[]} segments Segments to add. + * @param {Array} replacementHeadSegments The new head segments. * @returns {void} */ - replaceHead(segments) { - assert(segments.length >= this.count, `${segments.length} >= ${this.count}`); - - this.segmentsList.splice(-1, 1, mergeExtraSegments(this, segments)); + replaceHead(replacementHeadSegments) { + assert( + replacementHeadSegments.length >= this.count, + `${replacementHeadSegments.length} >= ${this.count}` + ); + this.segmentsList.splice(-1, 1, mergeExtraSegments(this, replacementHeadSegments)); } /** * Adds all segments of a given fork context into this context. - * @param {ForkContext} context A fork context to add. + * @param {ForkContext} otherForkContext The fork context to add from. * @returns {void} */ - addAll(context) { - assert(context.count === this.count); - - const source = context.segmentsList; - - for (let i = 0; i < source.length; ++i) { - this.segmentsList.push(source[i]); - } + addAll(otherForkContext) { + assert(otherForkContext.count === this.count); + this.segmentsList.push(...otherForkContext.segmentsList); } /** @@ -218,7 +316,8 @@ class ForkContext { } /** - * Creates the root fork context. + * Creates a new root context, meaning that there are no parent + * fork contexts. * @param {IdGenerator} idGenerator An identifier generator for segments. * @returns {ForkContext} New fork context. */ @@ -233,14 +332,16 @@ class ForkContext { /** * Creates an empty fork context preceded by a given context. * @param {ForkContext} parentContext The parent fork context. - * @param {boolean} forkLeavingPath A flag which shows inside of `finally` block. + * @param {boolean} shouldForkLeavingPath Indicates that we are inside of + * a `finally` block and should therefore fork the path that leaves + * `finally`. * @returns {ForkContext} New fork context. */ - static newEmpty(parentContext, forkLeavingPath) { + static newEmpty(parentContext, shouldForkLeavingPath) { return new ForkContext( parentContext.idGenerator, parentContext, - (forkLeavingPath ? 2 : 1) * parentContext.count + (shouldForkLeavingPath ? 2 : 1) * parentContext.count ); } } diff --git a/tools/node_modules/eslint/lib/linter/config-comment-parser.js b/tools/node_modules/eslint/lib/linter/config-comment-parser.js index 9aab3c44458546..cde261204f5ed9 100644 --- a/tools/node_modules/eslint/lib/linter/config-comment-parser.js +++ b/tools/node_modules/eslint/lib/linter/config-comment-parser.js @@ -139,7 +139,7 @@ module.exports = class ConfigCommentParser { const items = {}; string.split(",").forEach(name => { - const trimmedName = name.trim(); + const trimmedName = name.trim().replace(/^(?['"]?)(?.*)\k$/us, "$"); if (trimmedName) { items[trimmedName] = true; diff --git a/tools/node_modules/eslint/lib/linter/linter.js b/tools/node_modules/eslint/lib/linter/linter.js index 233cbed5b5ccdf..e195812e513a21 100644 --- a/tools/node_modules/eslint/lib/linter/linter.js +++ b/tools/node_modules/eslint/lib/linter/linter.js @@ -42,7 +42,8 @@ const ruleReplacements = require("../../conf/replacements.json"); const { getRuleFromConfig } = require("../config/flat-config-helpers"); const { FlatConfigArray } = require("../config/flat-config-array"); - +const { RuleValidator } = require("../config/rule-validator"); +const { assertIsRuleOptions, assertIsRuleSeverity } = require("../config/flat-config-schema"); const debug = require("debug")("eslint:linter"); const MAX_AUTOFIX_PASSES = 10; const DEFAULT_PARSER_NAME = "espree"; @@ -50,7 +51,6 @@ const DEFAULT_ECMA_VERSION = 5; const commentParser = new ConfigCommentParser(); const DEFAULT_ERROR_LOC = { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } }; const parserSymbol = Symbol.for("eslint.RuleTester.parser"); -const globals = require("../../conf/globals"); //------------------------------------------------------------------------------ // Typedefs @@ -145,29 +145,6 @@ function isEspree(parser) { return !!(parser === espree || parser[parserSymbol] === espree); } -/** - * Retrieves globals for the given ecmaVersion. - * @param {number} ecmaVersion The version to retrieve globals for. - * @returns {Object} The globals for the given ecmaVersion. - */ -function getGlobalsForEcmaVersion(ecmaVersion) { - - switch (ecmaVersion) { - case 3: - return globals.es3; - - case 5: - return globals.es5; - - default: - if (ecmaVersion < 2015) { - return globals[`es${ecmaVersion + 2009}`]; - } - - return globals[`es${ecmaVersion}`]; - } -} - /** * Ensures that variables representing built-in properties of the Global Object, * and any globals declared by special block comments, are present in the global @@ -361,13 +338,13 @@ function extractDirectiveComment(value) { * Parses comments in file to extract file-specific config of rules, globals * and environments and merges them with global config; also code blocks * where reporting is disabled or enabled and merges them with reporting config. - * @param {ASTNode} ast The top node of the AST. + * @param {SourceCode} sourceCode The SourceCode object to get comments from. * @param {function(string): {create: Function}} ruleMapper A map from rule IDs to defined rules * @param {string|null} warnInlineConfig If a string then it should warn directive comments as disabled. The string value is the config name what the setting came from. * @returns {{configuredRules: Object, enabledGlobals: {value:string,comment:Token}[], exportedVariables: Object, problems: LintMessage[], disableDirectives: DisableDirective[]}} * A collection of the directive comments that were found, along with any problems that occurred when parsing */ -function getDirectiveComments(ast, ruleMapper, warnInlineConfig) { +function getDirectiveComments(sourceCode, ruleMapper, warnInlineConfig) { const configuredRules = {}; const enabledGlobals = Object.create(null); const exportedVariables = {}; @@ -377,7 +354,7 @@ function getDirectiveComments(ast, ruleMapper, warnInlineConfig) { builtInRules: Rules }); - ast.comments.filter(token => token.type !== "Shebang").forEach(comment => { + sourceCode.getInlineConfigNodes().filter(token => token.type !== "Shebang").forEach(comment => { const { directivePart, justificationPart } = extractDirectiveComment(comment.value); const match = directivesPattern.exec(directivePart); @@ -511,6 +488,69 @@ function getDirectiveComments(ast, ruleMapper, warnInlineConfig) { }; } +/** + * Parses comments in file to extract disable directives. + * @param {SourceCode} sourceCode The SourceCode object to get comments from. + * @param {function(string): {create: Function}} ruleMapper A map from rule IDs to defined rules + * @returns {{problems: LintMessage[], disableDirectives: DisableDirective[]}} + * A collection of the directive comments that were found, along with any problems that occurred when parsing + */ +function getDirectiveCommentsForFlatConfig(sourceCode, ruleMapper) { + const problems = []; + const disableDirectives = []; + + sourceCode.getInlineConfigNodes().filter(token => token.type !== "Shebang").forEach(comment => { + const { directivePart, justificationPart } = extractDirectiveComment(comment.value); + + const match = directivesPattern.exec(directivePart); + + if (!match) { + return; + } + const directiveText = match[1]; + const lineCommentSupported = /^eslint-disable-(next-)?line$/u.test(directiveText); + + if (comment.type === "Line" && !lineCommentSupported) { + return; + } + + if (directiveText === "eslint-disable-line" && comment.loc.start.line !== comment.loc.end.line) { + const message = `${directiveText} comment should not span multiple lines.`; + + problems.push(createLintingProblem({ + ruleId: null, + message, + loc: comment.loc + })); + return; + } + + const directiveValue = directivePart.slice(match.index + directiveText.length); + + switch (directiveText) { + case "eslint-disable": + case "eslint-enable": + case "eslint-disable-next-line": + case "eslint-disable-line": { + const directiveType = directiveText.slice("eslint-".length); + const options = { commentToken: comment, type: directiveType, value: directiveValue, justification: justificationPart, ruleMapper }; + const { directives, directiveProblems } = createDisableDirectives(options); + + disableDirectives.push(...directives); + problems.push(...directiveProblems); + break; + } + + // no default + } + }); + + return { + problems, + disableDirectives + }; +} + /** * Normalize ECMAScript version from the initial config * @param {Parser} parser The parser which uses this options. @@ -898,6 +938,7 @@ const DEPRECATED_SOURCECODE_PASSTHROUGHS = { getTokensBetween: "getTokensBetween" }; + const BASE_TRAVERSAL_CONTEXT = Object.freeze( Object.keys(DEPRECATED_SOURCECODE_PASSTHROUGHS).reduce( (contextInfo, methodName) => @@ -1312,7 +1353,7 @@ class Linter { const sourceCode = slots.lastSourceCode; const commentDirectives = options.allowInlineConfig - ? getDirectiveComments(sourceCode.ast, ruleId => getRule(slots, ruleId), options.warnInlineConfig) + ? getDirectiveComments(sourceCode, ruleId => getRule(slots, ruleId), options.warnInlineConfig) : { configuredRules: {}, enabledGlobals: {}, exportedVariables: {}, problems: [], disableDirectives: [] }; // augment global scope with declared global variables @@ -1323,7 +1364,6 @@ class Linter { ); const configuredRules = Object.assign({}, config.rules, commentDirectives.configuredRules); - let lintingProblems; try { @@ -1539,19 +1579,6 @@ class Linter { languageOptions.ecmaVersion ); - /* - * add configured globals and language globals - * - * using Object.assign instead of object spread for performance reasons - * https://github.com/eslint/eslint/issues/16302 - */ - const configuredGlobals = Object.assign( - {}, - getGlobalsForEcmaVersion(languageOptions.ecmaVersion), - languageOptions.sourceType === "commonjs" ? globals.commonjs : void 0, - languageOptions.globals - ); - // double check that there is a parser to avoid mysterious error messages if (!languageOptions.parser) { throw new TypeError(`No parser specified for ${options.filename}`); @@ -1607,25 +1634,113 @@ class Linter { } const sourceCode = slots.lastSourceCode; - const commentDirectives = options.allowInlineConfig - ? getDirectiveComments( - sourceCode.ast, - ruleId => getRuleFromConfig(ruleId, config), - options.warnInlineConfig - ) - : { configuredRules: {}, enabledGlobals: {}, exportedVariables: {}, problems: [], disableDirectives: [] }; - // augment global scope with declared global variables - addDeclaredGlobals( - sourceCode.scopeManager.scopes[0], - configuredGlobals, - { exportedVariables: commentDirectives.exportedVariables, enabledGlobals: commentDirectives.enabledGlobals } - ); + /* + * Make adjustments based on the language options. For JavaScript, + * this is primarily about adding variables into the global scope + * to account for ecmaVersion and configured globals. + */ + sourceCode.applyLanguageOptions(languageOptions); - const configuredRules = Object.assign({}, config.rules, commentDirectives.configuredRules); + const mergedInlineConfig = { + rules: {} + }; + const inlineConfigProblems = []; + /* + * Inline config can be either enabled or disabled. If disabled, it's possible + * to detect the inline config and emit a warning (though this is not required). + * So we first check to see if inline config is allowed at all, and if so, we + * need to check if it's a warning or not. + */ + if (options.allowInlineConfig) { + + // if inline config should warn then add the warnings + if (options.warnInlineConfig) { + sourceCode.getInlineConfigNodes().forEach(node => { + inlineConfigProblems.push(createLintingProblem({ + ruleId: null, + message: `'${sourceCode.text.slice(node.range[0], node.range[1])}' has no effect because you have 'noInlineConfig' setting in ${options.warnInlineConfig}.`, + loc: node.loc, + severity: 1 + })); + + }); + } else { + const inlineConfigResult = sourceCode.applyInlineConfig(); + + inlineConfigProblems.push( + ...inlineConfigResult.problems + .map(createLintingProblem) + .map(problem => { + problem.fatal = true; + return problem; + }) + ); + + // next we need to verify information about the specified rules + const ruleValidator = new RuleValidator(); + + for (const { config: inlineConfig, node } of inlineConfigResult.configs) { + + Object.keys(inlineConfig.rules).forEach(ruleId => { + const rule = getRuleFromConfig(ruleId, config); + const ruleValue = inlineConfig.rules[ruleId]; + + if (!rule) { + inlineConfigProblems.push(createLintingProblem({ ruleId, loc: node.loc })); + return; + } + + try { + + const ruleOptions = Array.isArray(ruleValue) ? ruleValue : [ruleValue]; + + assertIsRuleOptions(ruleId, ruleValue); + assertIsRuleSeverity(ruleId, ruleOptions[0]); + + ruleValidator.validate({ + plugins: config.plugins, + rules: { + [ruleId]: ruleOptions + } + }); + mergedInlineConfig.rules[ruleId] = ruleValue; + } catch (err) { + + let baseMessage = err.message.slice( + err.message.startsWith("Key \"rules\":") + ? err.message.indexOf(":", 12) + 1 + : err.message.indexOf(":") + 1 + ).trim(); + + if (err.messageTemplate) { + baseMessage += ` You passed "${ruleValue}".`; + } + + inlineConfigProblems.push(createLintingProblem({ + ruleId, + message: `Inline configuration for rule "${ruleId}" is invalid:\n\t${baseMessage}\n`, + loc: node.loc + })); + } + }); + } + } + } + + const commentDirectives = options.allowInlineConfig && !options.warnInlineConfig + ? getDirectiveCommentsForFlatConfig( + sourceCode, + ruleId => getRuleFromConfig(ruleId, config) + ) + : { problems: [], disableDirectives: [] }; + + const configuredRules = Object.assign({}, config.rules, mergedInlineConfig.rules); let lintingProblems; + sourceCode.finalize(); + try { lintingProblems = runRules( sourceCode, @@ -1666,6 +1781,7 @@ class Linter { disableFixes: options.disableFixes, problems: lintingProblems .concat(commentDirectives.problems) + .concat(inlineConfigProblems) .sort((problemA, problemB) => problemA.line - problemB.line || problemA.column - problemB.column), reportUnusedDisableDirectives: options.reportUnusedDisableDirectives }); diff --git a/tools/node_modules/eslint/lib/linter/report-translator.js b/tools/node_modules/eslint/lib/linter/report-translator.js index 7d2705206cdba1..41a43eadc3be18 100644 --- a/tools/node_modules/eslint/lib/linter/report-translator.js +++ b/tools/node_modules/eslint/lib/linter/report-translator.js @@ -100,6 +100,22 @@ function normalizeReportLoc(descriptor) { return descriptor.node.loc; } +/** + * Clones the given fix object. + * @param {Fix|null} fix The fix to clone. + * @returns {Fix|null} Deep cloned fix object or `null` if `null` or `undefined` was passed in. + */ +function cloneFix(fix) { + if (!fix) { + return null; + } + + return { + range: [fix.range[0], fix.range[1]], + text: fix.text + }; +} + /** * Check that a fix has a valid range. * @param {Fix|null} fix The fix to validate. @@ -137,7 +153,7 @@ function mergeFixes(fixes, sourceCode) { return null; } if (fixes.length === 1) { - return fixes[0]; + return cloneFix(fixes[0]); } fixes.sort(compareFixesByRange); @@ -183,7 +199,7 @@ function normalizeFixes(descriptor, sourceCode) { } assertValidFix(fix); - return fix; + return cloneFix(fix); } /** diff --git a/tools/node_modules/eslint/lib/options.js b/tools/node_modules/eslint/lib/options.js index 2bc4018afb5d5b..ae9a5d5552a239 100644 --- a/tools/node_modules/eslint/lib/options.js +++ b/tools/node_modules/eslint/lib/options.js @@ -55,6 +55,7 @@ const optionator = require("optionator"); * @property {string} [stdinFilename] Specify filename to process STDIN as * @property {boolean} quiet Report errors only * @property {boolean} [version] Output the version number + * @property {boolean} warnIgnored Show warnings when the file list includes ignored files * @property {string[]} _ Positional filenames or patterns */ @@ -139,6 +140,17 @@ module.exports = function(usingFlatConfig) { }; } + let warnIgnoredFlag; + + if (usingFlatConfig) { + warnIgnoredFlag = { + option: "warn-ignored", + type: "Boolean", + default: "true", + description: "Suppress warnings when the file list includes ignored files" + }; + } + return optionator({ prepend: "eslint [options] file.js [file.js] [dir]", defaults: { @@ -349,6 +361,7 @@ module.exports = function(usingFlatConfig) { default: "false", description: "Exit with exit code 2 in case of fatal error" }, + warnIgnoredFlag, { option: "debug", type: "Boolean", diff --git a/tools/node_modules/eslint/lib/rule-tester/flat-rule-tester.js b/tools/node_modules/eslint/lib/rule-tester/flat-rule-tester.js index f143873f7bc767..51cb73b5f8023d 100644 --- a/tools/node_modules/eslint/lib/rule-tester/flat-rule-tester.js +++ b/tools/node_modules/eslint/lib/rule-tester/flat-rule-tester.js @@ -16,7 +16,9 @@ const equal = require("fast-deep-equal"), Traverser = require("../shared/traverser"), { getRuleOptionsSchema } = require("../config/flat-config-helpers"), - { Linter, SourceCodeFixer, interpolate } = require("../linter"); + { Linter, SourceCodeFixer, interpolate } = require("../linter"), + CodePath = require("../linter/code-path-analysis/code-path"); + const { FlatConfigArray } = require("../config/flat-config-array"); const { defaultConfig } = require("../config/default-config"); @@ -32,6 +34,7 @@ const { ConfigArraySymbol } = require("@humanwhocodes/config-array"); /** @typedef {import("../shared/types").Parser} Parser */ /** @typedef {import("../shared/types").LanguageOptions} LanguageOptions */ +/** @typedef {import("../shared/types").Rule} Rule */ /** @@ -130,6 +133,15 @@ const suggestionObjectParameters = new Set([ ]); const friendlySuggestionObjectParameterList = `[${[...suggestionObjectParameters].map(key => `'${key}'`).join(", ")}]`; +const forbiddenMethods = [ + "applyInlineConfig", + "applyLanguageOptions", + "finalize" +]; + +/** @type {Map} */ +const forbiddenMethodCalls = new Map(forbiddenMethods.map(methodName => ([methodName, new WeakSet()]))); + const hasOwnProperty = Function.call.bind(Object.hasOwnProperty); /** @@ -273,6 +285,49 @@ function getCommentsDeprecation() { ); } +/** + * Emit a deprecation warning if rule uses CodePath#currentSegments. + * @param {string} ruleName Name of the rule. + * @returns {void} + */ +function emitCodePathCurrentSegmentsWarning(ruleName) { + if (!emitCodePathCurrentSegmentsWarning[`warned-${ruleName}`]) { + emitCodePathCurrentSegmentsWarning[`warned-${ruleName}`] = true; + process.emitWarning( + `"${ruleName}" rule uses CodePath#currentSegments and will stop working in ESLint v9. Please read the documentation for how to update your code: https://eslint.org/docs/latest/extend/code-path-analysis#usage-examples`, + "DeprecationWarning" + ); + } +} + +/** + * Function to replace forbidden `SourceCode` methods. Allows just one call per method. + * @param {string} methodName The name of the method to forbid. + * @param {Function} prototype The prototype with the original method to call. + * @returns {Function} The function that throws the error. + */ +function throwForbiddenMethodError(methodName, prototype) { + + const original = prototype[methodName]; + + return function(...args) { + + const called = forbiddenMethodCalls.get(methodName); + + /* eslint-disable no-invalid-this -- needed to operate as a method. */ + if (!called.has(this)) { + called.add(this); + + return original.apply(this, args); + } + /* eslint-enable no-invalid-this -- not needed past this point */ + + throw new Error( + `\`SourceCode#${methodName}()\` cannot be called inside a rule.` + ); + }; +} + //------------------------------------------------------------------------------ // Public Interface //------------------------------------------------------------------------------ @@ -446,7 +501,7 @@ class FlatRuleTester { /** * Adds a new rule test to execute. * @param {string} ruleName The name of the rule to run. - * @param {Function} rule The rule to test. + * @param {Function | Rule} rule The rule to test. * @param {{ * valid: (ValidTestCase | string)[], * invalid: InvalidTestCase[] @@ -480,6 +535,7 @@ class FlatRuleTester { } const baseConfig = [ + { files: ["**"] }, // Make sure the default config matches for all files { plugins: { @@ -661,10 +717,6 @@ class FlatRuleTester { } } - // Verify the code. - const { getComments } = SourceCode.prototype; - let messages; - // check for validation errors try { configs.normalizeSync(); @@ -674,13 +726,34 @@ class FlatRuleTester { throw error; } + // Verify the code. + const { getComments, applyLanguageOptions, applyInlineConfig, finalize } = SourceCode.prototype; + const originalCurrentSegments = Object.getOwnPropertyDescriptor(CodePath.prototype, "currentSegments"); + let messages; + try { SourceCode.prototype.getComments = getCommentsDeprecation; + Object.defineProperty(CodePath.prototype, "currentSegments", { + get() { + emitCodePathCurrentSegmentsWarning(ruleName); + return originalCurrentSegments.get.call(this); + } + }); + + forbiddenMethods.forEach(methodName => { + SourceCode.prototype[methodName] = throwForbiddenMethodError(methodName, SourceCode.prototype); + }); + messages = linter.verify(code, configs, filename); } finally { SourceCode.prototype.getComments = getComments; + Object.defineProperty(CodePath.prototype, "currentSegments", originalCurrentSegments); + SourceCode.prototype.applyInlineConfig = applyInlineConfig; + SourceCode.prototype.applyLanguageOptions = applyLanguageOptions; + SourceCode.prototype.finalize = finalize; } + const fatalErrorMessage = messages.find(m => m.fatal); assert(!fatalErrorMessage, `A fatal parsing error occurred: ${fatalErrorMessage && fatalErrorMessage.message}`); @@ -1011,29 +1084,35 @@ class FlatRuleTester { /* * This creates a mocha test suite and pipes all supplied info through * one of the templates above. + * The test suites for valid/invalid are created conditionally as + * test runners (eg. vitest) fail for empty test suites. */ this.constructor.describe(ruleName, () => { - this.constructor.describe("valid", () => { - test.valid.forEach(valid => { - this.constructor[valid.only ? "itOnly" : "it"]( - sanitize(typeof valid === "object" ? valid.name || valid.code : valid), - () => { - testValidTemplate(valid); - } - ); + if (test.valid.length > 0) { + this.constructor.describe("valid", () => { + test.valid.forEach(valid => { + this.constructor[valid.only ? "itOnly" : "it"]( + sanitize(typeof valid === "object" ? valid.name || valid.code : valid), + () => { + testValidTemplate(valid); + } + ); + }); }); - }); + } - this.constructor.describe("invalid", () => { - test.invalid.forEach(invalid => { - this.constructor[invalid.only ? "itOnly" : "it"]( - sanitize(invalid.name || invalid.code), - () => { - testInvalidTemplate(invalid); - } - ); + if (test.invalid.length > 0) { + this.constructor.describe("invalid", () => { + test.invalid.forEach(invalid => { + this.constructor[invalid.only ? "itOnly" : "it"]( + sanitize(invalid.name || invalid.code), + () => { + testInvalidTemplate(invalid); + } + ); + }); }); - }); + } }); } } diff --git a/tools/node_modules/eslint/lib/rule-tester/rule-tester.js b/tools/node_modules/eslint/lib/rule-tester/rule-tester.js index e4dc126783c823..3bc80ab1837a6f 100644 --- a/tools/node_modules/eslint/lib/rule-tester/rule-tester.js +++ b/tools/node_modules/eslint/lib/rule-tester/rule-tester.js @@ -48,7 +48,8 @@ const equal = require("fast-deep-equal"), Traverser = require("../../lib/shared/traverser"), { getRuleOptionsSchema, validate } = require("../shared/config-validator"), - { Linter, SourceCodeFixer, interpolate } = require("../linter"); + { Linter, SourceCodeFixer, interpolate } = require("../linter"), + CodePath = require("../linter/code-path-analysis/code-path"); const ajv = require("../shared/ajv")({ strictDefaults: true }); @@ -62,6 +63,7 @@ const { SourceCode } = require("../source-code"); //------------------------------------------------------------------------------ /** @typedef {import("../shared/types").Parser} Parser */ +/** @typedef {import("../shared/types").Rule} Rule */ /** @@ -161,8 +163,43 @@ const suggestionObjectParameters = new Set([ ]); const friendlySuggestionObjectParameterList = `[${[...suggestionObjectParameters].map(key => `'${key}'`).join(", ")}]`; +const forbiddenMethods = [ + "applyInlineConfig", + "applyLanguageOptions", + "finalize" +]; + const hasOwnProperty = Function.call.bind(Object.hasOwnProperty); +const DEPRECATED_SOURCECODE_PASSTHROUGHS = { + getSource: "getText", + getSourceLines: "getLines", + getAllComments: "getAllComments", + getNodeByRangeIndex: "getNodeByRangeIndex", + + // getComments: "getComments", -- already handled by a separate error + getCommentsBefore: "getCommentsBefore", + getCommentsAfter: "getCommentsAfter", + getCommentsInside: "getCommentsInside", + getJSDocComment: "getJSDocComment", + getFirstToken: "getFirstToken", + getFirstTokens: "getFirstTokens", + getLastToken: "getLastToken", + getLastTokens: "getLastTokens", + getTokenAfter: "getTokenAfter", + getTokenBefore: "getTokenBefore", + getTokenByRangeStart: "getTokenByRangeStart", + getTokens: "getTokens", + getTokensAfter: "getTokensAfter", + getTokensBefore: "getTokensBefore", + getTokensBetween: "getTokensBetween", + + getScope: "getScope", + getAncestors: "getAncestors", + getDeclaredVariables: "getDeclaredVariables", + markVariableAsUsed: "markVariableAsUsed" +}; + /** * Clones a given value deeply. * Note: This ignores `parent` property. @@ -304,6 +341,19 @@ function getCommentsDeprecation() { ); } +/** + * Function to replace forbidden `SourceCode` methods. + * @param {string} methodName The name of the method to forbid. + * @returns {Function} The function that throws the error. + */ +function throwForbiddenMethodError(methodName) { + return () => { + throw new Error( + `\`SourceCode#${methodName}()\` cannot be called inside a rule.` + ); + }; +} + /** * Emit a deprecation warning if function-style format is being used. * @param {string} ruleName Name of the rule. @@ -334,6 +384,53 @@ function emitMissingSchemaWarning(ruleName) { } } +/** + * Emit a deprecation warning if a rule uses a deprecated `context` method. + * @param {string} ruleName Name of the rule. + * @param {string} methodName The name of the method on `context` that was used. + * @returns {void} + */ +function emitDeprecatedContextMethodWarning(ruleName, methodName) { + if (!emitDeprecatedContextMethodWarning[`warned-${ruleName}-${methodName}`]) { + emitDeprecatedContextMethodWarning[`warned-${ruleName}-${methodName}`] = true; + process.emitWarning( + `"${ruleName}" rule is using \`context.${methodName}()\`, which is deprecated and will be removed in ESLint v9. Please use \`sourceCode.${DEPRECATED_SOURCECODE_PASSTHROUGHS[methodName]}()\` instead.`, + "DeprecationWarning" + ); + } +} + +/** + * Emit a deprecation warning if rule uses CodePath#currentSegments. + * @param {string} ruleName Name of the rule. + * @returns {void} + */ +function emitCodePathCurrentSegmentsWarning(ruleName) { + if (!emitCodePathCurrentSegmentsWarning[`warned-${ruleName}`]) { + emitCodePathCurrentSegmentsWarning[`warned-${ruleName}`] = true; + process.emitWarning( + `"${ruleName}" rule uses CodePath#currentSegments and will stop working in ESLint v9. Please read the documentation for how to update your code: https://eslint.org/docs/latest/extend/code-path-analysis#usage-examples`, + "DeprecationWarning" + ); + } +} + +/** + * Emit a deprecation warning if `context.parserServices` is used. + * @param {string} ruleName Name of the rule. + * @returns {void} + */ +function emitParserServicesWarning(ruleName) { + if (!emitParserServicesWarning[`warned-${ruleName}`]) { + emitParserServicesWarning[`warned-${ruleName}`] = true; + process.emitWarning( + `"${ruleName}" rule is using \`context.parserServices\`, which is deprecated and will be removed in ESLint v9. Please use \`sourceCode.parserServices\` instead.`, + "DeprecationWarning" + ); + } +} + + //------------------------------------------------------------------------------ // Public Interface //------------------------------------------------------------------------------ @@ -508,17 +605,20 @@ class RuleTester { /** * Define a rule for one particular run of tests. * @param {string} name The name of the rule to define. - * @param {Function} rule The rule definition. + * @param {Function | Rule} rule The rule definition. * @returns {void} */ defineRule(name, rule) { + if (typeof rule === "function") { + emitLegacyRuleAPIWarning(name); + } this.rules[name] = rule; } /** * Adds a new rule test to execute. * @param {string} ruleName The name of the rule to run. - * @param {Function} rule The rule to test. + * @param {Function | Rule} rule The rule to test. * @param {{ * valid: (ValidTestCase | string)[], * invalid: InvalidTestCase[] @@ -562,7 +662,38 @@ class RuleTester { freezeDeeply(context.settings); freezeDeeply(context.parserOptions); - return (typeof rule === "function" ? rule : rule.create)(context); + // wrap all deprecated methods + const newContext = Object.create( + context, + Object.fromEntries(Object.keys(DEPRECATED_SOURCECODE_PASSTHROUGHS).map(methodName => [ + methodName, + { + value(...args) { + + // emit deprecation warning + emitDeprecatedContextMethodWarning(ruleName, methodName); + + // call the original method + return context[methodName].call(this, ...args); + }, + enumerable: true + } + ])) + ); + + // emit warning about context.parserServices + const parserServices = context.parserServices; + + Object.defineProperty(newContext, "parserServices", { + get() { + emitParserServicesWarning(ruleName); + return parserServices; + } + }); + + Object.freeze(newContext); + + return (typeof rule === "function" ? rule : rule.create)(newContext); } })); @@ -681,14 +812,30 @@ class RuleTester { validate(config, "rule-tester", id => (id === ruleName ? rule : null)); // Verify the code. - const { getComments } = SourceCode.prototype; + const { getComments, applyLanguageOptions, applyInlineConfig, finalize } = SourceCode.prototype; + const originalCurrentSegments = Object.getOwnPropertyDescriptor(CodePath.prototype, "currentSegments"); let messages; try { SourceCode.prototype.getComments = getCommentsDeprecation; + Object.defineProperty(CodePath.prototype, "currentSegments", { + get() { + emitCodePathCurrentSegmentsWarning(ruleName); + return originalCurrentSegments.get.call(this); + } + }); + + forbiddenMethods.forEach(methodName => { + SourceCode.prototype[methodName] = throwForbiddenMethodError(methodName); + }); + messages = linter.verify(code, config, filename); } finally { SourceCode.prototype.getComments = getComments; + Object.defineProperty(CodePath.prototype, "currentSegments", originalCurrentSegments); + SourceCode.prototype.applyInlineConfig = applyInlineConfig; + SourceCode.prototype.applyLanguageOptions = applyLanguageOptions; + SourceCode.prototype.finalize = finalize; } const fatalErrorMessage = messages.find(m => m.fatal); @@ -1021,29 +1168,35 @@ class RuleTester { /* * This creates a mocha test suite and pipes all supplied info through * one of the templates above. + * The test suites for valid/invalid are created conditionally as + * test runners (eg. vitest) fail for empty test suites. */ this.constructor.describe(ruleName, () => { - this.constructor.describe("valid", () => { - test.valid.forEach(valid => { - this.constructor[valid.only ? "itOnly" : "it"]( - sanitize(typeof valid === "object" ? valid.name || valid.code : valid), - () => { - testValidTemplate(valid); - } - ); + if (test.valid.length > 0) { + this.constructor.describe("valid", () => { + test.valid.forEach(valid => { + this.constructor[valid.only ? "itOnly" : "it"]( + sanitize(typeof valid === "object" ? valid.name || valid.code : valid), + () => { + testValidTemplate(valid); + } + ); + }); }); - }); + } - this.constructor.describe("invalid", () => { - test.invalid.forEach(invalid => { - this.constructor[invalid.only ? "itOnly" : "it"]( - sanitize(invalid.name || invalid.code), - () => { - testInvalidTemplate(invalid); - } - ); + if (test.invalid.length > 0) { + this.constructor.describe("invalid", () => { + test.invalid.forEach(invalid => { + this.constructor[invalid.only ? "itOnly" : "it"]( + sanitize(invalid.name || invalid.code), + () => { + testInvalidTemplate(invalid); + } + ); + }); }); - }); + } }); } } diff --git a/tools/node_modules/eslint/lib/rules/array-callback-return.js b/tools/node_modules/eslint/lib/rules/array-callback-return.js index 05cd4ede96651a..6d8f258fa140f2 100644 --- a/tools/node_modules/eslint/lib/rules/array-callback-return.js +++ b/tools/node_modules/eslint/lib/rules/array-callback-return.js @@ -18,15 +18,6 @@ const astUtils = require("./utils/ast-utils"); const TARGET_NODE_TYPE = /^(?:Arrow)?FunctionExpression$/u; const TARGET_METHODS = /^(?:every|filter|find(?:Last)?(?:Index)?|flatMap|forEach|map|reduce(?:Right)?|some|sort|toSorted)$/u; -/** - * Checks a given code path segment is reachable. - * @param {CodePathSegment} segment A segment to check. - * @returns {boolean} `true` if the segment is reachable. - */ -function isReachable(segment) { - return segment.reachable; -} - /** * Checks a given node is a member access which has the specified name's * property. @@ -38,6 +29,22 @@ function isTargetMethod(node) { return astUtils.isSpecificMemberAccess(node, null, TARGET_METHODS); } +/** + * Checks all segments in a set and returns true if any are reachable. + * @param {Set} segments The segments to check. + * @returns {boolean} True if any segment is reachable; false otherwise. + */ +function isAnySegmentReachable(segments) { + + for (const segment of segments) { + if (segment.reachable) { + return true; + } + } + + return false; +} + /** * Returns a human-legible description of an array method * @param {string} arrayMethodName A method name to fully qualify @@ -129,6 +136,76 @@ function getArrayMethodName(node) { return null; } +/** + * Checks if the given node is a void expression. + * @param {ASTNode} node The node to check. + * @returns {boolean} - `true` if the node is a void expression + */ +function isExpressionVoid(node) { + return node.type === "UnaryExpression" && node.operator === "void"; +} + +/** + * Fixes the linting error by prepending "void " to the given node + * @param {Object} sourceCode context given by context.sourceCode + * @param {ASTNode} node The node to fix. + * @param {Object} fixer The fixer object provided by ESLint. + * @returns {Array} - An array of fix objects to apply to the node. + */ +function voidPrependFixer(sourceCode, node, fixer) { + + const requiresParens = + + // prepending `void ` will fail if the node has a lower precedence than void + astUtils.getPrecedence(node) < astUtils.getPrecedence({ type: "UnaryExpression", operator: "void" }) && + + // check if there are parentheses around the node to avoid redundant parentheses + !astUtils.isParenthesised(sourceCode, node); + + // avoid parentheses issues + const returnOrArrowToken = sourceCode.getTokenBefore( + node, + node.parent.type === "ArrowFunctionExpression" + ? astUtils.isArrowToken + + // isReturnToken + : token => token.type === "Keyword" && token.value === "return" + ); + + const firstToken = sourceCode.getTokenAfter(returnOrArrowToken); + + const prependSpace = + + // is return token, as => allows void to be adjacent + returnOrArrowToken.value === "return" && + + // If two tokens (return and "(") are adjacent + returnOrArrowToken.range[1] === firstToken.range[0]; + + return [ + fixer.insertTextBefore(firstToken, `${prependSpace ? " " : ""}void ${requiresParens ? "(" : ""}`), + fixer.insertTextAfter(node, requiresParens ? ")" : "") + ]; +} + +/** + * Fixes the linting error by `wrapping {}` around the given node's body. + * @param {Object} sourceCode context given by context.sourceCode + * @param {ASTNode} node The node to fix. + * @param {Object} fixer The fixer object provided by ESLint. + * @returns {Array} - An array of fix objects to apply to the node. + */ +function curlyWrapFixer(sourceCode, node, fixer) { + const arrowToken = sourceCode.getTokenBefore(node.body, astUtils.isArrowToken); + const firstToken = sourceCode.getTokenAfter(arrowToken); + const lastToken = sourceCode.getLastToken(node); + + return [ + fixer.insertTextBefore(firstToken, "{"), + fixer.insertTextAfter(lastToken, "}") + ]; +} + //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ @@ -144,6 +221,9 @@ module.exports = { url: "https://eslint.org/docs/latest/rules/array-callback-return" }, + // eslint-disable-next-line eslint-plugin/require-meta-has-suggestions -- false positive + hasSuggestions: true, + schema: [ { type: "object", @@ -155,6 +235,10 @@ module.exports = { checkForEach: { type: "boolean", default: false + }, + allowVoid: { + type: "boolean", + default: false } }, additionalProperties: false @@ -165,13 +249,15 @@ module.exports = { expectedAtEnd: "{{arrayMethodName}}() expects a value to be returned at the end of {{name}}.", expectedInside: "{{arrayMethodName}}() expects a return value from {{name}}.", expectedReturnValue: "{{arrayMethodName}}() expects a return value from {{name}}.", - expectedNoReturnValue: "{{arrayMethodName}}() expects no useless return value from {{name}}." + expectedNoReturnValue: "{{arrayMethodName}}() expects no useless return value from {{name}}.", + wrapBraces: "Wrap the expression in `{}`.", + prependVoid: "Prepend `void` to the expression." } }, create(context) { - const options = context.options[0] || { allowImplicit: false, checkForEach: false }; + const options = context.options[0] || { allowImplicit: false, checkForEach: false, allowVoid: false }; const sourceCode = context.sourceCode; let funcInfo = { @@ -198,26 +284,56 @@ module.exports = { return; } - let messageId = null; + const messageAndSuggestions = { messageId: "", suggest: [] }; if (funcInfo.arrayMethodName === "forEach") { if (options.checkForEach && node.type === "ArrowFunctionExpression" && node.expression) { - messageId = "expectedNoReturnValue"; + + if (options.allowVoid) { + if (isExpressionVoid(node.body)) { + return; + } + + messageAndSuggestions.messageId = "expectedNoReturnValue"; + messageAndSuggestions.suggest = [ + { + messageId: "wrapBraces", + fix(fixer) { + return curlyWrapFixer(sourceCode, node, fixer); + } + }, + { + messageId: "prependVoid", + fix(fixer) { + return voidPrependFixer(sourceCode, node.body, fixer); + } + } + ]; + } else { + messageAndSuggestions.messageId = "expectedNoReturnValue"; + messageAndSuggestions.suggest = [{ + messageId: "wrapBraces", + fix(fixer) { + return curlyWrapFixer(sourceCode, node, fixer); + } + }]; + } } } else { - if (node.body.type === "BlockStatement" && funcInfo.codePath.currentSegments.some(isReachable)) { - messageId = funcInfo.hasReturn ? "expectedAtEnd" : "expectedInside"; + if (node.body.type === "BlockStatement" && isAnySegmentReachable(funcInfo.currentSegments)) { + messageAndSuggestions.messageId = funcInfo.hasReturn ? "expectedAtEnd" : "expectedInside"; } } - if (messageId) { + if (messageAndSuggestions.messageId) { const name = astUtils.getFunctionNameWithKind(node); context.report({ node, loc: astUtils.getFunctionHeadLoc(node, sourceCode), - messageId, - data: { name, arrayMethodName: fullMethodName(funcInfo.arrayMethodName) } + messageId: messageAndSuggestions.messageId, + data: { name, arrayMethodName: fullMethodName(funcInfo.arrayMethodName) }, + suggest: messageAndSuggestions.suggest.length !== 0 ? messageAndSuggestions.suggest : null }); } } @@ -242,7 +358,8 @@ module.exports = { methodName && !node.async && !node.generator, - node + node, + currentSegments: new Set() }; }, @@ -251,6 +368,23 @@ module.exports = { funcInfo = funcInfo.upper; }, + onUnreachableCodePathSegmentStart(segment) { + funcInfo.currentSegments.add(segment); + }, + + onUnreachableCodePathSegmentEnd(segment) { + funcInfo.currentSegments.delete(segment); + }, + + onCodePathSegmentStart(segment) { + funcInfo.currentSegments.add(segment); + }, + + onCodePathSegmentEnd(segment) { + funcInfo.currentSegments.delete(segment); + }, + + // Checks the return statement is valid. ReturnStatement(node) { @@ -260,30 +394,46 @@ module.exports = { funcInfo.hasReturn = true; - let messageId = null; + const messageAndSuggestions = { messageId: "", suggest: [] }; if (funcInfo.arrayMethodName === "forEach") { // if checkForEach: true, returning a value at any path inside a forEach is not allowed if (options.checkForEach && node.argument) { - messageId = "expectedNoReturnValue"; + + if (options.allowVoid) { + if (isExpressionVoid(node.argument)) { + return; + } + + messageAndSuggestions.messageId = "expectedNoReturnValue"; + messageAndSuggestions.suggest = [{ + messageId: "prependVoid", + fix(fixer) { + return voidPrependFixer(sourceCode, node.argument, fixer); + } + }]; + } else { + messageAndSuggestions.messageId = "expectedNoReturnValue"; + } } } else { // if allowImplicit: false, should also check node.argument if (!options.allowImplicit && !node.argument) { - messageId = "expectedReturnValue"; + messageAndSuggestions.messageId = "expectedReturnValue"; } } - if (messageId) { + if (messageAndSuggestions.messageId) { context.report({ node, - messageId, + messageId: messageAndSuggestions.messageId, data: { name: astUtils.getFunctionNameWithKind(funcInfo.node), arrayMethodName: fullMethodName(funcInfo.arrayMethodName) - } + }, + suggest: messageAndSuggestions.suggest.length !== 0 ? messageAndSuggestions.suggest : null }); } }, diff --git a/tools/node_modules/eslint/lib/rules/consistent-return.js b/tools/node_modules/eslint/lib/rules/consistent-return.js index e2d3f078270cf6..304e924b14a09c 100644 --- a/tools/node_modules/eslint/lib/rules/consistent-return.js +++ b/tools/node_modules/eslint/lib/rules/consistent-return.js @@ -16,12 +16,19 @@ const { upperCaseFirst } = require("../shared/string-utils"); //------------------------------------------------------------------------------ /** - * Checks whether or not a given code path segment is unreachable. - * @param {CodePathSegment} segment A CodePathSegment to check. - * @returns {boolean} `true` if the segment is unreachable. + * Checks all segments in a set and returns true if all are unreachable. + * @param {Set} segments The segments to check. + * @returns {boolean} True if all segments are unreachable; false otherwise. */ -function isUnreachable(segment) { - return !segment.reachable; +function areAllSegmentsUnreachable(segments) { + + for (const segment of segments) { + if (segment.reachable) { + return false; + } + } + + return true; } /** @@ -88,7 +95,7 @@ module.exports = { * When unreachable, all paths are returned or thrown. */ if (!funcInfo.hasReturnValue || - funcInfo.codePath.currentSegments.every(isUnreachable) || + areAllSegmentsUnreachable(funcInfo.currentSegments) || astUtils.isES5Constructor(node) || isClassConstructor(node) ) { @@ -141,13 +148,31 @@ module.exports = { hasReturn: false, hasReturnValue: false, messageId: "", - node + node, + currentSegments: new Set() }; }, onCodePathEnd() { funcInfo = funcInfo.upper; }, + onUnreachableCodePathSegmentStart(segment) { + funcInfo.currentSegments.add(segment); + }, + + onUnreachableCodePathSegmentEnd(segment) { + funcInfo.currentSegments.delete(segment); + }, + + onCodePathSegmentStart(segment) { + funcInfo.currentSegments.add(segment); + }, + + onCodePathSegmentEnd(segment) { + funcInfo.currentSegments.delete(segment); + }, + + // Reports a given return statement if it's inconsistent. ReturnStatement(node) { const argument = node.argument; diff --git a/tools/node_modules/eslint/lib/rules/constructor-super.js b/tools/node_modules/eslint/lib/rules/constructor-super.js index 5f405881252481..330be80f386539 100644 --- a/tools/node_modules/eslint/lib/rules/constructor-super.js +++ b/tools/node_modules/eslint/lib/rules/constructor-super.js @@ -10,12 +10,19 @@ //------------------------------------------------------------------------------ /** - * Checks whether a given code path segment is reachable or not. - * @param {CodePathSegment} segment A code path segment to check. - * @returns {boolean} `true` if the segment is reachable. + * Checks all segments in a set and returns true if any are reachable. + * @param {Set} segments The segments to check. + * @returns {boolean} True if any segment is reachable; false otherwise. */ -function isReachable(segment) { - return segment.reachable; +function isAnySegmentReachable(segments) { + + for (const segment of segments) { + if (segment.reachable) { + return true; + } + } + + return false; } /** @@ -210,7 +217,8 @@ module.exports = { isConstructor: true, hasExtends: Boolean(superClass), superIsConstructor: isPossibleConstructor(superClass), - codePath + codePath, + currentSegments: new Set() }; } else { funcInfo = { @@ -218,7 +226,8 @@ module.exports = { isConstructor: false, hasExtends: false, superIsConstructor: false, - codePath + codePath, + currentSegments: new Set() }; } }, @@ -261,6 +270,9 @@ module.exports = { * @returns {void} */ onCodePathSegmentStart(segment) { + + funcInfo.currentSegments.add(segment); + if (!(funcInfo && funcInfo.isConstructor && funcInfo.hasExtends)) { return; } @@ -281,6 +293,19 @@ module.exports = { } }, + onUnreachableCodePathSegmentStart(segment) { + funcInfo.currentSegments.add(segment); + }, + + onUnreachableCodePathSegmentEnd(segment) { + funcInfo.currentSegments.delete(segment); + }, + + onCodePathSegmentEnd(segment) { + funcInfo.currentSegments.delete(segment); + }, + + /** * Update information of the code path segment when a code path was * looped. @@ -344,12 +369,11 @@ module.exports = { // Reports if needed. if (funcInfo.hasExtends) { - const segments = funcInfo.codePath.currentSegments; + const segments = funcInfo.currentSegments; let duplicate = false; let info = null; - for (let i = 0; i < segments.length; ++i) { - const segment = segments[i]; + for (const segment of segments) { if (segment.reachable) { info = segInfoMap[segment.id]; @@ -374,7 +398,7 @@ module.exports = { info.validNodes.push(node); } } - } else if (funcInfo.codePath.currentSegments.some(isReachable)) { + } else if (isAnySegmentReachable(funcInfo.currentSegments)) { context.report({ messageId: "unexpected", node @@ -398,10 +422,9 @@ module.exports = { } // Returning argument is a substitute of 'super()'. - const segments = funcInfo.codePath.currentSegments; + const segments = funcInfo.currentSegments; - for (let i = 0; i < segments.length; ++i) { - const segment = segments[i]; + for (const segment of segments) { if (segment.reachable) { const info = segInfoMap[segment.id]; diff --git a/tools/node_modules/eslint/lib/rules/for-direction.js b/tools/node_modules/eslint/lib/rules/for-direction.js index 4ed73501581aac..3f2ad9df645072 100644 --- a/tools/node_modules/eslint/lib/rules/for-direction.js +++ b/tools/node_modules/eslint/lib/rules/for-direction.js @@ -5,6 +5,12 @@ "use strict"; +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const { getStaticValue } = require("@eslint-community/eslint-utils"); + //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ @@ -29,6 +35,7 @@ module.exports = { }, create(context) { + const { sourceCode } = context; /** * report an error. @@ -46,17 +53,17 @@ module.exports = { * check the right side of the assignment * @param {ASTNode} update UpdateExpression to check * @param {int} dir expected direction that could either be turned around or invalidated - * @returns {int} return dir, the negated dir or zero if it's not clear for identifiers + * @returns {int} return dir, the negated dir, or zero if the counter does not change or the direction is not clear */ function getRightDirection(update, dir) { - if (update.right.type === "UnaryExpression") { - if (update.right.operator === "-") { - return -dir; - } - } else if (update.right.type === "Identifier") { - return 0; + const staticValue = getStaticValue(update.right, sourceCode.getScope(update)); + + if (staticValue && ["bigint", "boolean", "number"].includes(typeof staticValue.value)) { + const sign = Math.sign(Number(staticValue.value)) || 0; // convert NaN to 0 + + return dir * sign; } - return dir; + return 0; } /** diff --git a/tools/node_modules/eslint/lib/rules/getter-return.js b/tools/node_modules/eslint/lib/rules/getter-return.js index 622b6a7541cda5..79ebf3e0902b07 100644 --- a/tools/node_modules/eslint/lib/rules/getter-return.js +++ b/tools/node_modules/eslint/lib/rules/getter-return.js @@ -14,15 +14,23 @@ const astUtils = require("./utils/ast-utils"); //------------------------------------------------------------------------------ // Helpers //------------------------------------------------------------------------------ + const TARGET_NODE_TYPE = /^(?:Arrow)?FunctionExpression$/u; /** - * Checks a given code path segment is reachable. - * @param {CodePathSegment} segment A segment to check. - * @returns {boolean} `true` if the segment is reachable. + * Checks all segments in a set and returns true if any are reachable. + * @param {Set} segments The segments to check. + * @returns {boolean} True if any segment is reachable; false otherwise. */ -function isReachable(segment) { - return segment.reachable; +function isAnySegmentReachable(segments) { + + for (const segment of segments) { + if (segment.reachable) { + return true; + } + } + + return false; } //------------------------------------------------------------------------------ @@ -71,7 +79,8 @@ module.exports = { codePath: null, hasReturn: false, shouldCheck: false, - node: null + node: null, + currentSegments: [] }; /** @@ -85,7 +94,7 @@ module.exports = { */ function checkLastSegment(node) { if (funcInfo.shouldCheck && - funcInfo.codePath.currentSegments.some(isReachable) + isAnySegmentReachable(funcInfo.currentSegments) ) { context.report({ node, @@ -144,7 +153,8 @@ module.exports = { codePath, hasReturn: false, shouldCheck: isGetter(node), - node + node, + currentSegments: new Set() }; }, @@ -152,6 +162,21 @@ module.exports = { onCodePathEnd() { funcInfo = funcInfo.upper; }, + onUnreachableCodePathSegmentStart(segment) { + funcInfo.currentSegments.add(segment); + }, + + onUnreachableCodePathSegmentEnd(segment) { + funcInfo.currentSegments.delete(segment); + }, + + onCodePathSegmentStart(segment) { + funcInfo.currentSegments.add(segment); + }, + + onCodePathSegmentEnd(segment) { + funcInfo.currentSegments.delete(segment); + }, // Checks the return statement is valid. ReturnStatement(node) { diff --git a/tools/node_modules/eslint/lib/rules/indent.js b/tools/node_modules/eslint/lib/rules/indent.js index 9068006d4972f1..7ea4b3f86c330c 100644 --- a/tools/node_modules/eslint/lib/rules/indent.js +++ b/tools/node_modules/eslint/lib/rules/indent.js @@ -1250,7 +1250,7 @@ module.exports = { IfStatement(node) { addBlocklessNodeIndent(node.consequent); - if (node.alternate && node.alternate.type !== "IfStatement") { + if (node.alternate) { addBlocklessNodeIndent(node.alternate); } }, diff --git a/tools/node_modules/eslint/lib/rules/index.js b/tools/node_modules/eslint/lib/rules/index.js index e42639656f7320..840abe73b0fbfe 100644 --- a/tools/node_modules/eslint/lib/rules/index.js +++ b/tools/node_modules/eslint/lib/rules/index.js @@ -175,6 +175,7 @@ module.exports = new LazyLoadingRuleMap(Object.entries({ "no-new-wrappers": () => require("./no-new-wrappers"), "no-nonoctal-decimal-escape": () => require("./no-nonoctal-decimal-escape"), "no-obj-calls": () => require("./no-obj-calls"), + "no-object-constructor": () => require("./no-object-constructor"), "no-octal": () => require("./no-octal"), "no-octal-escape": () => require("./no-octal-escape"), "no-param-reassign": () => require("./no-param-reassign"), diff --git a/tools/node_modules/eslint/lib/rules/lines-between-class-members.js b/tools/node_modules/eslint/lib/rules/lines-between-class-members.js index dee4bab5f54b01..3d0a5e6738e953 100644 --- a/tools/node_modules/eslint/lib/rules/lines-between-class-members.js +++ b/tools/node_modules/eslint/lib/rules/lines-between-class-members.js @@ -10,6 +10,21 @@ const astUtils = require("./utils/ast-utils"); +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +/** + * Types of class members. + * Those have `test` method to check it matches to the given class member. + * @private + */ +const ClassMemberTypes = { + "*": { test: () => true }, + field: { test: node => node.type === "PropertyDefinition" }, + method: { test: node => node.type === "MethodDefinition" } +}; + //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ @@ -29,7 +44,32 @@ module.exports = { schema: [ { - enum: ["always", "never"] + anyOf: [ + { + type: "object", + properties: { + enforce: { + type: "array", + items: { + type: "object", + properties: { + blankLine: { enum: ["always", "never"] }, + prev: { enum: ["method", "field", "*"] }, + next: { enum: ["method", "field", "*"] } + }, + additionalProperties: false, + required: ["blankLine", "prev", "next"] + }, + minItems: 1 + } + }, + additionalProperties: false, + required: ["enforce"] + }, + { + enum: ["always", "never"] + } + ] }, { type: "object", @@ -55,6 +95,7 @@ module.exports = { options[0] = context.options[0] || "always"; options[1] = context.options[1] || { exceptAfterSingleLine: false }; + const configureList = typeof options[0] === "object" ? options[0].enforce : [{ blankLine: options[0], prev: "*", next: "*" }]; const sourceCode = context.sourceCode; /** @@ -144,6 +185,38 @@ module.exports = { return sourceCode.getTokensBetween(before, after, { includeComments: true }).length !== 0; } + /** + * Checks whether the given node matches the given type. + * @param {ASTNode} node The class member node to check. + * @param {string} type The class member type to check. + * @returns {boolean} `true` if the class member node matched the type. + * @private + */ + function match(node, type) { + return ClassMemberTypes[type].test(node); + } + + /** + * Finds the last matched configuration from the configureList. + * @param {ASTNode} prevNode The previous node to match. + * @param {ASTNode} nextNode The current node to match. + * @returns {string|null} Padding type or `null` if no matches were found. + * @private + */ + function getPaddingType(prevNode, nextNode) { + for (let i = configureList.length - 1; i >= 0; --i) { + const configure = configureList[i]; + const matched = + match(prevNode, configure.prev) && + match(nextNode, configure.next); + + if (matched) { + return configure.blankLine; + } + } + return null; + } + return { ClassBody(node) { const body = node.body; @@ -158,22 +231,34 @@ module.exports = { const isPadded = afterPadding.loc.start.line - beforePadding.loc.end.line > 1; const hasTokenInPadding = hasTokenOrCommentBetween(beforePadding, afterPadding); const curLineLastToken = findLastConsecutiveTokenAfter(curLast, nextFirst, 0); + const paddingType = getPaddingType(body[i], body[i + 1]); + + if (paddingType === "never" && isPadded) { + context.report({ + node: body[i + 1], + messageId: "never", - if ((options[0] === "always" && !skip && !isPadded) || - (options[0] === "never" && isPadded)) { + fix(fixer) { + if (hasTokenInPadding) { + return null; + } + return fixer.replaceTextRange([beforePadding.range[1], afterPadding.range[0]], "\n"); + } + }); + } else if (paddingType === "always" && !skip && !isPadded) { context.report({ node: body[i + 1], - messageId: isPadded ? "never" : "always", + messageId: "always", + fix(fixer) { if (hasTokenInPadding) { return null; } - return isPadded - ? fixer.replaceTextRange([beforePadding.range[1], afterPadding.range[0]], "\n") - : fixer.insertTextAfter(curLineLastToken, "\n"); + return fixer.insertTextAfter(curLineLastToken, "\n"); } }); } + } } }; diff --git a/tools/node_modules/eslint/lib/rules/logical-assignment-operators.js b/tools/node_modules/eslint/lib/rules/logical-assignment-operators.js index 27ca585e99581d..c084c04c8eda08 100644 --- a/tools/node_modules/eslint/lib/rules/logical-assignment-operators.js +++ b/tools/node_modules/eslint/lib/rules/logical-assignment-operators.js @@ -150,6 +150,31 @@ function isInsideWithBlock(node) { return node.parent.type === "WithStatement" && node.parent.body === node ? true : isInsideWithBlock(node.parent); } +/** + * Gets the leftmost operand of a consecutive logical expression. + * @param {SourceCode} sourceCode The ESLint source code object + * @param {LogicalExpression} node LogicalExpression + * @returns {Expression} Leftmost operand + */ +function getLeftmostOperand(sourceCode, node) { + let left = node.left; + + while (left.type === "LogicalExpression" && left.operator === node.operator) { + + if (astUtils.isParenthesised(sourceCode, left)) { + + /* + * It should have associativity, + * but ignore it if use parentheses to make the evaluation order clear. + */ + return left; + } + left = left.left; + } + return left; + +} + //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ @@ -318,7 +343,10 @@ module.exports = { // foo = foo || bar "AssignmentExpression[operator='='][right.type='LogicalExpression']"(assignment) { - if (!astUtils.isSameReference(assignment.left, assignment.right.left)) { + const leftOperand = getLeftmostOperand(sourceCode, assignment.right); + + if (!astUtils.isSameReference(assignment.left, leftOperand) + ) { return; } @@ -342,10 +370,10 @@ module.exports = { yield ruleFixer.insertTextBefore(assignmentOperatorToken, assignment.right.operator); // -> foo ||= bar - const logicalOperatorToken = getOperatorToken(assignment.right); + const logicalOperatorToken = getOperatorToken(leftOperand.parent); const firstRightOperandToken = sourceCode.getTokenAfter(logicalOperatorToken); - yield ruleFixer.removeRange([assignment.right.range[0], firstRightOperandToken.range[0]]); + yield ruleFixer.removeRange([leftOperand.parent.range[0], firstRightOperandToken.range[0]]); } }; diff --git a/tools/node_modules/eslint/lib/rules/no-control-regex.js b/tools/node_modules/eslint/lib/rules/no-control-regex.js index 086747f374605e..dc412fcabd524b 100644 --- a/tools/node_modules/eslint/lib/rules/no-control-regex.js +++ b/tools/node_modules/eslint/lib/rules/no-control-regex.js @@ -14,6 +14,16 @@ const collector = new (class { } onPatternEnter() { + + /* + * `RegExpValidator` may parse the pattern twice in one `validatePattern`. + * So `this._controlChars` should be cleared here as well. + * + * For example, the `/(?\x1f)/` regex will parse the pattern twice. + * This is based on the content described in Annex B. + * If the regex contains a `GroupName` and the `u` flag is not used, `ParseText` will be called twice. + * See https://tc39.es/ecma262/2023/multipage/additional-ecmascript-features-for-web-browsers.html#sec-parsepattern-annexb + */ this._controlChars = []; } @@ -32,10 +42,13 @@ const collector = new (class { collectControlChars(regexpStr, flags) { const uFlag = typeof flags === "string" && flags.includes("u"); + const vFlag = typeof flags === "string" && flags.includes("v"); + + this._controlChars = []; + this._source = regexpStr; try { - this._source = regexpStr; - this._validator.validatePattern(regexpStr, void 0, void 0, uFlag); // Call onCharacter hook + this._validator.validatePattern(regexpStr, void 0, void 0, { unicode: uFlag, unicodeSets: vFlag }); // Call onCharacter hook } catch { // Ignore syntax errors in RegExp. diff --git a/tools/node_modules/eslint/lib/rules/no-empty-character-class.js b/tools/node_modules/eslint/lib/rules/no-empty-character-class.js index da29bbe9270469..5c8410235bcca3 100644 --- a/tools/node_modules/eslint/lib/rules/no-empty-character-class.js +++ b/tools/node_modules/eslint/lib/rules/no-empty-character-class.js @@ -5,20 +5,18 @@ "use strict"; +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const { RegExpParser, visitRegExpAST } = require("@eslint-community/regexpp"); + //------------------------------------------------------------------------------ // Helpers //------------------------------------------------------------------------------ -/* - * plain-English description of the following regexp: - * 0. `^` fix the match at the beginning of the string - * 1. `([^\\[]|\\.|\[([^\\\]]|\\.)+\])*`: regexp contents; 0 or more of the following - * 1.0. `[^\\[]`: any character that's not a `\` or a `[` (anything but escape sequences and character classes) - * 1.1. `\\.`: an escape sequence - * 1.2. `\[([^\\\]]|\\.)+\]`: a character class that isn't empty - * 2. `$`: fix the match at the end of the string - */ -const regex = /^([^\\[]|\\.|\[([^\\\]]|\\.)+\])*$/u; +const parser = new RegExpParser(); +const QUICK_TEST_REGEX = /\[\]/u; //------------------------------------------------------------------------------ // Rule Definition @@ -45,9 +43,32 @@ module.exports = { create(context) { return { "Literal[regex]"(node) { - if (!regex.test(node.regex.pattern)) { - context.report({ node, messageId: "unexpected" }); + const { pattern, flags } = node.regex; + + if (!QUICK_TEST_REGEX.test(pattern)) { + return; } + + let regExpAST; + + try { + regExpAST = parser.parsePattern(pattern, 0, pattern.length, { + unicode: flags.includes("u"), + unicodeSets: flags.includes("v") + }); + } catch { + + // Ignore regular expressions that regexpp cannot parse + return; + } + + visitRegExpAST(regExpAST, { + onCharacterClassEnter(characterClass) { + if (!characterClass.negate && characterClass.elements.length === 0) { + context.report({ node, messageId: "unexpected" }); + } + } + }); } }; diff --git a/tools/node_modules/eslint/lib/rules/no-empty-pattern.js b/tools/node_modules/eslint/lib/rules/no-empty-pattern.js index abb1a7c6ddbc0a..fb75f6d25b3458 100644 --- a/tools/node_modules/eslint/lib/rules/no-empty-pattern.js +++ b/tools/node_modules/eslint/lib/rules/no-empty-pattern.js @@ -4,6 +4,8 @@ */ "use strict"; +const astUtils = require("./utils/ast-utils"); + //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ @@ -19,7 +21,18 @@ module.exports = { url: "https://eslint.org/docs/latest/rules/no-empty-pattern" }, - schema: [], + schema: [ + { + type: "object", + properties: { + allowObjectPatternsAsParameters: { + type: "boolean", + default: false + } + }, + additionalProperties: false + } + ], messages: { unexpected: "Unexpected empty {{type}} pattern." @@ -27,11 +40,33 @@ module.exports = { }, create(context) { + const options = context.options[0] || {}, + allowObjectPatternsAsParameters = options.allowObjectPatternsAsParameters || false; + return { ObjectPattern(node) { - if (node.properties.length === 0) { - context.report({ node, messageId: "unexpected", data: { type: "object" } }); + + if (node.properties.length > 0) { + return; } + + // Allow {} and {} = {} empty object patterns as parameters when allowObjectPatternsAsParameters is true + if ( + allowObjectPatternsAsParameters && + ( + astUtils.isFunction(node.parent) || + ( + node.parent.type === "AssignmentPattern" && + astUtils.isFunction(node.parent.parent) && + node.parent.right.type === "ObjectExpression" && + node.parent.right.properties.length === 0 + ) + ) + ) { + return; + } + + context.report({ node, messageId: "unexpected", data: { type: "object" } }); }, ArrayPattern(node) { if (node.elements.length === 0) { diff --git a/tools/node_modules/eslint/lib/rules/no-fallthrough.js b/tools/node_modules/eslint/lib/rules/no-fallthrough.js index bd2ee9bbe2c803..91da12120222be 100644 --- a/tools/node_modules/eslint/lib/rules/no-fallthrough.js +++ b/tools/node_modules/eslint/lib/rules/no-fallthrough.js @@ -16,6 +16,22 @@ const { directivesPattern } = require("../shared/directives"); const DEFAULT_FALLTHROUGH_COMMENT = /falls?\s?through/iu; +/** + * Checks all segments in a set and returns true if any are reachable. + * @param {Set} segments The segments to check. + * @returns {boolean} True if any segment is reachable; false otherwise. + */ +function isAnySegmentReachable(segments) { + + for (const segment of segments) { + if (segment.reachable) { + return true; + } + } + + return false; +} + /** * Checks whether or not a given comment string is really a fallthrough comment and not an ESLint directive. * @param {string} comment The comment string to check. @@ -51,15 +67,6 @@ function hasFallthroughComment(caseWhichFallsThrough, subsequentCase, context, f return Boolean(comment && isFallThroughComment(comment.value, fallthroughCommentPattern)); } -/** - * Checks whether or not a given code path segment is reachable. - * @param {CodePathSegment} segment A CodePathSegment to check. - * @returns {boolean} `true` if the segment is reachable. - */ -function isReachable(segment) { - return segment.reachable; -} - /** * Checks whether a node and a token are separated by blank lines * @param {ASTNode} node The node to check @@ -109,7 +116,8 @@ module.exports = { create(context) { const options = context.options[0] || {}; - let currentCodePath = null; + const codePathSegments = []; + let currentCodePathSegments = new Set(); const sourceCode = context.sourceCode; const allowEmptyCase = options.allowEmptyCase || false; @@ -126,13 +134,33 @@ module.exports = { fallthroughCommentPattern = DEFAULT_FALLTHROUGH_COMMENT; } return { - onCodePathStart(codePath) { - currentCodePath = codePath; + + onCodePathStart() { + codePathSegments.push(currentCodePathSegments); + currentCodePathSegments = new Set(); }, + onCodePathEnd() { - currentCodePath = currentCodePath.upper; + currentCodePathSegments = codePathSegments.pop(); + }, + + onUnreachableCodePathSegmentStart(segment) { + currentCodePathSegments.add(segment); + }, + + onUnreachableCodePathSegmentEnd(segment) { + currentCodePathSegments.delete(segment); + }, + + onCodePathSegmentStart(segment) { + currentCodePathSegments.add(segment); }, + onCodePathSegmentEnd(segment) { + currentCodePathSegments.delete(segment); + }, + + SwitchCase(node) { /* @@ -157,7 +185,7 @@ module.exports = { * `break`, `return`, or `throw` are unreachable. * And allows empty cases and the last case. */ - if (currentCodePath.currentSegments.some(isReachable) && + if (isAnySegmentReachable(currentCodePathSegments) && (node.consequent.length > 0 || (!allowEmptyCase && hasBlankLinesBetween(node, nextToken))) && node.parent.cases[node.parent.cases.length - 1] !== node) { fallthroughCase = node; diff --git a/tools/node_modules/eslint/lib/rules/no-invalid-regexp.js b/tools/node_modules/eslint/lib/rules/no-invalid-regexp.js index 9a35743d122f9a..3c42a68e8a3ad0 100644 --- a/tools/node_modules/eslint/lib/rules/no-invalid-regexp.js +++ b/tools/node_modules/eslint/lib/rules/no-invalid-regexp.js @@ -10,7 +10,7 @@ const RegExpValidator = require("@eslint-community/regexpp").RegExpValidator; const validator = new RegExpValidator(); -const validFlags = /[dgimsuy]/gu; +const validFlags = /[dgimsuvy]/gu; const undefined1 = void 0; //------------------------------------------------------------------------------ @@ -108,12 +108,14 @@ module.exports = { /** * Check syntax error in a given pattern. * @param {string} pattern The RegExp pattern to validate. - * @param {boolean} uFlag The Unicode flag. + * @param {Object} flags The RegExp flags to validate. + * @param {boolean} [flags.unicode] The Unicode flag. + * @param {boolean} [flags.unicodeSets] The UnicodeSets flag. * @returns {string|null} The syntax error. */ - function validateRegExpPattern(pattern, uFlag) { + function validateRegExpPattern(pattern, flags) { try { - validator.validatePattern(pattern, undefined1, undefined1, uFlag); + validator.validatePattern(pattern, undefined1, undefined1, flags); return null; } catch (err) { return err.message; @@ -131,10 +133,19 @@ module.exports = { } try { validator.validateFlags(flags); - return null; } catch { return `Invalid flags supplied to RegExp constructor '${flags}'`; } + + /* + * `regexpp` checks the combination of `u` and `v` flags when parsing `Pattern` according to `ecma262`, + * but this rule may check only the flag when the pattern is unidentifiable, so check it here. + * https://tc39.es/ecma262/multipage/text-processing.html#sec-parsepattern + */ + if (flags.includes("u") && flags.includes("v")) { + return "Regex 'u' and 'v' flags cannot be used together"; + } + return null; } return { @@ -166,8 +177,12 @@ module.exports = { // If flags are unknown, report the regex only if its pattern is invalid both with and without the "u" flag flags === null - ? validateRegExpPattern(pattern, true) && validateRegExpPattern(pattern, false) - : validateRegExpPattern(pattern, flags.includes("u")) + ? ( + validateRegExpPattern(pattern, { unicode: true, unicodeSets: false }) && + validateRegExpPattern(pattern, { unicode: false, unicodeSets: true }) && + validateRegExpPattern(pattern, { unicode: false, unicodeSets: false }) + ) + : validateRegExpPattern(pattern, { unicode: flags.includes("u"), unicodeSets: flags.includes("v") }) ); if (message) { diff --git a/tools/node_modules/eslint/lib/rules/no-loop-func.js b/tools/node_modules/eslint/lib/rules/no-loop-func.js index e1d65fdc92c1aa..48312fbf58a521 100644 --- a/tools/node_modules/eslint/lib/rules/no-loop-func.js +++ b/tools/node_modules/eslint/lib/rules/no-loop-func.js @@ -186,7 +186,7 @@ module.exports = { } const references = sourceCode.getScope(node).through; - const unsafeRefs = references.filter(r => !isSafe(loopNode, r)).map(r => r.identifier.name); + const unsafeRefs = references.filter(r => r.resolved && !isSafe(loopNode, r)).map(r => r.identifier.name); if (unsafeRefs.length > 0) { context.report({ diff --git a/tools/node_modules/eslint/lib/rules/no-misleading-character-class.js b/tools/node_modules/eslint/lib/rules/no-misleading-character-class.js index 47ee84ec857d15..20591df2cc9952 100644 --- a/tools/node_modules/eslint/lib/rules/no-misleading-character-class.js +++ b/tools/node_modules/eslint/lib/rules/no-misleading-character-class.js @@ -13,30 +13,40 @@ const { isValidWithUnicodeFlag } = require("./utils/regular-expressions"); // Helpers //------------------------------------------------------------------------------ +/** + * @typedef {import('@eslint-community/regexpp').AST.Character} Character + * @typedef {import('@eslint-community/regexpp').AST.CharacterClassElement} CharacterClassElement + */ + /** * Iterate character sequences of a given nodes. * * CharacterClassRange syntax can steal a part of character sequence, * so this function reverts CharacterClassRange syntax and restore the sequence. - * @param {regexpp.AST.CharacterClassElement[]} nodes The node list to iterate character sequences. - * @returns {IterableIterator} The list of character sequences. + * @param {CharacterClassElement[]} nodes The node list to iterate character sequences. + * @returns {IterableIterator} The list of character sequences. */ function *iterateCharacterSequence(nodes) { + + /** @type {Character[]} */ let seq = []; for (const node of nodes) { switch (node.type) { case "Character": - seq.push(node.value); + seq.push(node); break; case "CharacterClassRange": - seq.push(node.min.value); + seq.push(node.min); yield seq; - seq = [node.max.value]; + seq = [node.max]; break; case "CharacterSet": + case "CharacterClass": // [[]] nesting character class + case "ClassStringDisjunction": // \q{...} + case "ExpressionCharacterClass": // [A--B] if (seq.length > 0) { yield seq; seq = []; @@ -52,32 +62,74 @@ function *iterateCharacterSequence(nodes) { } } + +/** + * Checks whether the given character node is a Unicode code point escape or not. + * @param {Character} char the character node to check. + * @returns {boolean} `true` if the character node is a Unicode code point escape. + */ +function isUnicodeCodePointEscape(char) { + return /^\\u\{[\da-f]+\}$/iu.test(char.raw); +} + +/** + * Each function returns `true` if it detects that kind of problem. + * @type {Record boolean>} + */ const hasCharacterSequence = { surrogatePairWithoutUFlag(chars) { - return chars.some((c, i) => i !== 0 && isSurrogatePair(chars[i - 1], c)); + return chars.some((c, i) => { + if (i === 0) { + return false; + } + const c1 = chars[i - 1]; + + return ( + isSurrogatePair(c1.value, c.value) && + !isUnicodeCodePointEscape(c1) && + !isUnicodeCodePointEscape(c) + ); + }); + }, + + surrogatePair(chars) { + return chars.some((c, i) => { + if (i === 0) { + return false; + } + const c1 = chars[i - 1]; + + return ( + isSurrogatePair(c1.value, c.value) && + ( + isUnicodeCodePointEscape(c1) || + isUnicodeCodePointEscape(c) + ) + ); + }); }, combiningClass(chars) { return chars.some((c, i) => ( i !== 0 && - isCombiningCharacter(c) && - !isCombiningCharacter(chars[i - 1]) + isCombiningCharacter(c.value) && + !isCombiningCharacter(chars[i - 1].value) )); }, emojiModifier(chars) { return chars.some((c, i) => ( i !== 0 && - isEmojiModifier(c) && - !isEmojiModifier(chars[i - 1]) + isEmojiModifier(c.value) && + !isEmojiModifier(chars[i - 1].value) )); }, regionalIndicatorSymbol(chars) { return chars.some((c, i) => ( i !== 0 && - isRegionalIndicatorSymbol(c) && - isRegionalIndicatorSymbol(chars[i - 1]) + isRegionalIndicatorSymbol(c.value) && + isRegionalIndicatorSymbol(chars[i - 1].value) )); }, @@ -87,9 +139,9 @@ const hasCharacterSequence = { return chars.some((c, i) => ( i !== 0 && i !== lastIndex && - c === 0x200d && - chars[i - 1] !== 0x200d && - chars[i + 1] !== 0x200d + c.value === 0x200d && + chars[i - 1].value !== 0x200d && + chars[i + 1].value !== 0x200d )); } }; @@ -117,6 +169,7 @@ module.exports = { messages: { surrogatePairWithoutUFlag: "Unexpected surrogate pair in character class. Use 'u' flag.", + surrogatePair: "Unexpected surrogate pair in character class.", combiningClass: "Unexpected combined character in character class.", emojiModifier: "Unexpected modified Emoji in character class.", regionalIndicatorSymbol: "Unexpected national flag in character class.", @@ -144,7 +197,10 @@ module.exports = { pattern, 0, pattern.length, - flags.includes("u") + { + unicode: flags.includes("u"), + unicodeSets: flags.includes("v") + } ); } catch { diff --git a/tools/node_modules/eslint/lib/rules/no-new-object.js b/tools/node_modules/eslint/lib/rules/no-new-object.js index 08a482be715034..06275f47125134 100644 --- a/tools/node_modules/eslint/lib/rules/no-new-object.js +++ b/tools/node_modules/eslint/lib/rules/no-new-object.js @@ -1,6 +1,7 @@ /** * @fileoverview A rule to disallow calls to the Object constructor * @author Matt DuVall + * @deprecated in ESLint v8.50.0 */ "use strict"; @@ -26,6 +27,12 @@ module.exports = { url: "https://eslint.org/docs/latest/rules/no-new-object" }, + deprecated: true, + + replacedBy: [ + "no-object-constructor" + ], + schema: [], messages: { diff --git a/tools/node_modules/eslint/lib/rules/no-new-wrappers.js b/tools/node_modules/eslint/lib/rules/no-new-wrappers.js index 9a12e1a3b5d279..5050a98a044a75 100644 --- a/tools/node_modules/eslint/lib/rules/no-new-wrappers.js +++ b/tools/node_modules/eslint/lib/rules/no-new-wrappers.js @@ -5,6 +5,12 @@ "use strict"; +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const { getVariableByName } = require("./utils/ast-utils"); + //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ @@ -28,18 +34,24 @@ module.exports = { }, create(context) { + const { sourceCode } = context; return { NewExpression(node) { const wrapperObjects = ["String", "Number", "Boolean"]; - - if (wrapperObjects.includes(node.callee.name)) { - context.report({ - node, - messageId: "noConstructor", - data: { fn: node.callee.name } - }); + const { name } = node.callee; + + if (wrapperObjects.includes(name)) { + const variable = getVariableByName(sourceCode.getScope(node), name); + + if (variable && variable.identifiers.length === 0) { + context.report({ + node, + messageId: "noConstructor", + data: { fn: name } + }); + } } } }; diff --git a/tools/node_modules/eslint/lib/rules/no-object-constructor.js b/tools/node_modules/eslint/lib/rules/no-object-constructor.js new file mode 100644 index 00000000000000..1299779f7ec843 --- /dev/null +++ b/tools/node_modules/eslint/lib/rules/no-object-constructor.js @@ -0,0 +1,118 @@ +/** + * @fileoverview Rule to disallow calls to the `Object` constructor without an argument + * @author Francesco Trotta + */ + +"use strict"; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const { getVariableByName, isArrowToken } = require("./utils/ast-utils"); + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +/** + * Tests if a node appears at the beginning of an ancestor ExpressionStatement node. + * @param {ASTNode} node The node to check. + * @returns {boolean} Whether the node appears at the beginning of an ancestor ExpressionStatement node. + */ +function isStartOfExpressionStatement(node) { + const start = node.range[0]; + let ancestor = node; + + while ((ancestor = ancestor.parent) && ancestor.range[0] === start) { + if (ancestor.type === "ExpressionStatement") { + return true; + } + } + return false; +} + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +/** @type {import('../shared/types').Rule} */ +module.exports = { + meta: { + type: "suggestion", + + docs: { + description: "Disallow calls to the `Object` constructor without an argument", + recommended: false, + url: "https://eslint.org/docs/latest/rules/no-object-constructor" + }, + + hasSuggestions: true, + + schema: [], + + messages: { + preferLiteral: "The object literal notation {} is preferable.", + useLiteral: "Replace with '{{replacement}}'." + } + }, + + create(context) { + + const sourceCode = context.sourceCode; + + /** + * Determines whether or not an object literal that replaces a specified node needs to be enclosed in parentheses. + * @param {ASTNode} node The node to be replaced. + * @returns {boolean} Whether or not parentheses around the object literal are required. + */ + function needsParentheses(node) { + if (isStartOfExpressionStatement(node)) { + return true; + } + + const prevToken = sourceCode.getTokenBefore(node); + + if (prevToken && isArrowToken(prevToken)) { + return true; + } + + return false; + } + + /** + * Reports on nodes where the `Object` constructor is called without arguments. + * @param {ASTNode} node The node to evaluate. + * @returns {void} + */ + function check(node) { + if (node.callee.type !== "Identifier" || node.callee.name !== "Object" || node.arguments.length) { + return; + } + + const variable = getVariableByName(sourceCode.getScope(node), "Object"); + + if (variable && variable.identifiers.length === 0) { + const replacement = needsParentheses(node) ? "({})" : "{}"; + + context.report({ + node, + messageId: "preferLiteral", + suggest: [ + { + messageId: "useLiteral", + data: { replacement }, + fix: fixer => fixer.replaceText(node, replacement) + } + ] + }); + } + } + + return { + CallExpression: check, + NewExpression: check + }; + + } +}; diff --git a/tools/node_modules/eslint/lib/rules/no-promise-executor-return.js b/tools/node_modules/eslint/lib/rules/no-promise-executor-return.js index d46a730e4746dc..e6ed7a22efc554 100644 --- a/tools/node_modules/eslint/lib/rules/no-promise-executor-return.js +++ b/tools/node_modules/eslint/lib/rules/no-promise-executor-return.js @@ -10,6 +10,7 @@ //------------------------------------------------------------------------------ const { findVariable } = require("@eslint-community/eslint-utils"); +const astUtils = require("./utils/ast-utils"); //------------------------------------------------------------------------------ // Helpers @@ -59,6 +60,78 @@ function isPromiseExecutor(node, scope) { isGlobalReference(parent.callee, getOuterScope(scope)); } +/** + * Checks if the given node is a void expression. + * @param {ASTNode} node The node to check. + * @returns {boolean} - `true` if the node is a void expression + */ +function expressionIsVoid(node) { + return node.type === "UnaryExpression" && node.operator === "void"; +} + +/** + * Fixes the linting error by prepending "void " to the given node + * @param {Object} sourceCode context given by context.sourceCode + * @param {ASTNode} node The node to fix. + * @param {Object} fixer The fixer object provided by ESLint. + * @returns {Array} - An array of fix objects to apply to the node. + */ +function voidPrependFixer(sourceCode, node, fixer) { + + const requiresParens = + + // prepending `void ` will fail if the node has a lower precedence than void + astUtils.getPrecedence(node) < astUtils.getPrecedence({ type: "UnaryExpression", operator: "void" }) && + + // check if there are parentheses around the node to avoid redundant parentheses + !astUtils.isParenthesised(sourceCode, node); + + // avoid parentheses issues + const returnOrArrowToken = sourceCode.getTokenBefore( + node, + node.parent.type === "ArrowFunctionExpression" + ? astUtils.isArrowToken + + // isReturnToken + : token => token.type === "Keyword" && token.value === "return" + ); + + const firstToken = sourceCode.getTokenAfter(returnOrArrowToken); + + const prependSpace = + + // is return token, as => allows void to be adjacent + returnOrArrowToken.value === "return" && + + // If two tokens (return and "(") are adjacent + returnOrArrowToken.range[1] === firstToken.range[0]; + + return [ + fixer.insertTextBefore(firstToken, `${prependSpace ? " " : ""}void ${requiresParens ? "(" : ""}`), + fixer.insertTextAfter(node, requiresParens ? ")" : "") + ]; +} + +/** + * Fixes the linting error by `wrapping {}` around the given node's body. + * @param {Object} sourceCode context given by context.sourceCode + * @param {ASTNode} node The node to fix. + * @param {Object} fixer The fixer object provided by ESLint. + * @returns {Array} - An array of fix objects to apply to the node. + */ +function curlyWrapFixer(sourceCode, node, fixer) { + + // https://github.com/eslint/eslint/pull/17282#issuecomment-1592795923 + const arrowToken = sourceCode.getTokenBefore(node.body, astUtils.isArrowToken); + const firstToken = sourceCode.getTokenAfter(arrowToken); + const lastToken = sourceCode.getLastToken(node); + + return [ + fixer.insertTextBefore(firstToken, "{"), + fixer.insertTextAfter(lastToken, "}") + ]; +} + //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ @@ -74,10 +147,27 @@ module.exports = { url: "https://eslint.org/docs/latest/rules/no-promise-executor-return" }, - schema: [], + hasSuggestions: true, + + schema: [{ + type: "object", + properties: { + allowVoid: { + type: "boolean", + default: false + } + }, + additionalProperties: false + }], messages: { - returnsValue: "Return values from promise executor functions cannot be read." + returnsValue: "Return values from promise executor functions cannot be read.", + + // arrow and function suggestions + prependVoid: "Prepend `void` to the expression.", + + // only arrow suggestions + wrapBraces: "Wrap the expression in `{}`." } }, @@ -85,26 +175,52 @@ module.exports = { let funcInfo = null; const sourceCode = context.sourceCode; - - /** - * Reports the given node. - * @param {ASTNode} node Node to report. - * @returns {void} - */ - function report(node) { - context.report({ node, messageId: "returnsValue" }); - } + const { + allowVoid = false + } = context.options[0] || {}; return { onCodePathStart(_, node) { funcInfo = { upper: funcInfo, - shouldCheck: functionTypesToCheck.has(node.type) && isPromiseExecutor(node, sourceCode.getScope(node)) + shouldCheck: + functionTypesToCheck.has(node.type) && + isPromiseExecutor(node, sourceCode.getScope(node)) }; - if (funcInfo.shouldCheck && node.type === "ArrowFunctionExpression" && node.expression) { - report(node.body); + if (// Is a Promise executor + funcInfo.shouldCheck && + node.type === "ArrowFunctionExpression" && + node.expression && + + // Except void + !(allowVoid && expressionIsVoid(node.body)) + ) { + const suggest = []; + + // prevent useless refactors + if (allowVoid) { + suggest.push({ + messageId: "prependVoid", + fix(fixer) { + return voidPrependFixer(sourceCode, node.body, fixer); + } + }); + } + + suggest.push({ + messageId: "wrapBraces", + fix(fixer) { + return curlyWrapFixer(sourceCode, node, fixer); + } + }); + + context.report({ + node: node.body, + messageId: "returnsValue", + suggest + }); } }, @@ -113,9 +229,31 @@ module.exports = { }, ReturnStatement(node) { - if (funcInfo.shouldCheck && node.argument) { - report(node); + if (!(funcInfo.shouldCheck && node.argument)) { + return; } + + // node is `return ` + if (!allowVoid) { + context.report({ node, messageId: "returnsValue" }); + return; + } + + if (expressionIsVoid(node.argument)) { + return; + } + + // allowVoid && !expressionIsVoid + context.report({ + node, + messageId: "returnsValue", + suggest: [{ + messageId: "prependVoid", + fix(fixer) { + return voidPrependFixer(sourceCode, node.argument, fixer); + } + }] + }); } }; } diff --git a/tools/node_modules/eslint/lib/rules/no-regex-spaces.js b/tools/node_modules/eslint/lib/rules/no-regex-spaces.js index e7fae6d405514b..cb250107289ac8 100644 --- a/tools/node_modules/eslint/lib/rules/no-regex-spaces.js +++ b/tools/node_modules/eslint/lib/rules/no-regex-spaces.js @@ -77,7 +77,7 @@ module.exports = { let regExpAST; try { - regExpAST = regExpParser.parsePattern(pattern, 0, pattern.length, flags.includes("u")); + regExpAST = regExpParser.parsePattern(pattern, 0, pattern.length, { unicode: flags.includes("u"), unicodeSets: flags.includes("v") }); } catch { // Ignore regular expressions with syntax errors @@ -155,13 +155,28 @@ module.exports = { const regExpVar = astUtils.getVariableByName(scope, "RegExp"); const shadowed = regExpVar && regExpVar.defs.length > 0; const patternNode = node.arguments[0]; - const flagsNode = node.arguments[1]; if (node.callee.type === "Identifier" && node.callee.name === "RegExp" && isString(patternNode) && !shadowed) { const pattern = patternNode.value; const rawPattern = patternNode.raw.slice(1, -1); const rawPatternStartRange = patternNode.range[0] + 1; - const flags = isString(flagsNode) ? flagsNode.value : ""; + let flags; + + if (node.arguments.length < 2) { + + // It has no flags. + flags = ""; + } else { + const flagsNode = node.arguments[1]; + + if (isString(flagsNode)) { + flags = flagsNode.value; + } else { + + // The flags cannot be determined. + return; + } + } checkRegex( node, diff --git a/tools/node_modules/eslint/lib/rules/no-return-await.js b/tools/node_modules/eslint/lib/rules/no-return-await.js index b5abf14c69b857..77abda0cadfd1b 100644 --- a/tools/node_modules/eslint/lib/rules/no-return-await.js +++ b/tools/node_modules/eslint/lib/rules/no-return-await.js @@ -1,6 +1,7 @@ /** * @fileoverview Disallows unnecessary `return await` * @author Jordan Harband + * @deprecated in ESLint v8.46.0 */ "use strict"; @@ -26,6 +27,10 @@ module.exports = { fixable: null, + deprecated: true, + + replacedBy: [], + schema: [ ], diff --git a/tools/node_modules/eslint/lib/rules/no-this-before-super.js b/tools/node_modules/eslint/lib/rules/no-this-before-super.js index 139bb6649d131f..f96d8ace81d27e 100644 --- a/tools/node_modules/eslint/lib/rules/no-this-before-super.js +++ b/tools/node_modules/eslint/lib/rules/no-this-before-super.js @@ -90,6 +90,21 @@ module.exports = { return Boolean(funcInfo && funcInfo.isConstructor && funcInfo.hasExtends); } + /** + * Determines if every segment in a set has been called. + * @param {Set} segments The segments to search. + * @returns {boolean} True if every segment has been called; false otherwise. + */ + function isEverySegmentCalled(segments) { + for (const segment of segments) { + if (!isCalled(segment)) { + return false; + } + } + + return true; + } + /** * Checks whether or not this is before `super()` is called. * @returns {boolean} `true` if this is before `super()` is called. @@ -97,7 +112,7 @@ module.exports = { function isBeforeCallOfSuper() { return ( isInConstructorOfDerivedClass() && - !funcInfo.codePath.currentSegments.every(isCalled) + !isEverySegmentCalled(funcInfo.currentSegments) ); } @@ -108,11 +123,9 @@ module.exports = { * @returns {void} */ function setInvalid(node) { - const segments = funcInfo.codePath.currentSegments; - - for (let i = 0; i < segments.length; ++i) { - const segment = segments[i]; + const segments = funcInfo.currentSegments; + for (const segment of segments) { if (segment.reachable) { segInfoMap[segment.id].invalidNodes.push(node); } @@ -124,11 +137,9 @@ module.exports = { * @returns {void} */ function setSuperCalled() { - const segments = funcInfo.codePath.currentSegments; - - for (let i = 0; i < segments.length; ++i) { - const segment = segments[i]; + const segments = funcInfo.currentSegments; + for (const segment of segments) { if (segment.reachable) { segInfoMap[segment.id].superCalled = true; } @@ -156,14 +167,16 @@ module.exports = { classNode.superClass && !astUtils.isNullOrUndefined(classNode.superClass) ), - codePath + codePath, + currentSegments: new Set() }; } else { funcInfo = { upper: funcInfo, isConstructor: false, hasExtends: false, - codePath + codePath, + currentSegments: new Set() }; } }, @@ -211,6 +224,8 @@ module.exports = { * @returns {void} */ onCodePathSegmentStart(segment) { + funcInfo.currentSegments.add(segment); + if (!isInConstructorOfDerivedClass()) { return; } @@ -225,6 +240,18 @@ module.exports = { }; }, + onUnreachableCodePathSegmentStart(segment) { + funcInfo.currentSegments.add(segment); + }, + + onUnreachableCodePathSegmentEnd(segment) { + funcInfo.currentSegments.delete(segment); + }, + + onCodePathSegmentEnd(segment) { + funcInfo.currentSegments.delete(segment); + }, + /** * Update information of the code path segment when a code path was * looped. diff --git a/tools/node_modules/eslint/lib/rules/no-unreachable-loop.js b/tools/node_modules/eslint/lib/rules/no-unreachable-loop.js index 1df764e17d87ef..577d39ac7c7adf 100644 --- a/tools/node_modules/eslint/lib/rules/no-unreachable-loop.js +++ b/tools/node_modules/eslint/lib/rules/no-unreachable-loop.js @@ -11,6 +11,22 @@ const allLoopTypes = ["WhileStatement", "DoWhileStatement", "ForStatement", "ForInStatement", "ForOfStatement"]; +/** + * Checks all segments in a set and returns true if any are reachable. + * @param {Set} segments The segments to check. + * @returns {boolean} True if any segment is reachable; false otherwise. + */ +function isAnySegmentReachable(segments) { + + for (const segment of segments) { + if (segment.reachable) { + return true; + } + } + + return false; +} + /** * Determines whether the given node is the first node in the code path to which a loop statement * 'loops' for the next iteration. @@ -90,29 +106,36 @@ module.exports = { loopsByTargetSegments = new Map(), loopsToReport = new Set(); - let currentCodePath = null; + const codePathSegments = []; + let currentCodePathSegments = new Set(); return { - onCodePathStart(codePath) { - currentCodePath = codePath; + + onCodePathStart() { + codePathSegments.push(currentCodePathSegments); + currentCodePathSegments = new Set(); }, onCodePathEnd() { - currentCodePath = currentCodePath.upper; + currentCodePathSegments = codePathSegments.pop(); }, - [loopSelector](node) { + onUnreachableCodePathSegmentStart(segment) { + currentCodePathSegments.add(segment); + }, - /** - * Ignore unreachable loop statements to avoid unnecessary complexity in the implementation, or false positives otherwise. - * For unreachable segments, the code path analysis does not raise events required for this implementation. - */ - if (currentCodePath.currentSegments.some(segment => segment.reachable)) { - loopsToReport.add(node); - } + onUnreachableCodePathSegmentEnd(segment) { + currentCodePathSegments.delete(segment); + }, + + onCodePathSegmentEnd(segment) { + currentCodePathSegments.delete(segment); }, onCodePathSegmentStart(segment, node) { + + currentCodePathSegments.add(segment); + if (isLoopingTarget(node)) { const loop = node.parent; @@ -140,6 +163,18 @@ module.exports = { } }, + [loopSelector](node) { + + /** + * Ignore unreachable loop statements to avoid unnecessary complexity in the implementation, or false positives otherwise. + * For unreachable segments, the code path analysis does not raise events required for this implementation. + */ + if (isAnySegmentReachable(currentCodePathSegments)) { + loopsToReport.add(node); + } + }, + + "Program:exit"() { loopsToReport.forEach( node => context.report({ node, messageId: "invalid" }) diff --git a/tools/node_modules/eslint/lib/rules/no-unreachable.js b/tools/node_modules/eslint/lib/rules/no-unreachable.js index 6216a73a23533d..0cf750e4251edb 100644 --- a/tools/node_modules/eslint/lib/rules/no-unreachable.js +++ b/tools/node_modules/eslint/lib/rules/no-unreachable.js @@ -24,12 +24,19 @@ function isInitialized(node) { } /** - * Checks whether or not a given code path segment is unreachable. - * @param {CodePathSegment} segment A CodePathSegment to check. - * @returns {boolean} `true` if the segment is unreachable. + * Checks all segments in a set and returns true if all are unreachable. + * @param {Set} segments The segments to check. + * @returns {boolean} True if all segments are unreachable; false otherwise. */ -function isUnreachable(segment) { - return !segment.reachable; +function areAllSegmentsUnreachable(segments) { + + for (const segment of segments) { + if (segment.reachable) { + return false; + } + } + + return true; } /** @@ -124,7 +131,6 @@ module.exports = { }, create(context) { - let currentCodePath = null; /** @type {ConstructorInfo | null} */ let constructorInfo = null; @@ -132,6 +138,12 @@ module.exports = { /** @type {ConsecutiveRange} */ const range = new ConsecutiveRange(context.sourceCode); + /** @type {Array>} */ + const codePathSegments = []; + + /** @type {Set} */ + let currentCodePathSegments = new Set(); + /** * Reports a given node if it's unreachable. * @param {ASTNode} node A statement node to report. @@ -140,7 +152,7 @@ module.exports = { function reportIfUnreachable(node) { let nextNode = null; - if (node && (node.type === "PropertyDefinition" || currentCodePath.currentSegments.every(isUnreachable))) { + if (node && (node.type === "PropertyDefinition" || areAllSegmentsUnreachable(currentCodePathSegments))) { // Store this statement to distinguish consecutive statements. if (range.isEmpty) { @@ -181,12 +193,29 @@ module.exports = { return { // Manages the current code path. - onCodePathStart(codePath) { - currentCodePath = codePath; + onCodePathStart() { + codePathSegments.push(currentCodePathSegments); + currentCodePathSegments = new Set(); }, onCodePathEnd() { - currentCodePath = currentCodePath.upper; + currentCodePathSegments = codePathSegments.pop(); + }, + + onUnreachableCodePathSegmentStart(segment) { + currentCodePathSegments.add(segment); + }, + + onUnreachableCodePathSegmentEnd(segment) { + currentCodePathSegments.delete(segment); + }, + + onCodePathSegmentEnd(segment) { + currentCodePathSegments.delete(segment); + }, + + onCodePathSegmentStart(segment) { + currentCodePathSegments.add(segment); }, // Registers for all statement nodes (excludes FunctionDeclaration). diff --git a/tools/node_modules/eslint/lib/rules/no-useless-backreference.js b/tools/node_modules/eslint/lib/rules/no-useless-backreference.js index c99ac411495833..7ca43c8b260777 100644 --- a/tools/node_modules/eslint/lib/rules/no-useless-backreference.js +++ b/tools/node_modules/eslint/lib/rules/no-useless-backreference.js @@ -95,7 +95,7 @@ module.exports = { let regExpAST; try { - regExpAST = parser.parsePattern(pattern, 0, pattern.length, flags.includes("u")); + regExpAST = parser.parsePattern(pattern, 0, pattern.length, { unicode: flags.includes("u"), unicodeSets: flags.includes("v") }); } catch { // Ignore regular expressions with syntax errors diff --git a/tools/node_modules/eslint/lib/rules/no-useless-escape.js b/tools/node_modules/eslint/lib/rules/no-useless-escape.js index 8304d915f9e55d..0e0f6f09f2c35f 100644 --- a/tools/node_modules/eslint/lib/rules/no-useless-escape.js +++ b/tools/node_modules/eslint/lib/rules/no-useless-escape.js @@ -6,7 +6,12 @@ "use strict"; const astUtils = require("./utils/ast-utils"); +const { RegExpParser, visitRegExpAST } = require("@eslint-community/regexpp"); +/** + * @typedef {import('@eslint-community/regexpp').AST.CharacterClass} CharacterClass + * @typedef {import('@eslint-community/regexpp').AST.ExpressionCharacterClass} ExpressionCharacterClass + */ //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ @@ -28,55 +33,17 @@ const VALID_STRING_ESCAPES = union(new Set("\\nrvtbfux"), astUtils.LINEBREAKS); const REGEX_GENERAL_ESCAPES = new Set("\\bcdDfnpPrsStvwWxu0123456789]"); const REGEX_NON_CHARCLASS_ESCAPES = union(REGEX_GENERAL_ESCAPES, new Set("^/.$*+?[{}|()Bk")); -/** - * Parses a regular expression into a list of characters with character class info. - * @param {string} regExpText The raw text used to create the regular expression - * @returns {Object[]} A list of characters, each with info on escaping and whether they're in a character class. - * @example - * - * parseRegExp("a\\b[cd-]"); - * - * // returns: - * [ - * { text: "a", index: 0, escaped: false, inCharClass: false, startsCharClass: false, endsCharClass: false }, - * { text: "b", index: 2, escaped: true, inCharClass: false, startsCharClass: false, endsCharClass: false }, - * { text: "c", index: 4, escaped: false, inCharClass: true, startsCharClass: true, endsCharClass: false }, - * { text: "d", index: 5, escaped: false, inCharClass: true, startsCharClass: false, endsCharClass: false }, - * { text: "-", index: 6, escaped: false, inCharClass: true, startsCharClass: false, endsCharClass: false } - * ]; - * +/* + * Set of characters that require escaping in character classes in `unicodeSets` mode. + * ( ) [ ] { } / - \ | are ClassSetSyntaxCharacter */ -function parseRegExp(regExpText) { - const charList = []; +const REGEX_CLASSSET_CHARACTER_ESCAPES = union(REGEX_GENERAL_ESCAPES, new Set("q/[{}|()-")); - regExpText.split("").reduce((state, char, index) => { - if (!state.escapeNextChar) { - if (char === "\\") { - return Object.assign(state, { escapeNextChar: true }); - } - if (char === "[" && !state.inCharClass) { - return Object.assign(state, { inCharClass: true, startingCharClass: true }); - } - if (char === "]" && state.inCharClass) { - if (charList.length && charList[charList.length - 1].inCharClass) { - charList[charList.length - 1].endsCharClass = true; - } - return Object.assign(state, { inCharClass: false, startingCharClass: false }); - } - } - charList.push({ - text: char, - index, - escaped: state.escapeNextChar, - inCharClass: state.inCharClass, - startsCharClass: state.startingCharClass, - endsCharClass: false - }); - return Object.assign(state, { escapeNextChar: false, startingCharClass: false }); - }, { escapeNextChar: false, inCharClass: false, startingCharClass: false }); - - return charList; -} +/* + * A single character set of ClassSetReservedDoublePunctuator. + * && !! ## $$ %% ** ++ ,, .. :: ;; << == >> ?? @@ ^^ `` ~~ are ClassSetReservedDoublePunctuator + */ +const REGEX_CLASS_SET_RESERVED_DOUBLE_PUNCTUATOR = new Set("!#$%&*+,.:;<=>?@^`~"); /** @type {import('../shared/types').Rule} */ module.exports = { @@ -94,6 +61,7 @@ module.exports = { messages: { unnecessaryEscape: "Unnecessary escape character: \\{{character}}.", removeEscape: "Remove the `\\`. This maintains the current functionality.", + removeEscapeDoNotKeepSemantics: "Remove the `\\` if it was inserted by mistake.", escapeBackslash: "Replace the `\\` with `\\\\` to include the actual backslash character." }, @@ -102,15 +70,17 @@ module.exports = { create(context) { const sourceCode = context.sourceCode; + const parser = new RegExpParser(); /** * Reports a node * @param {ASTNode} node The node to report * @param {number} startOffset The backslash's offset from the start of the node * @param {string} character The uselessly escaped character (not including the backslash) + * @param {boolean} [disableEscapeBackslashSuggest] `true` if escapeBackslash suggestion should be turned off. * @returns {void} */ - function report(node, startOffset, character) { + function report(node, startOffset, character, disableEscapeBackslashSuggest) { const rangeStart = node.range[0] + startOffset; const range = [rangeStart, rangeStart + 1]; const start = sourceCode.getLocFromIndex(rangeStart); @@ -125,17 +95,24 @@ module.exports = { data: { character }, suggest: [ { - messageId: "removeEscape", + + // Removing unnecessary `\` characters in a directive is not guaranteed to maintain functionality. + messageId: astUtils.isDirective(node.parent) + ? "removeEscapeDoNotKeepSemantics" : "removeEscape", fix(fixer) { return fixer.removeRange(range); } }, - { - messageId: "escapeBackslash", - fix(fixer) { - return fixer.insertTextBeforeRange(range, "\\"); - } - } + ...disableEscapeBackslashSuggest + ? [] + : [ + { + messageId: "escapeBackslash", + fix(fixer) { + return fixer.insertTextBeforeRange(range, "\\"); + } + } + ] ] }); } @@ -178,6 +155,133 @@ module.exports = { } } + /** + * Checks if the escape character in given regexp is unnecessary. + * @private + * @param {ASTNode} node node to validate. + * @returns {void} + */ + function validateRegExp(node) { + const { pattern, flags } = node.regex; + let patternNode; + const unicode = flags.includes("u"); + const unicodeSets = flags.includes("v"); + + try { + patternNode = parser.parsePattern(pattern, 0, pattern.length, { unicode, unicodeSets }); + } catch { + + // Ignore regular expressions with syntax errors + return; + } + + /** @type {(CharacterClass | ExpressionCharacterClass)[]} */ + const characterClassStack = []; + + visitRegExpAST(patternNode, { + onCharacterClassEnter: characterClassNode => characterClassStack.unshift(characterClassNode), + onCharacterClassLeave: () => characterClassStack.shift(), + onExpressionCharacterClassEnter: characterClassNode => characterClassStack.unshift(characterClassNode), + onExpressionCharacterClassLeave: () => characterClassStack.shift(), + onCharacterEnter(characterNode) { + if (!characterNode.raw.startsWith("\\")) { + + // It's not an escaped character. + return; + } + + const escapedChar = characterNode.raw.slice(1); + + if (escapedChar !== String.fromCodePoint(characterNode.value)) { + + // It's a valid escape. + return; + } + let allowedEscapes; + + if (characterClassStack.length) { + allowedEscapes = unicodeSets ? REGEX_CLASSSET_CHARACTER_ESCAPES : REGEX_GENERAL_ESCAPES; + } else { + allowedEscapes = REGEX_NON_CHARCLASS_ESCAPES; + } + if (allowedEscapes.has(escapedChar)) { + return; + } + + const reportedIndex = characterNode.start + 1; + let disableEscapeBackslashSuggest = false; + + if (characterClassStack.length) { + const characterClassNode = characterClassStack[0]; + + if (escapedChar === "^") { + + /* + * The '^' character is also a special case; it must always be escaped outside of character classes, but + * it only needs to be escaped in character classes if it's at the beginning of the character class. To + * account for this, consider it to be a valid escape character outside of character classes, and filter + * out '^' characters that appear at the start of a character class. + */ + if (characterClassNode.start + 1 === characterNode.start) { + + return; + } + } + if (!unicodeSets) { + if (escapedChar === "-") { + + /* + * The '-' character is a special case, because it's only valid to escape it if it's in a character + * class, and is not at either edge of the character class. To account for this, don't consider '-' + * characters to be valid in general, and filter out '-' characters that appear in the middle of a + * character class. + */ + if (characterClassNode.start + 1 !== characterNode.start && characterNode.end !== characterClassNode.end - 1) { + + return; + } + } + } else { // unicodeSets mode + if (REGEX_CLASS_SET_RESERVED_DOUBLE_PUNCTUATOR.has(escapedChar)) { + + // Escaping is valid if it is a ClassSetReservedDoublePunctuator. + if (pattern[characterNode.end] === escapedChar) { + return; + } + if (pattern[characterNode.start - 1] === escapedChar) { + if (escapedChar !== "^") { + return; + } + + // If the previous character is a `negate` caret(`^`), escape to caret is unnecessary. + + if (!characterClassNode.negate) { + return; + } + const negateCaretIndex = characterClassNode.start + 1; + + if (negateCaretIndex < characterNode.start - 1) { + return; + } + } + } + + if (characterNode.parent.type === "ClassIntersection" || characterNode.parent.type === "ClassSubtraction") { + disableEscapeBackslashSuggest = true; + } + } + } + + report( + node, + reportedIndex, + escapedChar, + disableEscapeBackslashSuggest + ); + } + }); + } + /** * Checks if a node has an escape. * @param {ASTNode} node node to check. @@ -216,32 +320,7 @@ module.exports = { validateString(node, match); } } else if (node.regex) { - parseRegExp(node.regex.pattern) - - /* - * The '-' character is a special case, because it's only valid to escape it if it's in a character - * class, and is not at either edge of the character class. To account for this, don't consider '-' - * characters to be valid in general, and filter out '-' characters that appear in the middle of a - * character class. - */ - .filter(charInfo => !(charInfo.text === "-" && charInfo.inCharClass && !charInfo.startsCharClass && !charInfo.endsCharClass)) - - /* - * The '^' character is also a special case; it must always be escaped outside of character classes, but - * it only needs to be escaped in character classes if it's at the beginning of the character class. To - * account for this, consider it to be a valid escape character outside of character classes, and filter - * out '^' characters that appear at the start of a character class. - */ - .filter(charInfo => !(charInfo.text === "^" && charInfo.startsCharClass)) - - // Filter out characters that aren't escaped. - .filter(charInfo => charInfo.escaped) - - // Filter out characters that are valid to escape, based on their position in the regular expression. - .filter(charInfo => !(charInfo.inCharClass ? REGEX_GENERAL_ESCAPES : REGEX_NON_CHARCLASS_ESCAPES).has(charInfo.text)) - - // Report all the remaining characters. - .forEach(charInfo => report(node, charInfo.index, charInfo.text)); + validateRegExp(node); } } diff --git a/tools/node_modules/eslint/lib/rules/no-useless-return.js b/tools/node_modules/eslint/lib/rules/no-useless-return.js index f89523153d475b..81d61051053e87 100644 --- a/tools/node_modules/eslint/lib/rules/no-useless-return.js +++ b/tools/node_modules/eslint/lib/rules/no-useless-return.js @@ -57,6 +57,22 @@ function isInFinally(node) { return false; } +/** + * Checks all segments in a set and returns true if any are reachable. + * @param {Set} segments The segments to check. + * @returns {boolean} True if any segment is reachable; false otherwise. + */ +function isAnySegmentReachable(segments) { + + for (const segment of segments) { + if (segment.reachable) { + return true; + } + } + + return false; +} + //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ @@ -205,7 +221,6 @@ module.exports = { */ function markReturnStatementsOnCurrentSegmentsAsUsed() { scopeInfo - .codePath .currentSegments .forEach(segment => markReturnStatementsOnSegmentAsUsed(segment, new Set())); } @@ -222,7 +237,8 @@ module.exports = { upper: scopeInfo, uselessReturns: [], traversedTryBlockStatements: [], - codePath + codePath, + currentSegments: new Set() }; }, @@ -259,6 +275,9 @@ module.exports = { * NOTE: This event is notified for only reachable segments. */ onCodePathSegmentStart(segment) { + + scopeInfo.currentSegments.add(segment); + const info = { uselessReturns: getUselessReturns([], segment.allPrevSegments), returned: false @@ -268,6 +287,18 @@ module.exports = { segmentInfoMap.set(segment, info); }, + onUnreachableCodePathSegmentStart(segment) { + scopeInfo.currentSegments.add(segment); + }, + + onUnreachableCodePathSegmentEnd(segment) { + scopeInfo.currentSegments.delete(segment); + }, + + onCodePathSegmentEnd(segment) { + scopeInfo.currentSegments.delete(segment); + }, + // Adds ReturnStatement node to check whether it's useless or not. ReturnStatement(node) { if (node.argument) { @@ -279,12 +310,12 @@ module.exports = { isInFinally(node) || // Ignore `return` statements in unreachable places (https://github.com/eslint/eslint/issues/11647). - !scopeInfo.codePath.currentSegments.some(s => s.reachable) + !isAnySegmentReachable(scopeInfo.currentSegments) ) { return; } - for (const segment of scopeInfo.codePath.currentSegments) { + for (const segment of scopeInfo.currentSegments) { const info = segmentInfoMap.get(segment); if (info) { diff --git a/tools/node_modules/eslint/lib/rules/padding-line-between-statements.js b/tools/node_modules/eslint/lib/rules/padding-line-between-statements.js index 6b165c07f27382..95e08736a9c56f 100644 --- a/tools/node_modules/eslint/lib/rules/padding-line-between-statements.js +++ b/tools/node_modules/eslint/lib/rules/padding-line-between-statements.js @@ -130,42 +130,6 @@ function isBlockLikeStatement(sourceCode, node) { ); } -/** - * Check whether the given node is a directive or not. - * @param {ASTNode} node The node to check. - * @param {SourceCode} sourceCode The source code object to get tokens. - * @returns {boolean} `true` if the node is a directive. - */ -function isDirective(node, sourceCode) { - return ( - astUtils.isTopLevelExpressionStatement(node) && - node.expression.type === "Literal" && - typeof node.expression.value === "string" && - !astUtils.isParenthesised(sourceCode, node.expression) - ); -} - -/** - * Check whether the given node is a part of directive prologue or not. - * @param {ASTNode} node The node to check. - * @param {SourceCode} sourceCode The source code object to get tokens. - * @returns {boolean} `true` if the node is a part of directive prologue. - */ -function isDirectivePrologue(node, sourceCode) { - if (isDirective(node, sourceCode)) { - for (const sibling of node.parent.body) { - if (sibling === node) { - break; - } - if (!isDirective(sibling, sourceCode)) { - return false; - } - } - return true; - } - return false; -} - /** * Gets the actual last token. * @@ -359,12 +323,10 @@ const StatementTypes = { CJS_IMPORT.test(sourceCode.getText(node.declarations[0].init)) }, directive: { - test: isDirectivePrologue + test: astUtils.isDirective }, expression: { - test: (node, sourceCode) => - node.type === "ExpressionStatement" && - !isDirectivePrologue(node, sourceCode) + test: node => node.type === "ExpressionStatement" && !astUtils.isDirective(node) }, iife: { test: isIIFEStatement @@ -375,10 +337,10 @@ const StatementTypes = { isBlockLikeStatement(sourceCode, node) }, "multiline-expression": { - test: (node, sourceCode) => + test: node => node.loc.start.line !== node.loc.end.line && node.type === "ExpressionStatement" && - !isDirectivePrologue(node, sourceCode) + !astUtils.isDirective(node) }, "multiline-const": newMultilineKeywordTester("const"), diff --git a/tools/node_modules/eslint/lib/rules/prefer-named-capture-group.js b/tools/node_modules/eslint/lib/rules/prefer-named-capture-group.js index 8fb68de1794e7e..a82ee1f78356df 100644 --- a/tools/node_modules/eslint/lib/rules/prefer-named-capture-group.js +++ b/tools/node_modules/eslint/lib/rules/prefer-named-capture-group.js @@ -112,14 +112,17 @@ module.exports = { * @param {string} pattern The regular expression pattern to be checked. * @param {ASTNode} node AST node which contains the regular expression or a call/new expression. * @param {ASTNode} regexNode AST node which contains the regular expression. - * @param {boolean} uFlag Flag indicates whether unicode mode is enabled or not. + * @param {string|null} flags The regular expression flags to be checked. * @returns {void} */ - function checkRegex(pattern, node, regexNode, uFlag) { + function checkRegex(pattern, node, regexNode, flags) { let ast; try { - ast = parser.parsePattern(pattern, 0, pattern.length, uFlag); + ast = parser.parsePattern(pattern, 0, pattern.length, { + unicode: Boolean(flags && flags.includes("u")), + unicodeSets: Boolean(flags && flags.includes("v")) + }); } catch { // ignore regex syntax errors @@ -148,7 +151,7 @@ module.exports = { return { Literal(node) { if (node.regex) { - checkRegex(node.regex.pattern, node, node, node.regex.flags.includes("u")); + checkRegex(node.regex.pattern, node, node, node.regex.flags); } }, Program(node) { @@ -166,7 +169,7 @@ module.exports = { const flags = getStringIfConstant(refNode.arguments[1]); if (regex) { - checkRegex(regex, refNode, refNode.arguments[0], flags && flags.includes("u")); + checkRegex(regex, refNode, refNode.arguments[0], flags); } } } diff --git a/tools/node_modules/eslint/lib/rules/prefer-regex-literals.js b/tools/node_modules/eslint/lib/rules/prefer-regex-literals.js index eca805483f4a00..ffaaeac3f2792a 100644 --- a/tools/node_modules/eslint/lib/rules/prefer-regex-literals.js +++ b/tools/node_modules/eslint/lib/rules/prefer-regex-literals.js @@ -241,7 +241,7 @@ module.exports = { /** * Returns a ecmaVersion compatible for regexpp. * @param {number} ecmaVersion The ecmaVersion to convert. - * @returns {import("regexpp/ecma-versions").EcmaVersion} The resulting ecmaVersion compatible for regexpp. + * @returns {import("@eslint-community/regexpp/ecma-versions").EcmaVersion} The resulting ecmaVersion compatible for regexpp. */ function getRegexppEcmaVersion(ecmaVersion) { if (ecmaVersion <= 5) { @@ -297,7 +297,10 @@ module.exports = { const validator = new RegExpValidator({ ecmaVersion: regexppEcmaVersion }); try { - validator.validatePattern(pattern, 0, pattern.length, flags ? flags.includes("u") : false); + validator.validatePattern(pattern, 0, pattern.length, { + unicode: flags ? flags.includes("u") : false, + unicodeSets: flags ? flags.includes("v") : false + }); if (flags) { validator.validateFlags(flags); } @@ -461,7 +464,10 @@ module.exports = { if (regexContent && !noFix) { let charIncrease = 0; - const ast = new RegExpParser({ ecmaVersion: regexppEcmaVersion }).parsePattern(regexContent, 0, regexContent.length, flags ? flags.includes("u") : false); + const ast = new RegExpParser({ ecmaVersion: regexppEcmaVersion }).parsePattern(regexContent, 0, regexContent.length, { + unicode: flags ? flags.includes("u") : false, + unicodeSets: flags ? flags.includes("v") : false + }); visitRegExpAST(ast, { onCharacterEnter(characterNode) { diff --git a/tools/node_modules/eslint/lib/rules/require-atomic-updates.js b/tools/node_modules/eslint/lib/rules/require-atomic-updates.js index ba369a203e7dfe..7e397ceb1cfd87 100644 --- a/tools/node_modules/eslint/lib/rules/require-atomic-updates.js +++ b/tools/node_modules/eslint/lib/rules/require-atomic-updates.js @@ -213,7 +213,8 @@ module.exports = { stack = { upper: stack, codePath, - referenceMap: shouldVerify ? createReferenceMap(scope) : null + referenceMap: shouldVerify ? createReferenceMap(scope) : null, + currentSegments: new Set() }; }, onCodePathEnd() { @@ -223,11 +224,25 @@ module.exports = { // Initialize the segment information. onCodePathSegmentStart(segment) { segmentInfo.initialize(segment); + stack.currentSegments.add(segment); }, + onUnreachableCodePathSegmentStart(segment) { + stack.currentSegments.add(segment); + }, + + onUnreachableCodePathSegmentEnd(segment) { + stack.currentSegments.delete(segment); + }, + + onCodePathSegmentEnd(segment) { + stack.currentSegments.delete(segment); + }, + + // Handle references to prepare verification. Identifier(node) { - const { codePath, referenceMap } = stack; + const { referenceMap } = stack; const reference = referenceMap && referenceMap.get(node); // Ignore if this is not a valid variable reference. @@ -240,7 +255,7 @@ module.exports = { // Add a fresh read variable. if (reference.isRead() && !(writeExpr && writeExpr.parent.operator === "=")) { - segmentInfo.markAsRead(codePath.currentSegments, variable); + segmentInfo.markAsRead(stack.currentSegments, variable); } /* @@ -267,16 +282,15 @@ module.exports = { * If the reference exists in `outdatedReadVariables` list, report it. */ ":expression:exit"(node) { - const { codePath, referenceMap } = stack; // referenceMap exists if this is in a resumable function scope. - if (!referenceMap) { + if (!stack.referenceMap) { return; } // Mark the read variables on this code path as outdated. if (node.type === "AwaitExpression" || node.type === "YieldExpression") { - segmentInfo.makeOutdated(codePath.currentSegments); + segmentInfo.makeOutdated(stack.currentSegments); } // Verify. @@ -288,7 +302,7 @@ module.exports = { for (const reference of references) { const variable = reference.resolved; - if (segmentInfo.isOutdated(codePath.currentSegments, variable)) { + if (segmentInfo.isOutdated(stack.currentSegments, variable)) { if (node.parent.left === reference.identifier) { context.report({ node: node.parent, diff --git a/tools/node_modules/eslint/lib/rules/require-unicode-regexp.js b/tools/node_modules/eslint/lib/rules/require-unicode-regexp.js index f748753a2d13e6..dd687f8d796b24 100644 --- a/tools/node_modules/eslint/lib/rules/require-unicode-regexp.js +++ b/tools/node_modules/eslint/lib/rules/require-unicode-regexp.js @@ -28,7 +28,7 @@ module.exports = { type: "suggestion", docs: { - description: "Enforce the use of `u` flag on RegExp", + description: "Enforce the use of `u` or `v` flag on RegExp", recommended: false, url: "https://eslint.org/docs/latest/rules/require-unicode-regexp" }, @@ -51,7 +51,7 @@ module.exports = { "Literal[regex]"(node) { const flags = node.regex.flags || ""; - if (!flags.includes("u")) { + if (!flags.includes("u") && !flags.includes("v")) { context.report({ messageId: "requireUFlag", node, @@ -85,7 +85,7 @@ module.exports = { const pattern = getStringIfConstant(patternNode, scope); const flags = getStringIfConstant(flagsNode, scope); - if (!flagsNode || (typeof flags === "string" && !flags.includes("u"))) { + if (!flagsNode || (typeof flags === "string" && !flags.includes("u") && !flags.includes("v"))) { context.report({ messageId: "requireUFlag", node: refNode, diff --git a/tools/node_modules/eslint/lib/rules/utils/ast-utils.js b/tools/node_modules/eslint/lib/rules/utils/ast-utils.js index e8ed3edd85c809..bebb4d5168b30b 100644 --- a/tools/node_modules/eslint/lib/rules/utils/ast-utils.js +++ b/tools/node_modules/eslint/lib/rules/utils/ast-utils.js @@ -26,8 +26,8 @@ const { const anyFunctionPattern = /^(?:Function(?:Declaration|Expression)|ArrowFunctionExpression)$/u; const anyLoopPattern = /^(?:DoWhile|For|ForIn|ForOf|While)Statement$/u; +const arrayMethodWithThisArgPattern = /^(?:every|filter|find(?:Last)?(?:Index)?|flatMap|forEach|map|some)$/u; const arrayOrTypedArrayPattern = /Array$/u; -const arrayMethodPattern = /^(?:every|filter|find|findIndex|forEach|map|some)$/u; const bindOrCallOrApplyPattern = /^(?:bind|call|apply)$/u; const thisTagPattern = /^[\s*]*@this/mu; @@ -467,12 +467,12 @@ function isArrayFromMethod(node) { } /** - * Checks whether or not a node is a method which has `thisArg`. + * Checks whether or not a node is a method which expects a function as a first argument, and `thisArg` as a second argument. * @param {ASTNode} node A node to check. - * @returns {boolean} Whether or not the node is a method which has `thisArg`. + * @returns {boolean} Whether or not the node is a method which expects a function as a first argument, and `thisArg` as a second argument. */ function isMethodWhichHasThisArg(node) { - return isSpecificMemberAccess(node, null, arrayMethodPattern); + return isSpecificMemberAccess(node, null, arrayMethodWithThisArgPattern); } /** @@ -1006,6 +1006,15 @@ function isTopLevelExpressionStatement(node) { } +/** + * Check whether the given node is a part of a directive prologue or not. + * @param {ASTNode} node The node to check. + * @returns {boolean} `true` if the node is a part of directive prologue. + */ +function isDirective(node) { + return node.type === "ExpressionStatement" && typeof node.directive === "string"; +} + //------------------------------------------------------------------------------ // Public Interface //------------------------------------------------------------------------------ @@ -2158,5 +2167,6 @@ module.exports = { getSwitchCaseColonToken, getModuleExportName, isConstant, - isTopLevelExpressionStatement + isTopLevelExpressionStatement, + isDirective }; diff --git a/tools/node_modules/eslint/lib/rules/utils/regular-expressions.js b/tools/node_modules/eslint/lib/rules/utils/regular-expressions.js index 234a1cb8b1140b..12e544e379de63 100644 --- a/tools/node_modules/eslint/lib/rules/utils/regular-expressions.js +++ b/tools/node_modules/eslint/lib/rules/utils/regular-expressions.js @@ -8,7 +8,7 @@ const { RegExpValidator } = require("@eslint-community/regexpp"); -const REGEXPP_LATEST_ECMA_VERSION = 2022; +const REGEXPP_LATEST_ECMA_VERSION = 2024; /** * Checks if the given regular expression pattern would be valid with the `u` flag. @@ -28,7 +28,7 @@ function isValidWithUnicodeFlag(ecmaVersion, pattern) { }); try { - validator.validatePattern(pattern, void 0, void 0, /* uFlag = */ true); + validator.validatePattern(pattern, void 0, void 0, { unicode: /* uFlag = */ true }); } catch { return false; } diff --git a/tools/node_modules/eslint/lib/source-code/source-code.js b/tools/node_modules/eslint/lib/source-code/source-code.js index 07c0d294829989..4bbd5ae3a5cb69 100644 --- a/tools/node_modules/eslint/lib/source-code/source-code.js +++ b/tools/node_modules/eslint/lib/source-code/source-code.js @@ -12,7 +12,15 @@ const { isCommentToken } = require("@eslint-community/eslint-utils"), TokenStore = require("./token-store"), astUtils = require("../shared/ast-utils"), - Traverser = require("../shared/traverser"); + Traverser = require("../shared/traverser"), + globals = require("../../conf/globals"), + { + directivesPattern + } = require("../shared/directives"), + + /* eslint-disable-next-line n/no-restricted-require -- Too messy to figure out right now. */ + ConfigCommentParser = require("../linter/config-comment-parser"), + eslintScope = require("eslint-scope"); //------------------------------------------------------------------------------ // Type Definitions @@ -24,6 +32,8 @@ const // Private //------------------------------------------------------------------------------ +const commentParser = new ConfigCommentParser(); + /** * Validates that the given AST has the required information. * @param {ASTNode} ast The Program node of the AST to check. @@ -49,6 +59,29 @@ function validate(ast) { } } +/** + * Retrieves globals for the given ecmaVersion. + * @param {number} ecmaVersion The version to retrieve globals for. + * @returns {Object} The globals for the given ecmaVersion. + */ +function getGlobalsForEcmaVersion(ecmaVersion) { + + switch (ecmaVersion) { + case 3: + return globals.es3; + + case 5: + return globals.es5; + + default: + if (ecmaVersion < 2015) { + return globals[`es${ecmaVersion + 2009}`]; + } + + return globals[`es${ecmaVersion}`]; + } +} + /** * Check to see if its a ES6 export declaration. * @param {ASTNode} astNode An AST node. @@ -83,6 +116,36 @@ function sortedMerge(tokens, comments) { return result; } +/** + * Normalizes a value for a global in a config + * @param {(boolean|string|null)} configuredValue The value given for a global in configuration or in + * a global directive comment + * @returns {("readable"|"writeable"|"off")} The value normalized as a string + * @throws Error if global value is invalid + */ +function normalizeConfigGlobal(configuredValue) { + switch (configuredValue) { + case "off": + return "off"; + + case true: + case "true": + case "writeable": + case "writable": + return "writable"; + + case null: + case false: + case "false": + case "readable": + case "readonly": + return "readonly"; + + default: + throw new Error(`'${configuredValue}' is not a valid configuration for a global (use 'readonly', 'writable', or 'off')`); + } +} + /** * Determines if two nodes or tokens overlap. * @param {ASTNode|Token} first The first node or token to check. @@ -145,6 +208,116 @@ function isSpaceBetween(sourceCode, first, second, checkInsideOfJSXText) { return false; } +//----------------------------------------------------------------------------- +// Directive Comments +//----------------------------------------------------------------------------- + +/** + * Extract the directive and the justification from a given directive comment and trim them. + * @param {string} value The comment text to extract. + * @returns {{directivePart: string, justificationPart: string}} The extracted directive and justification. + */ +function extractDirectiveComment(value) { + const match = /\s-{2,}\s/u.exec(value); + + if (!match) { + return { directivePart: value.trim(), justificationPart: "" }; + } + + const directive = value.slice(0, match.index).trim(); + const justification = value.slice(match.index + match[0].length).trim(); + + return { directivePart: directive, justificationPart: justification }; +} + +/** + * Ensures that variables representing built-in properties of the Global Object, + * and any globals declared by special block comments, are present in the global + * scope. + * @param {Scope} globalScope The global scope. + * @param {Object|undefined} configGlobals The globals declared in configuration + * @param {Object|undefined} inlineGlobals The globals declared in the source code + * @returns {void} + */ +function addDeclaredGlobals(globalScope, configGlobals = {}, inlineGlobals = {}) { + + // Define configured global variables. + for (const id of new Set([...Object.keys(configGlobals), ...Object.keys(inlineGlobals)])) { + + /* + * `normalizeConfigGlobal` will throw an error if a configured global value is invalid. However, these errors would + * typically be caught when validating a config anyway (validity for inline global comments is checked separately). + */ + const configValue = configGlobals[id] === void 0 ? void 0 : normalizeConfigGlobal(configGlobals[id]); + const commentValue = inlineGlobals[id] && inlineGlobals[id].value; + const value = commentValue || configValue; + const sourceComments = inlineGlobals[id] && inlineGlobals[id].comments; + + if (value === "off") { + continue; + } + + let variable = globalScope.set.get(id); + + if (!variable) { + variable = new eslintScope.Variable(id, globalScope); + + globalScope.variables.push(variable); + globalScope.set.set(id, variable); + } + + variable.eslintImplicitGlobalSetting = configValue; + variable.eslintExplicitGlobal = sourceComments !== void 0; + variable.eslintExplicitGlobalComments = sourceComments; + variable.writeable = (value === "writable"); + } + + /* + * "through" contains all references which definitions cannot be found. + * Since we augment the global scope using configuration, we need to update + * references and remove the ones that were added by configuration. + */ + globalScope.through = globalScope.through.filter(reference => { + const name = reference.identifier.name; + const variable = globalScope.set.get(name); + + if (variable) { + + /* + * Links the variable and the reference. + * And this reference is removed from `Scope#through`. + */ + reference.resolved = variable; + variable.references.push(reference); + + return false; + } + + return true; + }); +} + +/** + * Sets the given variable names as exported so they won't be triggered by + * the `no-unused-vars` rule. + * @param {eslint.Scope} globalScope The global scope to define exports in. + * @param {Record} variables An object whose keys are the variable + * names to export. + * @returns {void} + */ +function markExportedVariables(globalScope, variables) { + + Object.keys(variables).forEach(name => { + const variable = globalScope.set.get(name); + + if (variable) { + variable.eslintUsed = true; + variable.eslintExported = true; + } + }); + +} + //------------------------------------------------------------------------------ // Public Interface //------------------------------------------------------------------------------ @@ -187,7 +360,9 @@ class SourceCode extends TokenStore { * General purpose caching for the class. */ this[caches] = new Map([ - ["scopes", new WeakMap()] + ["scopes", new WeakMap()], + ["vars", new Map()], + ["configNodes", void 0] ]); /** @@ -266,7 +441,7 @@ class SourceCode extends TokenStore { // Cache for comments found using getComments(). this._commentCache = new WeakMap(); - // don't allow modification of this object + // don't allow further modification of this object Object.freeze(this); Object.freeze(this.lines); } @@ -724,6 +899,178 @@ class SourceCode extends TokenStore { } + /** + * Returns an array of all inline configuration nodes found in the + * source code. + * @returns {Array} An array of all inline configuration nodes. + */ + getInlineConfigNodes() { + + // check the cache first + let configNodes = this[caches].get("configNodes"); + + if (configNodes) { + return configNodes; + } + + // calculate fresh config nodes + configNodes = this.ast.comments.filter(comment => { + + // shebang comments are never directives + if (comment.type === "Shebang") { + return false; + } + + const { directivePart } = extractDirectiveComment(comment.value); + + const directiveMatch = directivesPattern.exec(directivePart); + + if (!directiveMatch) { + return false; + } + + // only certain comment types are supported as line comments + return comment.type !== "Line" || !!/^eslint-disable-(next-)?line$/u.test(directiveMatch[1]); + }); + + this[caches].set("configNodes", configNodes); + + return configNodes; + } + + /** + * Applies language options sent in from the core. + * @param {Object} languageOptions The language options for this run. + * @returns {void} + */ + applyLanguageOptions(languageOptions) { + + /* + * Add configured globals and language globals + * + * Using Object.assign instead of object spread for performance reasons + * https://github.com/eslint/eslint/issues/16302 + */ + const configGlobals = Object.assign( + {}, + getGlobalsForEcmaVersion(languageOptions.ecmaVersion), + languageOptions.sourceType === "commonjs" ? globals.commonjs : void 0, + languageOptions.globals + ); + const varsCache = this[caches].get("vars"); + + varsCache.set("configGlobals", configGlobals); + } + + /** + * Applies configuration found inside of the source code. This method is only + * called when ESLint is running with inline configuration allowed. + * @returns {{problems:Array,configs:{config:FlatConfigArray,node:ASTNode}}} Information + * that ESLint needs to further process the inline configuration. + */ + applyInlineConfig() { + + const problems = []; + const configs = []; + const exportedVariables = {}; + const inlineGlobals = Object.create(null); + + this.getInlineConfigNodes().forEach(comment => { + + const { directivePart } = extractDirectiveComment(comment.value); + const match = directivesPattern.exec(directivePart); + const directiveText = match[1]; + const directiveValue = directivePart.slice(match.index + directiveText.length); + + switch (directiveText) { + case "exported": + Object.assign(exportedVariables, commentParser.parseStringConfig(directiveValue, comment)); + break; + + case "globals": + case "global": + for (const [id, { value }] of Object.entries(commentParser.parseStringConfig(directiveValue, comment))) { + let normalizedValue; + + try { + normalizedValue = normalizeConfigGlobal(value); + } catch (err) { + problems.push({ + ruleId: null, + loc: comment.loc, + message: err.message + }); + continue; + } + + if (inlineGlobals[id]) { + inlineGlobals[id].comments.push(comment); + inlineGlobals[id].value = normalizedValue; + } else { + inlineGlobals[id] = { + comments: [comment], + value: normalizedValue + }; + } + } + break; + + case "eslint": { + const parseResult = commentParser.parseJsonConfig(directiveValue, comment.loc); + + if (parseResult.success) { + configs.push({ + config: { + rules: parseResult.config + }, + node: comment + }); + } else { + problems.push(parseResult.error); + } + + break; + } + + // no default + } + }); + + // save all the new variables for later + const varsCache = this[caches].get("vars"); + + varsCache.set("inlineGlobals", inlineGlobals); + varsCache.set("exportedVariables", exportedVariables); + + return { + configs, + problems + }; + } + + /** + * Called by ESLint core to indicate that it has finished providing + * information. We now add in all the missing variables and ensure that + * state-changing methods cannot be called by rules. + * @returns {void} + */ + finalize() { + + // Step 1: ensure that all of the necessary variables are up to date + const varsCache = this[caches].get("vars"); + const globalScope = this.scopeManager.scopes[0]; + const configGlobals = varsCache.get("configGlobals"); + const inlineGlobals = varsCache.get("inlineGlobals"); + const exportedVariables = varsCache.get("exportedVariables"); + + addDeclaredGlobals(globalScope, configGlobals, inlineGlobals); + + if (exportedVariables) { + markExportedVariables(globalScope, exportedVariables); + } + + } + } module.exports = SourceCode; diff --git a/tools/node_modules/eslint/lib/unsupported-api.js b/tools/node_modules/eslint/lib/unsupported-api.js index b688608ca88419..8a2e147aabeca8 100644 --- a/tools/node_modules/eslint/lib/unsupported-api.js +++ b/tools/node_modules/eslint/lib/unsupported-api.js @@ -14,6 +14,7 @@ const { FileEnumerator } = require("./cli-engine/file-enumerator"); const { FlatESLint, shouldUseFlatConfig } = require("./eslint/flat-eslint"); const FlatRuleTester = require("./rule-tester/flat-rule-tester"); +const { ESLint } = require("./eslint/eslint"); //----------------------------------------------------------------------------- // Exports @@ -24,5 +25,6 @@ module.exports = { FlatESLint, shouldUseFlatConfig, FlatRuleTester, - FileEnumerator + FileEnumerator, + LegacyESLint: ESLint }; diff --git a/tools/node_modules/eslint/messages/eslintrc-incompat.js b/tools/node_modules/eslint/messages/eslintrc-incompat.js new file mode 100644 index 00000000000000..ee77cb2328ee15 --- /dev/null +++ b/tools/node_modules/eslint/messages/eslintrc-incompat.js @@ -0,0 +1,98 @@ +"use strict"; + +/* eslint consistent-return: 0 -- no default case */ + +const messages = { + + env: ` +A config object is using the "env" key, which is not supported in flat config system. + +Flat config uses "languageOptions.globals" to define global variables for your files. + +Please see the following page for information on how to convert your config object into the correct format: +https://eslint.org/docs/latest/use/configure/migration-guide#configuring-language-options +`, + + extends: ` +A config object is using the "extends" key, which is not supported in flat config system. + +Instead of "extends", you can include config objects that you'd like to extend from directly in the flat config array. + +Please see the following page for more information: +https://eslint.org/docs/latest/use/configure/migration-guide#predefined-and-shareable-configs +`, + + globals: ` +A config object is using the "globals" key, which is not supported in flat config system. + +Flat config uses "languageOptions.globals" to define global variables for your files. + +Please see the following page for information on how to convert your config object into the correct format: +https://eslint.org/docs/latest/use/configure/migration-guide#configuring-language-options +`, + + ignorePatterns: ` +A config object is using the "ignorePatterns" key, which is not supported in flat config system. + +Flat config uses "ignores" to specify files to ignore. + +Please see the following page for information on how to convert your config object into the correct format: +https://eslint.org/docs/latest/use/configure/migration-guide#ignoring-files +`, + + noInlineConfig: ` +A config object is using the "noInlineConfig" key, which is not supported in flat config system. + +Flat config uses "linterOptions.noInlineConfig" to specify files to ignore. + +Please see the following page for information on how to convert your config object into the correct format: +https://eslint.org/docs/latest/use/configure/migration-guide#linter-options +`, + + overrides: ` +A config object is using the "overrides" key, which is not supported in flat config system. + +Flat config is an array that acts like the eslintrc "overrides" array. + +Please see the following page for information on how to convert your config object into the correct format: +https://eslint.org/docs/latest/use/configure/migration-guide#glob-based-configs +`, + + parser: ` +A config object is using the "parser" key, which is not supported in flat config system. + +Flat config uses "languageOptions.parser" to override the default parser. + +Please see the following page for information on how to convert your config object into the correct format: +https://eslint.org/docs/latest/use/configure/migration-guide#custom-parsers +`, + + parserOptions: ` +A config object is using the "parserOptions" key, which is not supported in flat config system. + +Flat config uses "languageOptions.parserOptions" to specify parser options. + +Please see the following page for information on how to convert your config object into the correct format: +https://eslint.org/docs/latest/use/configure/migration-guide#configuring-language-options +`, + + reportUnusedDisableDirectives: ` +A config object is using the "reportUnusedDisableDirectives" key, which is not supported in flat config system. + +Flat config uses "linterOptions.reportUnusedDisableDirectives" to specify files to ignore. + +Please see the following page for information on how to convert your config object into the correct format: +https://eslint.org/docs/latest/use/configure/migration-guide#linter-options +`, + + root: ` +A config object is using the "root" key, which is not supported in flat config system. + +Flat configs always act as if they are the root config file, so this key can be safely removed. +` +}; + +module.exports = function({ key }) { + + return messages[key].trim(); +}; diff --git a/tools/node_modules/eslint/messages/eslintrc-plugins.js b/tools/node_modules/eslint/messages/eslintrc-plugins.js new file mode 100644 index 00000000000000..bb708c95b0531a --- /dev/null +++ b/tools/node_modules/eslint/messages/eslintrc-plugins.js @@ -0,0 +1,24 @@ +"use strict"; + +module.exports = function({ plugins }) { + + const isArrayOfStrings = typeof plugins[0] === "string"; + + return ` +A config object has a "plugins" key defined as an array${isArrayOfStrings ? " of strings" : ""}. + +Flat config requires "plugins" to be an object in this form: + + { + plugins: { + ${isArrayOfStrings && plugins[0] ? plugins[0] : "namespace"}: pluginObject + } + } + +Please see the following page for information on how to convert your config object into the correct format: +https://eslint.org/docs/latest/use/configure/migration-guide#importing-plugins-and-custom-parsers + +If you're using a shareable config that you cannot rewrite in flat config format, then use the compatibility utility: +https://eslint.org/docs/latest/use/configure/migration-guide#using-eslintrc-configs-in-flat-config +`; +}; diff --git a/tools/node_modules/eslint/node_modules/@babel/code-frame/lib/index.js b/tools/node_modules/eslint/node_modules/@babel/code-frame/lib/index.js index cf70a04ea3d928..74495b0d852b72 100644 --- a/tools/node_modules/eslint/node_modules/@babel/code-frame/lib/index.js +++ b/tools/node_modules/eslint/node_modules/@babel/code-frame/lib/index.js @@ -6,6 +6,21 @@ Object.defineProperty(exports, "__esModule", { exports.codeFrameColumns = codeFrameColumns; exports.default = _default; var _highlight = require("@babel/highlight"); +var _chalk = _interopRequireWildcard(require("chalk"), true); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +let chalkWithForcedColor = undefined; +function getChalk(forceColor) { + if (forceColor) { + var _chalkWithForcedColor; + (_chalkWithForcedColor = chalkWithForcedColor) != null ? _chalkWithForcedColor : chalkWithForcedColor = new _chalk.default.constructor({ + enabled: true, + level: 1 + }); + return chalkWithForcedColor; + } + return _chalk.default; +} let deprecationWarningShown = false; function getDefs(chalk) { return { @@ -73,7 +88,7 @@ function getMarkerLines(loc, source, opts) { } function codeFrameColumns(rawLines, loc, opts = {}) { const highlighted = (opts.highlightCode || opts.forceColor) && (0, _highlight.shouldHighlight)(opts); - const chalk = (0, _highlight.getChalk)(opts); + const chalk = getChalk(opts.forceColor); const defs = getDefs(chalk); const maybeHighlight = (chalkFn, string) => { return highlighted ? chalkFn(string) : string; diff --git a/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/ansi-styles/index.js b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/ansi-styles/index.js new file mode 100644 index 00000000000000..90a871c4d78f6f --- /dev/null +++ b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/ansi-styles/index.js @@ -0,0 +1,165 @@ +'use strict'; +const colorConvert = require('color-convert'); + +const wrapAnsi16 = (fn, offset) => function () { + const code = fn.apply(colorConvert, arguments); + return `\u001B[${code + offset}m`; +}; + +const wrapAnsi256 = (fn, offset) => function () { + const code = fn.apply(colorConvert, arguments); + return `\u001B[${38 + offset};5;${code}m`; +}; + +const wrapAnsi16m = (fn, offset) => function () { + const rgb = fn.apply(colorConvert, arguments); + return `\u001B[${38 + offset};2;${rgb[0]};${rgb[1]};${rgb[2]}m`; +}; + +function assembleStyles() { + const codes = new Map(); + const styles = { + modifier: { + reset: [0, 0], + // 21 isn't widely supported and 22 does the same thing + bold: [1, 22], + dim: [2, 22], + italic: [3, 23], + underline: [4, 24], + inverse: [7, 27], + hidden: [8, 28], + strikethrough: [9, 29] + }, + color: { + black: [30, 39], + red: [31, 39], + green: [32, 39], + yellow: [33, 39], + blue: [34, 39], + magenta: [35, 39], + cyan: [36, 39], + white: [37, 39], + gray: [90, 39], + + // Bright color + redBright: [91, 39], + greenBright: [92, 39], + yellowBright: [93, 39], + blueBright: [94, 39], + magentaBright: [95, 39], + cyanBright: [96, 39], + whiteBright: [97, 39] + }, + bgColor: { + bgBlack: [40, 49], + bgRed: [41, 49], + bgGreen: [42, 49], + bgYellow: [43, 49], + bgBlue: [44, 49], + bgMagenta: [45, 49], + bgCyan: [46, 49], + bgWhite: [47, 49], + + // Bright color + bgBlackBright: [100, 49], + bgRedBright: [101, 49], + bgGreenBright: [102, 49], + bgYellowBright: [103, 49], + bgBlueBright: [104, 49], + bgMagentaBright: [105, 49], + bgCyanBright: [106, 49], + bgWhiteBright: [107, 49] + } + }; + + // Fix humans + styles.color.grey = styles.color.gray; + + for (const groupName of Object.keys(styles)) { + const group = styles[groupName]; + + for (const styleName of Object.keys(group)) { + const style = group[styleName]; + + styles[styleName] = { + open: `\u001B[${style[0]}m`, + close: `\u001B[${style[1]}m` + }; + + group[styleName] = styles[styleName]; + + codes.set(style[0], style[1]); + } + + Object.defineProperty(styles, groupName, { + value: group, + enumerable: false + }); + + Object.defineProperty(styles, 'codes', { + value: codes, + enumerable: false + }); + } + + const ansi2ansi = n => n; + const rgb2rgb = (r, g, b) => [r, g, b]; + + styles.color.close = '\u001B[39m'; + styles.bgColor.close = '\u001B[49m'; + + styles.color.ansi = { + ansi: wrapAnsi16(ansi2ansi, 0) + }; + styles.color.ansi256 = { + ansi256: wrapAnsi256(ansi2ansi, 0) + }; + styles.color.ansi16m = { + rgb: wrapAnsi16m(rgb2rgb, 0) + }; + + styles.bgColor.ansi = { + ansi: wrapAnsi16(ansi2ansi, 10) + }; + styles.bgColor.ansi256 = { + ansi256: wrapAnsi256(ansi2ansi, 10) + }; + styles.bgColor.ansi16m = { + rgb: wrapAnsi16m(rgb2rgb, 10) + }; + + for (let key of Object.keys(colorConvert)) { + if (typeof colorConvert[key] !== 'object') { + continue; + } + + const suite = colorConvert[key]; + + if (key === 'ansi16') { + key = 'ansi'; + } + + if ('ansi16' in suite) { + styles.color.ansi[key] = wrapAnsi16(suite.ansi16, 0); + styles.bgColor.ansi[key] = wrapAnsi16(suite.ansi16, 10); + } + + if ('ansi256' in suite) { + styles.color.ansi256[key] = wrapAnsi256(suite.ansi256, 0); + styles.bgColor.ansi256[key] = wrapAnsi256(suite.ansi256, 10); + } + + if ('rgb' in suite) { + styles.color.ansi16m[key] = wrapAnsi16m(suite.rgb, 0); + styles.bgColor.ansi16m[key] = wrapAnsi16m(suite.rgb, 10); + } + } + + return styles; +} + +// Make the export immutable +Object.defineProperty(module, 'exports', { + enumerable: true, + get: assembleStyles +}); diff --git a/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/ansi-styles/license b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/ansi-styles/license new file mode 100644 index 00000000000000..e7af2f77107d73 --- /dev/null +++ b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/ansi-styles/license @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/ansi-styles/package.json b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/ansi-styles/package.json new file mode 100644 index 00000000000000..65edb48c399c5c --- /dev/null +++ b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/ansi-styles/package.json @@ -0,0 +1,56 @@ +{ + "name": "ansi-styles", + "version": "3.2.1", + "description": "ANSI escape codes for styling strings in the terminal", + "license": "MIT", + "repository": "chalk/ansi-styles", + "author": { + "name": "Sindre Sorhus", + "email": "sindresorhus@gmail.com", + "url": "sindresorhus.com" + }, + "engines": { + "node": ">=4" + }, + "scripts": { + "test": "xo && ava", + "screenshot": "svg-term --command='node screenshot' --out=screenshot.svg --padding=3 --width=55 --height=3 --at=1000 --no-cursor" + }, + "files": [ + "index.js" + ], + "keywords": [ + "ansi", + "styles", + "color", + "colour", + "colors", + "terminal", + "console", + "cli", + "string", + "tty", + "escape", + "formatting", + "rgb", + "256", + "shell", + "xterm", + "log", + "logging", + "command-line", + "text" + ], + "dependencies": { + "color-convert": "^1.9.0" + }, + "devDependencies": { + "ava": "*", + "babel-polyfill": "^6.23.0", + "svg-term-cli": "^2.1.1", + "xo": "*" + }, + "ava": { + "require": "babel-polyfill" + } +} diff --git a/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/ansi-styles/readme.md b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/ansi-styles/readme.md new file mode 100644 index 00000000000000..3158e2df59ce66 --- /dev/null +++ b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/ansi-styles/readme.md @@ -0,0 +1,147 @@ +# ansi-styles [![Build Status](https://travis-ci.org/chalk/ansi-styles.svg?branch=master)](https://travis-ci.org/chalk/ansi-styles) + +> [ANSI escape codes](http://en.wikipedia.org/wiki/ANSI_escape_code#Colors_and_Styles) for styling strings in the terminal + +You probably want the higher-level [chalk](https://github.com/chalk/chalk) module for styling your strings. + + + + +## Install + +``` +$ npm install ansi-styles +``` + + +## Usage + +```js +const style = require('ansi-styles'); + +console.log(`${style.green.open}Hello world!${style.green.close}`); + + +// Color conversion between 16/256/truecolor +// NOTE: If conversion goes to 16 colors or 256 colors, the original color +// may be degraded to fit that color palette. This means terminals +// that do not support 16 million colors will best-match the +// original color. +console.log(style.bgColor.ansi.hsl(120, 80, 72) + 'Hello world!' + style.bgColor.close); +console.log(style.color.ansi256.rgb(199, 20, 250) + 'Hello world!' + style.color.close); +console.log(style.color.ansi16m.hex('#ABCDEF') + 'Hello world!' + style.color.close); +``` + +## API + +Each style has an `open` and `close` property. + + +## Styles + +### Modifiers + +- `reset` +- `bold` +- `dim` +- `italic` *(Not widely supported)* +- `underline` +- `inverse` +- `hidden` +- `strikethrough` *(Not widely supported)* + +### Colors + +- `black` +- `red` +- `green` +- `yellow` +- `blue` +- `magenta` +- `cyan` +- `white` +- `gray` ("bright black") +- `redBright` +- `greenBright` +- `yellowBright` +- `blueBright` +- `magentaBright` +- `cyanBright` +- `whiteBright` + +### Background colors + +- `bgBlack` +- `bgRed` +- `bgGreen` +- `bgYellow` +- `bgBlue` +- `bgMagenta` +- `bgCyan` +- `bgWhite` +- `bgBlackBright` +- `bgRedBright` +- `bgGreenBright` +- `bgYellowBright` +- `bgBlueBright` +- `bgMagentaBright` +- `bgCyanBright` +- `bgWhiteBright` + + +## Advanced usage + +By default, you get a map of styles, but the styles are also available as groups. They are non-enumerable so they don't show up unless you access them explicitly. This makes it easier to expose only a subset in a higher-level module. + +- `style.modifier` +- `style.color` +- `style.bgColor` + +###### Example + +```js +console.log(style.color.green.open); +``` + +Raw escape codes (i.e. without the CSI escape prefix `\u001B[` and render mode postfix `m`) are available under `style.codes`, which returns a `Map` with the open codes as keys and close codes as values. + +###### Example + +```js +console.log(style.codes.get(36)); +//=> 39 +``` + + +## [256 / 16 million (TrueColor) support](https://gist.github.com/XVilka/8346728) + +`ansi-styles` uses the [`color-convert`](https://github.com/Qix-/color-convert) package to allow for converting between various colors and ANSI escapes, with support for 256 and 16 million colors. + +To use these, call the associated conversion function with the intended output, for example: + +```js +style.color.ansi.rgb(100, 200, 15); // RGB to 16 color ansi foreground code +style.bgColor.ansi.rgb(100, 200, 15); // RGB to 16 color ansi background code + +style.color.ansi256.hsl(120, 100, 60); // HSL to 256 color ansi foreground code +style.bgColor.ansi256.hsl(120, 100, 60); // HSL to 256 color ansi foreground code + +style.color.ansi16m.hex('#C0FFEE'); // Hex (RGB) to 16 million color foreground code +style.bgColor.ansi16m.hex('#C0FFEE'); // Hex (RGB) to 16 million color background code +``` + + +## Related + +- [ansi-escapes](https://github.com/sindresorhus/ansi-escapes) - ANSI escape codes for manipulating the terminal + + +## Maintainers + +- [Sindre Sorhus](https://github.com/sindresorhus) +- [Josh Junon](https://github.com/qix-) + + +## License + +MIT diff --git a/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/index.js b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/index.js new file mode 100644 index 00000000000000..1cc5fa89a95159 --- /dev/null +++ b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/index.js @@ -0,0 +1,228 @@ +'use strict'; +const escapeStringRegexp = require('escape-string-regexp'); +const ansiStyles = require('ansi-styles'); +const stdoutColor = require('supports-color').stdout; + +const template = require('./templates.js'); + +const isSimpleWindowsTerm = process.platform === 'win32' && !(process.env.TERM || '').toLowerCase().startsWith('xterm'); + +// `supportsColor.level` → `ansiStyles.color[name]` mapping +const levelMapping = ['ansi', 'ansi', 'ansi256', 'ansi16m']; + +// `color-convert` models to exclude from the Chalk API due to conflicts and such +const skipModels = new Set(['gray']); + +const styles = Object.create(null); + +function applyOptions(obj, options) { + options = options || {}; + + // Detect level if not set manually + const scLevel = stdoutColor ? stdoutColor.level : 0; + obj.level = options.level === undefined ? scLevel : options.level; + obj.enabled = 'enabled' in options ? options.enabled : obj.level > 0; +} + +function Chalk(options) { + // We check for this.template here since calling `chalk.constructor()` + // by itself will have a `this` of a previously constructed chalk object + if (!this || !(this instanceof Chalk) || this.template) { + const chalk = {}; + applyOptions(chalk, options); + + chalk.template = function () { + const args = [].slice.call(arguments); + return chalkTag.apply(null, [chalk.template].concat(args)); + }; + + Object.setPrototypeOf(chalk, Chalk.prototype); + Object.setPrototypeOf(chalk.template, chalk); + + chalk.template.constructor = Chalk; + + return chalk.template; + } + + applyOptions(this, options); +} + +// Use bright blue on Windows as the normal blue color is illegible +if (isSimpleWindowsTerm) { + ansiStyles.blue.open = '\u001B[94m'; +} + +for (const key of Object.keys(ansiStyles)) { + ansiStyles[key].closeRe = new RegExp(escapeStringRegexp(ansiStyles[key].close), 'g'); + + styles[key] = { + get() { + const codes = ansiStyles[key]; + return build.call(this, this._styles ? this._styles.concat(codes) : [codes], this._empty, key); + } + }; +} + +styles.visible = { + get() { + return build.call(this, this._styles || [], true, 'visible'); + } +}; + +ansiStyles.color.closeRe = new RegExp(escapeStringRegexp(ansiStyles.color.close), 'g'); +for (const model of Object.keys(ansiStyles.color.ansi)) { + if (skipModels.has(model)) { + continue; + } + + styles[model] = { + get() { + const level = this.level; + return function () { + const open = ansiStyles.color[levelMapping[level]][model].apply(null, arguments); + const codes = { + open, + close: ansiStyles.color.close, + closeRe: ansiStyles.color.closeRe + }; + return build.call(this, this._styles ? this._styles.concat(codes) : [codes], this._empty, model); + }; + } + }; +} + +ansiStyles.bgColor.closeRe = new RegExp(escapeStringRegexp(ansiStyles.bgColor.close), 'g'); +for (const model of Object.keys(ansiStyles.bgColor.ansi)) { + if (skipModels.has(model)) { + continue; + } + + const bgModel = 'bg' + model[0].toUpperCase() + model.slice(1); + styles[bgModel] = { + get() { + const level = this.level; + return function () { + const open = ansiStyles.bgColor[levelMapping[level]][model].apply(null, arguments); + const codes = { + open, + close: ansiStyles.bgColor.close, + closeRe: ansiStyles.bgColor.closeRe + }; + return build.call(this, this._styles ? this._styles.concat(codes) : [codes], this._empty, model); + }; + } + }; +} + +const proto = Object.defineProperties(() => {}, styles); + +function build(_styles, _empty, key) { + const builder = function () { + return applyStyle.apply(builder, arguments); + }; + + builder._styles = _styles; + builder._empty = _empty; + + const self = this; + + Object.defineProperty(builder, 'level', { + enumerable: true, + get() { + return self.level; + }, + set(level) { + self.level = level; + } + }); + + Object.defineProperty(builder, 'enabled', { + enumerable: true, + get() { + return self.enabled; + }, + set(enabled) { + self.enabled = enabled; + } + }); + + // See below for fix regarding invisible grey/dim combination on Windows + builder.hasGrey = this.hasGrey || key === 'gray' || key === 'grey'; + + // `__proto__` is used because we must return a function, but there is + // no way to create a function with a different prototype + builder.__proto__ = proto; // eslint-disable-line no-proto + + return builder; +} + +function applyStyle() { + // Support varags, but simply cast to string in case there's only one arg + const args = arguments; + const argsLen = args.length; + let str = String(arguments[0]); + + if (argsLen === 0) { + return ''; + } + + if (argsLen > 1) { + // Don't slice `arguments`, it prevents V8 optimizations + for (let a = 1; a < argsLen; a++) { + str += ' ' + args[a]; + } + } + + if (!this.enabled || this.level <= 0 || !str) { + return this._empty ? '' : str; + } + + // Turns out that on Windows dimmed gray text becomes invisible in cmd.exe, + // see https://github.com/chalk/chalk/issues/58 + // If we're on Windows and we're dealing with a gray color, temporarily make 'dim' a noop. + const originalDim = ansiStyles.dim.open; + if (isSimpleWindowsTerm && this.hasGrey) { + ansiStyles.dim.open = ''; + } + + for (const code of this._styles.slice().reverse()) { + // Replace any instances already present with a re-opening code + // otherwise only the part of the string until said closing code + // will be colored, and the rest will simply be 'plain'. + str = code.open + str.replace(code.closeRe, code.open) + code.close; + + // Close the styling before a linebreak and reopen + // after next line to fix a bleed issue on macOS + // https://github.com/chalk/chalk/pull/92 + str = str.replace(/\r?\n/g, `${code.close}$&${code.open}`); + } + + // Reset the original `dim` if we changed it to work around the Windows dimmed gray issue + ansiStyles.dim.open = originalDim; + + return str; +} + +function chalkTag(chalk, strings) { + if (!Array.isArray(strings)) { + // If chalk() was called by itself or with a string, + // return the string itself as a string. + return [].slice.call(arguments, 1).join(' '); + } + + const args = [].slice.call(arguments, 2); + const parts = [strings.raw[0]]; + + for (let i = 1; i < strings.length; i++) { + parts.push(String(args[i - 1]).replace(/[{}\\]/g, '\\$&')); + parts.push(String(strings.raw[i])); + } + + return template(chalk, parts.join('')); +} + +Object.defineProperties(Chalk.prototype, styles); + +module.exports = Chalk(); // eslint-disable-line new-cap +module.exports.supportsColor = stdoutColor; +module.exports.default = module.exports; // For TypeScript diff --git a/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/index.js.flow b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/index.js.flow new file mode 100644 index 00000000000000..622caaa2e803f3 --- /dev/null +++ b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/index.js.flow @@ -0,0 +1,93 @@ +// @flow strict + +type TemplateStringsArray = $ReadOnlyArray; + +export type Level = $Values<{ + None: 0, + Basic: 1, + Ansi256: 2, + TrueColor: 3 +}>; + +export type ChalkOptions = {| + enabled?: boolean, + level?: Level +|}; + +export type ColorSupport = {| + level: Level, + hasBasic: boolean, + has256: boolean, + has16m: boolean +|}; + +export interface Chalk { + (...text: string[]): string, + (text: TemplateStringsArray, ...placeholders: string[]): string, + constructor(options?: ChalkOptions): Chalk, + enabled: boolean, + level: Level, + rgb(r: number, g: number, b: number): Chalk, + hsl(h: number, s: number, l: number): Chalk, + hsv(h: number, s: number, v: number): Chalk, + hwb(h: number, w: number, b: number): Chalk, + bgHex(color: string): Chalk, + bgKeyword(color: string): Chalk, + bgRgb(r: number, g: number, b: number): Chalk, + bgHsl(h: number, s: number, l: number): Chalk, + bgHsv(h: number, s: number, v: number): Chalk, + bgHwb(h: number, w: number, b: number): Chalk, + hex(color: string): Chalk, + keyword(color: string): Chalk, + + +reset: Chalk, + +bold: Chalk, + +dim: Chalk, + +italic: Chalk, + +underline: Chalk, + +inverse: Chalk, + +hidden: Chalk, + +strikethrough: Chalk, + + +visible: Chalk, + + +black: Chalk, + +red: Chalk, + +green: Chalk, + +yellow: Chalk, + +blue: Chalk, + +magenta: Chalk, + +cyan: Chalk, + +white: Chalk, + +gray: Chalk, + +grey: Chalk, + +blackBright: Chalk, + +redBright: Chalk, + +greenBright: Chalk, + +yellowBright: Chalk, + +blueBright: Chalk, + +magentaBright: Chalk, + +cyanBright: Chalk, + +whiteBright: Chalk, + + +bgBlack: Chalk, + +bgRed: Chalk, + +bgGreen: Chalk, + +bgYellow: Chalk, + +bgBlue: Chalk, + +bgMagenta: Chalk, + +bgCyan: Chalk, + +bgWhite: Chalk, + +bgBlackBright: Chalk, + +bgRedBright: Chalk, + +bgGreenBright: Chalk, + +bgYellowBright: Chalk, + +bgBlueBright: Chalk, + +bgMagentaBright: Chalk, + +bgCyanBright: Chalk, + +bgWhiteBrigh: Chalk, + + supportsColor: ColorSupport +}; + +declare module.exports: Chalk; diff --git a/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/license b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/license new file mode 100644 index 00000000000000..e7af2f77107d73 --- /dev/null +++ b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/license @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/package.json b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/package.json new file mode 100644 index 00000000000000..bc324685a7625f --- /dev/null +++ b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/package.json @@ -0,0 +1,71 @@ +{ + "name": "chalk", + "version": "2.4.2", + "description": "Terminal string styling done right", + "license": "MIT", + "repository": "chalk/chalk", + "engines": { + "node": ">=4" + }, + "scripts": { + "test": "xo && tsc --project types && flow --max-warnings=0 && nyc ava", + "bench": "matcha benchmark.js", + "coveralls": "nyc report --reporter=text-lcov | coveralls" + }, + "files": [ + "index.js", + "templates.js", + "types/index.d.ts", + "index.js.flow" + ], + "keywords": [ + "color", + "colour", + "colors", + "terminal", + "console", + "cli", + "string", + "str", + "ansi", + "style", + "styles", + "tty", + "formatting", + "rgb", + "256", + "shell", + "xterm", + "log", + "logging", + "command-line", + "text" + ], + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "devDependencies": { + "ava": "*", + "coveralls": "^3.0.0", + "execa": "^0.9.0", + "flow-bin": "^0.68.0", + "import-fresh": "^2.0.0", + "matcha": "^0.7.0", + "nyc": "^11.0.2", + "resolve-from": "^4.0.0", + "typescript": "^2.5.3", + "xo": "*" + }, + "types": "types/index.d.ts", + "xo": { + "envs": [ + "node", + "mocha" + ], + "ignores": [ + "test/_flow.js" + ] + } +} diff --git a/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/readme.md b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/readme.md new file mode 100644 index 00000000000000..d298e2c48d64a0 --- /dev/null +++ b/tools/node_modules/eslint/node_modules/@babel/code-frame/node_modules/chalk/readme.md @@ -0,0 +1,314 @@ +

+
+
+ Chalk +
+
+
+

+ +> Terminal string styling done right + +[![Build Status](https://travis-ci.org/chalk/chalk.svg?branch=master)](https://travis-ci.org/chalk/chalk) [![Coverage Status](https://coveralls.io/repos/github/chalk/chalk/badge.svg?branch=master)](https://coveralls.io/github/chalk/chalk?branch=master) [![](https://img.shields.io/badge/unicorn-approved-ff69b4.svg)](https://www.youtube.com/watch?v=9auOCbH5Ns4) [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/xojs/xo) [![Mentioned in Awesome Node.js](https://awesome.re/mentioned-badge.svg)](https://github.com/sindresorhus/awesome-nodejs) + +### [See what's new in Chalk 2](https://github.com/chalk/chalk/releases/tag/v2.0.0) + + + + +## Highlights + +- Expressive API +- Highly performant +- Ability to nest styles +- [256/Truecolor color support](#256-and-truecolor-color-support) +- Auto-detects color support +- Doesn't extend `String.prototype` +- Clean and focused +- Actively maintained +- [Used by ~23,000 packages](https://www.npmjs.com/browse/depended/chalk) as of December 31, 2017 + + +## Install + +```console +$ npm install chalk +``` + + + + + + +## Usage + +```js +const chalk = require('chalk'); + +console.log(chalk.blue('Hello world!')); +``` + +Chalk comes with an easy to use composable API where you just chain and nest the styles you want. + +```js +const chalk = require('chalk'); +const log = console.log; + +// Combine styled and normal strings +log(chalk.blue('Hello') + ' World' + chalk.red('!')); + +// Compose multiple styles using the chainable API +log(chalk.blue.bgRed.bold('Hello world!')); + +// Pass in multiple arguments +log(chalk.blue('Hello', 'World!', 'Foo', 'bar', 'biz', 'baz')); + +// Nest styles +log(chalk.red('Hello', chalk.underline.bgBlue('world') + '!')); + +// Nest styles of the same type even (color, underline, background) +log(chalk.green( + 'I am a green line ' + + chalk.blue.underline.bold('with a blue substring') + + ' that becomes green again!' +)); + +// ES2015 template literal +log(` +CPU: ${chalk.red('90%')} +RAM: ${chalk.green('40%')} +DISK: ${chalk.yellow('70%')} +`); + +// ES2015 tagged template literal +log(chalk` +CPU: {red ${cpu.totalPercent}%} +RAM: {green ${ram.used / ram.total * 100}%} +DISK: {rgb(255,131,0) ${disk.used / disk.total * 100}%} +`); + +// Use RGB colors in terminal emulators that support it. +log(chalk.keyword('orange')('Yay for orange colored text!')); +log(chalk.rgb(123, 45, 67).underline('Underlined reddish color')); +log(chalk.hex('#DEADED').bold('Bold gray!')); +``` + +Easily define your own themes: + +```js +const chalk = require('chalk'); + +const error = chalk.bold.red; +const warning = chalk.keyword('orange'); + +console.log(error('Error!')); +console.log(warning('Warning!')); +``` + +Take advantage of console.log [string substitution](https://nodejs.org/docs/latest/api/console.html#console_console_log_data_args): + +```js +const name = 'Sindre'; +console.log(chalk.green('Hello %s'), name); +//=> 'Hello Sindre' +``` + + +## API + +### chalk.`