From cad0402276e678abdf06190d791f74fc47a23db1 Mon Sep 17 00:00:00 2001 From: Geoffrey Booth Date: Sat, 14 Dec 2019 22:27:48 -0500 Subject: [PATCH] module: loader getSource, getFormat, transform hooks PR-URL: https://github.com/nodejs/node/pull/30986 Reviewed-By: Guy Bedford Reviewed-By: Bradley Farias --- doc/api/cli.md | 4 +- doc/api/esm.md | 368 ++++++++++++++---- lib/internal/errors.js | 2 +- lib/internal/main/check_syntax.js | 6 +- lib/internal/modules/esm/default_resolve.js | 135 ------- lib/internal/modules/esm/get_format.js | 77 ++++ lib/internal/modules/esm/get_source.js | 35 ++ lib/internal/modules/esm/loader.js | 84 ++-- lib/internal/modules/esm/resolve.js | 72 ++++ lib/internal/modules/esm/transform_source.js | 7 + lib/internal/modules/esm/translators.js | 54 ++- node.gyp | 5 +- test/es-module/test-esm-data-urls.js | 2 +- test/es-module/test-esm-get-source-loader.mjs | 6 + test/es-module/test-esm-invalid-extension.js | 4 +- test/es-module/test-esm-loader-get-format.mjs | 12 + .../test-esm-loader-invalid-format.mjs | 5 +- .../es-module/test-esm-loader-invalid-url.mjs | 6 +- test/es-module/test-esm-loader-search.js | 13 +- .../test-esm-transform-source-loader.mjs | 6 + .../builtin-named-exports-loader.mjs | 21 +- .../es-module-loaders/example-loader.mjs | 28 +- .../fixtures/es-module-loaders/get-source.mjs | 10 + test/fixtures/es-module-loaders/js-loader.mjs | 23 +- .../es-module-loaders/loader-get-format.mjs | 10 + .../loader-invalid-format.mjs | 15 +- .../es-module-loaders/loader-invalid-url.mjs | 9 +- .../es-module-loaders/loader-shared-dep.mjs | 4 +- .../loader-unknown-builtin-module.mjs | 19 +- .../es-module-loaders/loader-with-dep.mjs | 4 +- .../missing-dynamic-instantiate-hook.mjs | 19 +- .../not-found-assert-loader.mjs | 6 +- .../es-module-loaders/transform-source.mjs | 11 + .../package-type-module/extension.unknown | 2 +- test/message/esm_loader_not_found.out | 4 +- 35 files changed, 744 insertions(+), 344 deletions(-) delete mode 100644 lib/internal/modules/esm/default_resolve.js create mode 100644 lib/internal/modules/esm/get_format.js create mode 100644 lib/internal/modules/esm/get_source.js create mode 100644 lib/internal/modules/esm/resolve.js create mode 100644 lib/internal/modules/esm/transform_source.js create mode 100644 test/es-module/test-esm-get-source-loader.mjs create mode 100644 test/es-module/test-esm-loader-get-format.mjs create mode 100644 test/es-module/test-esm-transform-source-loader.mjs create mode 100644 test/fixtures/es-module-loaders/get-source.mjs create mode 100644 test/fixtures/es-module-loaders/loader-get-format.mjs create mode 100644 test/fixtures/es-module-loaders/transform-source.mjs diff --git a/doc/api/cli.md b/doc/api/cli.md index 5edd332a413e37..e724395016e5e6 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -435,7 +435,7 @@ endpoint on `http://host:port/json/list`. added: v9.0.0 --> -Specify the `module` of a custom [experimental ECMAScript Module][] loader. +Specify the `module` of a custom [experimental ECMAScript Module loader][]. `module` may be either a path to a file, or an ECMAScript Module name. ### `--insecure-http-parser` @@ -1406,6 +1406,6 @@ greater than `4` (its current default value). For more information, see the [debugger]: debugger.html [debugging security implications]: https://nodejs.org/en/docs/guides/debugging-getting-started/#security-implications [emit_warning]: process.html#process_process_emitwarning_warning_type_code_ctor -[experimental ECMAScript Module]: esm.html#esm_resolve_hook +[experimental ECMAScript Module loader]: esm.html#esm_experimental_loaders [libuv threadpool documentation]: http://docs.libuv.org/en/latest/threadpool.html [remote code execution]: https://www.owasp.org/index.php/Code_Injection diff --git a/doc/api/esm.md b/doc/api/esm.md index 0013e82fa3dfa2..4dce1d10c238f3 100644 --- a/doc/api/esm.md +++ b/doc/api/esm.md @@ -1000,7 +1000,7 @@ node --experimental-modules --experimental-wasm-modules index.mjs would provide the exports interface for the instantiation of `module.wasm`. -## Experimental Loader hooks +## Experimental Loaders **Note: This API is currently being redesigned and will still change.** @@ -1012,39 +1012,49 @@ provided via a `--experimental-loader ./loader-name.mjs` argument to Node.js. When hooks are used they only apply to ES module loading and not to any CommonJS modules loaded. -### Resolve hook +### Hooks -The resolve hook returns the resolved file URL and module format for a -given module specifier and parent file URL: +#### resolve hook -```js -import { URL, pathToFileURL } from 'url'; -const baseURL = pathToFileURL(process.cwd()).href; +> Note: The loaders API is being redesigned. This hook may disappear or its +> signature may change. Do not rely on the API described below. +The `resolve` hook returns the resolved file URL for a given module specifier +and parent URL. The module specifier is the string in an `import` statement or +`import()` expression, and the parent URL is the URL of the module that imported +this one, or `undefined` if this is the main entry point for the application. + +```js /** * @param {string} specifier - * @param {string} parentModuleURL - * @param {function} defaultResolver + * @param {object} context + * @param {string} context.parentURL + * @param {function} defaultResolve + * @returns {object} response + * @returns {string} response.url */ -export async function resolve(specifier, - parentModuleURL = baseURL, - defaultResolver) { - return { - url: new URL(specifier, parentModuleURL).href, - format: 'module' - }; +export async function resolve(specifier, context, defaultResolve) { + const { parentURL = null } = context; + if (someCondition) { + // For some or all specifiers, do some custom logic for resolving. + // Always return an object of the form {url: } + return { + url: (parentURL) ? + new URL(specifier, parentURL).href : new URL(specifier).href + }; + } + // Defer to Node.js for all other specifiers. + return defaultResolve(specifier, context, defaultResolve); } ``` -The `parentModuleURL` is provided as `undefined` when performing main Node.js -load itself. +#### getFormat hook -The default Node.js ES module resolution function is provided as a third -argument to the resolver for easy compatibility workflows. +> Note: The loaders API is being redesigned. This hook may disappear or its +> signature may change. Do not rely on the API described below. -In addition to returning the resolved file URL value, the resolve hook also -returns a `format` property specifying the module format of the resolved -module. This can be one of the following: +The `getFormat` hook provides a way to define a custom method of determining how +a URL should be interpreted. This can be one of the following: | `format` | Description | | --- | --- | @@ -1052,74 +1062,124 @@ module. This can be one of the following: | `'commonjs'` | Load a Node.js CommonJS module | | `'dynamic'` | Use a [dynamic instantiate hook][] | | `'json'` | Load a JSON file | -| `'module'` | Load a standard JavaScript module | +| `'module'` | Load a standard JavaScript module (ES module) | | `'wasm'` | Load a WebAssembly module | -For example, a dummy loader to load JavaScript restricted to browser resolution -rules with only JS file extension and Node.js builtin modules support could -be written: - ```js -import path from 'path'; -import process from 'process'; -import Module from 'module'; -import { URL, pathToFileURL } from 'url'; +/** + * @param {string} url + * @param {object} context (currently empty) + * @param {function} defaultGetFormat + * @returns {object} response + * @returns {string} response.format + */ +export async function getFormat(url, context, defaultGetFormat) { + if (someCondition) { + // For some or all URLs, do some custom logic for determining format. + // Always return an object of the form {format: }, where the + // format is one of the strings in the table above. + return { + format: 'module' + }; + } + // Defer to Node.js for all other URLs. + return defaultGetFormat(url, context, defaultGetFormat); +} +``` + +#### getSource hook -const builtins = Module.builtinModules; -const JS_EXTENSIONS = new Set(['.js', '.mjs']); +> Note: The loaders API is being redesigned. This hook may disappear or its +> signature may change. Do not rely on the API described below. -const baseURL = pathToFileURL(process.cwd()).href; +The `getSource` 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. +```js /** - * @param {string} specifier - * @param {string} parentModuleURL - * @param {function} defaultResolver + * @param {string} url + * @param {object} context + * @param {string} context.format + * @param {function} defaultGetSource + * @returns {object} response + * @returns {string|buffer} response.source */ -export async function resolve(specifier, - parentModuleURL = baseURL, - defaultResolver) { - if (builtins.includes(specifier)) { +export async function getSource(url, context, defaultGetSource) { + const { format } = context; + if (someCondition) { + // For some or all URLs, do some custom logic for retrieving the source. + // Always return an object of the form {source: }. return { - url: specifier, - format: 'builtin' + source: '...' }; } - if (/^\.{0,2}[/]/.test(specifier) !== true && !specifier.startsWith('file:')) { - // For node_modules support: - // return defaultResolver(specifier, parentModuleURL); - throw new Error( - `imports must begin with '/', './', or '../'; '${specifier}' does not`); - } - const resolved = new URL(specifier, parentModuleURL); - const ext = path.extname(resolved.pathname); - if (!JS_EXTENSIONS.has(ext)) { - throw new Error( - `Cannot load file with non-JavaScript file extension ${ext}.`); - } - return { - url: resolved.href, - format: 'module' - }; + // Defer to Node.js for all other URLs. + return defaultGetSource(url, context, defaultGetSource); } ``` -With this loader, running: +#### transformSource hook ```console NODE_OPTIONS='--experimental-modules --experimental-loader ./custom-loader.mjs' node x.js ``` -would load the module `x.js` as an ES module with relative resolution support -(with `node_modules` loading skipped in this example). +> Note: The loaders API is being redesigned. This hook may disappear or its +> signature may change. Do not rely on the API described below. + +The `transformSource` hook provides a way to modify the source code of a loaded +ES module file after the source string has been loaded but before Node.js has +done anything with it. + +If this hook is used to convert unknown-to-Node.js file types into executable +JavaScript, a resolve hook is also necessary in order to register any +unknown-to-Node.js file extensions. See the [transpiler loader example][] below. + +```js +/** + * @param {string|buffer} source + * @param {object} context + * @param {string} context.url + * @param {string} context.format + * @param {function} defaultTransformSource + * @returns {object} response + * @returns {string|buffer} response.source + */ +export async function transformSource(source, + context, + defaultTransformSource) { + const { url, format } = context; + if (someCondition) { + // For some or all URLs, do some custom logic for modifying the source. + // Always return an object of the form {source: }. + return { + source: '...' + }; + } + // Defer to Node.js for all other sources. + return defaultTransformSource( + source, context, defaultTransformSource); +} +``` + +#### dynamicInstantiate hook -### Dynamic instantiate hook +> Note: The loaders API is being redesigned. This hook may disappear or its +> signature may change. Do not rely on the API described below. To create a custom dynamic module that doesn't correspond to one of the existing `format` interpretations, the `dynamicInstantiate` hook can be used. This hook is called only for modules that return `format: 'dynamic'` from -the `resolve` hook. +the [`getFormat` hook][]. ```js +/** + * @param {string} url + * @returns {object} response + * @returns {array} response.exports + * @returns {function} response.execute + */ export async function dynamicInstantiate(url) { return { exports: ['customExportName'], @@ -1135,6 +1195,179 @@ With the list of module exports provided upfront, the `execute` function will then be called at the exact point of module evaluation order for that module in the import tree. +### Examples + +The various loader hooks can be used together to accomplish wide-ranging +customizations of Node.js’ code loading and evaluation behaviors. + +#### HTTPS loader + +In current Node.js, specifiers starting with `https://` are unsupported. 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 'https'; + +export function resolve(specifier, context, defaultResolve) { + const { parentURL = null } = context; + + // Normally Node.js would error on specifiers starting with 'https://', so + // this hook intercepts them and converts them into absolute URLs to be + // passed along to the later hooks below. + if (specifier.startsWith('https://')) { + return { + url: specifier + }; + } else if (parentURL && parentURL.startsWith('https://')) { + return { + url: new URL(specifier, parentURL).href + }; + } + + // Let Node.js handle all other specifiers. + return defaultResolve(specifier, context, defaultResolve); +} + +export function getFormat(url, context, defaultGetFormat) { + // This loader assumes all network-provided JavaScript is ES module code. + if (url.startsWith('https://')) { + return { + format: 'module' + }; + } + + // Let Node.js handle all other URLs. + return defaultGetFormat(url, context, defaultGetFormat); +} + +export function getSource(url, context, defaultGetSource) { + // 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.on('data', (chunk) => data += chunk); + res.on('end', () => resolve({ source: data })); + }).on('error', (err) => reject(err)); + }); + } + + // Let Node.js handle all other URLs. + return defaultGetSource(url, context, defaultGetSource); +} +``` + +```js +// main.mjs +import { VERSION } from 'https://coffeescript.org/browser-compiler-modern/coffeescript.js'; + +console.log(VERSION); +``` + +With this loader, running: + +```console +node --experimental-loader ./https-loader.mjs ./main.js +``` + +Will print 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 [`transformSource` hook][]. Before that hook gets called, +however, other hooks need to tell Node.js not to throw an error on unknown file +types; and to tell Node.js how to load this new file type. + +This is obviously 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 { URL, pathToFileURL } from 'url'; +import CoffeeScript from 'coffeescript'; + +const baseURL = pathToFileURL(`${process.cwd()}/`).href; + +// CoffeeScript files end in .coffee, .litcoffee or .coffee.md. +const extensionsRegex = /\.coffee$|\.litcoffee$|\.coffee\.md$/; + +export function resolve(specifier, context, defaultResolve) { + const { parentURL = baseURL } = context; + + // Node.js normally errors on unknown file extensions, so return a URL for + // specifiers ending in the CoffeeScript file extensions. + if (extensionsRegex.test(specifier)) { + return { + url: new URL(specifier, parentURL).href + }; + } + + // Let Node.js handle all other specifiers. + return defaultResolve(specifier, context, defaultResolve); +} + +export function getFormat(url, context, defaultGetFormat) { + // Now that we patched resolve to let CoffeeScript URLs through, we need to + // tell Node.js what format such URLs should be interpreted as. For the + // purposes of this loader, all CoffeeScript URLs are ES modules. + if (extensionsRegex.test(url)) { + return { + format: 'module' + }; + } + + // Let Node.js handle all other URLs. + return defaultGetFormat(url, context, defaultGetFormat); +} + +export function transformSource(source, context, defaultTransformSource) { + const { url, format } = context; + + if (extensionsRegex.test(url)) { + return { + source: CoffeeScript.compile(source, { bare: true }) + }; + } + + // Let Node.js handle all other sources. + return defaultTransformSource(source, context, defaultTransformSource); +} +``` + +```coffee +# main.coffee +import { scream } from './scream.coffee' +console.log scream 'hello, world' + +import { version } from 'process' +console.log "Brought to you by Node.js version #{version}" +``` + +```coffee +# scream.coffee +export scream = (str) -> str.toUpperCase() +``` + +With this loader, running: + +```console +node --experimental-loader ./coffeescript-loader.mjs main.coffee +``` + +Will cause `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. + ## Resolution Algorithm ### Features @@ -1415,11 +1648,14 @@ success! [`data:` URLs]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs [`esm`]: https://github.com/standard-things/esm#readme [`export`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export +[`getFormat` hook]: #esm_code_getformat_code_hook [`import()`]: #esm_import-expressions [`import.meta.url`]: #esm_import_meta [`import`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import [`module.createRequire()`]: modules.html#modules_module_createrequire_filename [`module.syncBuiltinESMExports()`]: modules.html#modules_module_syncbuiltinesmexports -[dynamic instantiate hook]: #esm_dynamic_instantiate_hook +[`transformSource` hook]: #esm_code_transformsource_code_hook +[dynamic instantiate hook]: #esm_code_dynamicinstantiate_code_hook [special scheme]: https://url.spec.whatwg.org/#special-scheme [the official standard format]: https://tc39.github.io/ecma262/#sec-modules +[transpiler loader example]: #esm_transpiler_loader diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 5aa5a5f50fc396..724701635d670c 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1326,7 +1326,7 @@ 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', - 'Unknown file extension "%s" for %s imported from %s', + 'Unknown file extension "%s" for %s', TypeError); E('ERR_UNKNOWN_MODULE_FORMAT', 'Unknown module format: %s', RangeError); E('ERR_UNKNOWN_SIGNAL', 'Unknown signal: %s', TypeError); diff --git a/lib/internal/main/check_syntax.js b/lib/internal/main/check_syntax.js index a3aba9a00fb5e2..f69e7b6ba5f38f 100644 --- a/lib/internal/main/check_syntax.js +++ b/lib/internal/main/check_syntax.js @@ -53,8 +53,10 @@ function checkSyntax(source, filename) { if (filename === '[stdin]' || filename === '[eval]') { isModule = getOptionValue('--input-type') === 'module'; } else { - const resolve = require('internal/modules/esm/default_resolve'); - const { format } = resolve(pathToFileURL(filename).toString()); + const { defaultResolve } = require('internal/modules/esm/resolve'); + const { defaultGetFormat } = require('internal/modules/esm/get_format'); + const { url } = defaultResolve(pathToFileURL(filename).toString()); + const { format } = defaultGetFormat(url); isModule = format === 'module'; } if (isModule) { diff --git a/lib/internal/modules/esm/default_resolve.js b/lib/internal/modules/esm/default_resolve.js deleted file mode 100644 index 749f6861cd3ee6..00000000000000 --- a/lib/internal/modules/esm/default_resolve.js +++ /dev/null @@ -1,135 +0,0 @@ -'use strict'; - -const { - SafeMap, -} = primordials; - -const internalFS = require('internal/fs/utils'); -const { NativeModule } = require('internal/bootstrap/loaders'); -const { extname } = require('path'); -const { realpathSync } = require('fs'); -const { getOptionValue } = require('internal/options'); - -const preserveSymlinks = getOptionValue('--preserve-symlinks'); -const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main'); -const experimentalJsonModules = getOptionValue('--experimental-json-modules'); -const experimentalSpeciferResolution = - getOptionValue('--experimental-specifier-resolution'); -const typeFlag = getOptionValue('--input-type'); -const experimentalWasmModules = getOptionValue('--experimental-wasm-modules'); -const { resolve: moduleWrapResolve, - getPackageType } = internalBinding('module_wrap'); -const { URL, pathToFileURL, fileURLToPath } = require('internal/url'); -const { ERR_INPUT_TYPE_NOT_ALLOWED, - ERR_UNKNOWN_FILE_EXTENSION, - ERR_UNSUPPORTED_ESM_URL_SCHEME } = require('internal/errors').codes; - -const realpathCache = new SafeMap(); - -// const TYPE_NONE = 0; -// const TYPE_COMMONJS = 1; -const TYPE_MODULE = 2; - -const extensionFormatMap = { - '__proto__': null, - '.cjs': 'commonjs', - '.js': 'module', - '.mjs': 'module' -}; - -const legacyExtensionFormatMap = { - '__proto__': null, - '.cjs': 'commonjs', - '.js': 'commonjs', - '.json': 'commonjs', - '.mjs': 'module', - '.node': 'commonjs' -}; - -if (experimentalWasmModules) - extensionFormatMap['.wasm'] = legacyExtensionFormatMap['.wasm'] = 'wasm'; - -if (experimentalJsonModules) - extensionFormatMap['.json'] = legacyExtensionFormatMap['.json'] = 'json'; - -function resolve(specifier, parentURL) { - let parsed; - try { - parsed = new URL(specifier); - if (parsed.protocol === 'data:') { - const [ , mime ] = /^([^/]+\/[^;,]+)(?:[^,]*?)(;base64)?,/.exec(parsed.pathname) || [ null, null, null ]; - const format = ({ - '__proto__': null, - 'text/javascript': 'module', - 'application/json': 'json', - 'application/wasm': experimentalWasmModules ? 'wasm' : null - })[mime] || null; - return { - url: specifier, - format - }; - } - } catch {} - if (parsed && parsed.protocol !== 'file:' && parsed.protocol !== 'data:') - throw new ERR_UNSUPPORTED_ESM_URL_SCHEME(); - if (NativeModule.canBeRequiredByUsers(specifier)) { - return { - url: specifier, - format: 'builtin' - }; - } - if (parentURL && parentURL.startsWith('data:')) { - // This is gonna blow up, we want the error - new URL(specifier, parentURL); - } - - const isMain = parentURL === undefined; - if (isMain) { - parentURL = pathToFileURL(`${process.cwd()}/`).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 - // --eval, --print or STDIN string input. It is not allowed with file - // 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(); - } - - let url = moduleWrapResolve(specifier, parentURL); - - if (isMain ? !preserveSymlinksMain : !preserveSymlinks) { - const real = realpathSync(fileURLToPath(url), { - [internalFS.realpathCacheKey]: realpathCache - }); - const old = url; - url = pathToFileURL(real); - url.search = old.search; - url.hash = old.hash; - } - - const ext = extname(url.pathname); - let format; - if (ext === '.js' || ext === '') { - format = getPackageType(url.href) === TYPE_MODULE ? 'module' : 'commonjs'; - } else { - format = extensionFormatMap[ext]; - } - if (!format) { - if (experimentalSpeciferResolution === 'node') { - process.emitWarning( - 'The Node.js specifier resolution in ESM is experimental.', - 'ExperimentalWarning'); - format = legacyExtensionFormatMap[ext]; - } else { - throw new ERR_UNKNOWN_FILE_EXTENSION( - ext, - fileURLToPath(url), - fileURLToPath(parentURL)); - } - } - return { url: `${url}`, format }; -} - -module.exports = resolve; diff --git a/lib/internal/modules/esm/get_format.js b/lib/internal/modules/esm/get_format.js new file mode 100644 index 00000000000000..2c215ab5378a40 --- /dev/null +++ b/lib/internal/modules/esm/get_format.js @@ -0,0 +1,77 @@ +'use strict'; + +const { NativeModule } = require('internal/bootstrap/loaders'); +const { extname } = require('path'); +const { getOptionValue } = require('internal/options'); + +const experimentalJsonModules = getOptionValue('--experimental-json-modules'); +const experimentalSpeciferResolution = + getOptionValue('--experimental-specifier-resolution'); +const experimentalWasmModules = getOptionValue('--experimental-wasm-modules'); +const { getPackageType } = internalBinding('module_wrap'); +const { URL, fileURLToPath } = require('internal/url'); +const { ERR_UNKNOWN_FILE_EXTENSION } = require('internal/errors').codes; + +// const TYPE_NONE = 0; +// const TYPE_COMMONJS = 1; +const TYPE_MODULE = 2; + +const extensionFormatMap = { + '__proto__': null, + '.cjs': 'commonjs', + '.js': 'module', + '.mjs': 'module' +}; + +const legacyExtensionFormatMap = { + '__proto__': null, + '.cjs': 'commonjs', + '.js': 'commonjs', + '.json': 'commonjs', + '.mjs': 'module', + '.node': 'commonjs' +}; + +if (experimentalWasmModules) + extensionFormatMap['.wasm'] = legacyExtensionFormatMap['.wasm'] = 'wasm'; + +if (experimentalJsonModules) + extensionFormatMap['.json'] = legacyExtensionFormatMap['.json'] = 'json'; + +function defaultGetFormat(url, context, defaultGetFormat) { + if (NativeModule.canBeRequiredByUsers(url)) { + return { format: 'builtin' }; + } + const parsed = new URL(url); + if (parsed.protocol === 'data:') { + const [ , mime ] = /^([^/]+\/[^;,]+)(?:[^,]*?)(;base64)?,/.exec(parsed.pathname) || [ null, null, null ]; + const format = ({ + '__proto__': null, + 'text/javascript': 'module', + 'application/json': experimentalJsonModules ? 'json' : null, + 'application/wasm': experimentalWasmModules ? 'wasm' : null + })[mime] || null; + return { format }; + } else if (parsed.protocol === 'file:') { + const ext = extname(parsed.pathname); + let format; + if (ext === '.js' || ext === '') { + format = getPackageType(parsed.href) === TYPE_MODULE ? + 'module' : 'commonjs'; + } else { + format = extensionFormatMap[ext]; + } + if (!format) { + if (experimentalSpeciferResolution === 'node') { + process.emitWarning( + 'The Node.js specifier resolution in ESM is experimental.', + 'ExperimentalWarning'); + format = legacyExtensionFormatMap[ext]; + } else { + throw new ERR_UNKNOWN_FILE_EXTENSION(ext, fileURLToPath(url)); + } + } + return { format: format || null }; + } +} +exports.defaultGetFormat = defaultGetFormat; diff --git a/lib/internal/modules/esm/get_source.js b/lib/internal/modules/esm/get_source.js new file mode 100644 index 00000000000000..18af566df90ae3 --- /dev/null +++ b/lib/internal/modules/esm/get_source.js @@ -0,0 +1,35 @@ +'use strict'; + +const { Buffer } = require('buffer'); + +const fs = require('fs'); +const { URL } = require('url'); +const { promisify } = require('internal/util'); +const { + ERR_INVALID_URL, + ERR_INVALID_URL_SCHEME, +} = require('internal/errors').codes; +const readFileAsync = promisify(fs.readFile); + +const DATA_URL_PATTERN = /^[^/]+\/[^,;]+(?:[^,]*?)(;base64)?,([\s\S]*)$/; + +async function defaultGetSource(url, { format } = {}, defaultGetSource) { + const parsed = new URL(url); + if (parsed.protocol === 'file:') { + return { + source: await readFileAsync(parsed) + }; + } else if (parsed.protocol === 'data:') { + const match = DATA_URL_PATTERN.exec(parsed.pathname); + if (!match) { + throw new ERR_INVALID_URL(url); + } + const [ , base64, body ] = match; + return { + source: Buffer.from(body, base64 ? 'base64' : 'utf8') + }; + } else { + throw new ERR_INVALID_URL_SCHEME(['file', 'data']); + } +} +exports.defaultGetSource = defaultGetSource; diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 255e5d2aba7bd8..6d9b267ffe5d67 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -13,18 +13,21 @@ const { ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK, ERR_UNKNOWN_MODULE_FORMAT } = require('internal/errors').codes; -const { - URL, - pathToFileURL -} = require('url'); +const { URL, pathToFileURL } = require('internal/url'); const { validateString } = require('internal/validators'); const ModuleMap = require('internal/modules/esm/module_map'); const ModuleJob = require('internal/modules/esm/module_job'); -const defaultResolve = require('internal/modules/esm/default_resolve'); +const { defaultResolve } = require('internal/modules/esm/resolve'); +const { defaultGetFormat } = require('internal/modules/esm/get_format'); +const { defaultGetSource } = require( + 'internal/modules/esm/get_source'); +const { defaultTransformSource } = require( + 'internal/modules/esm/transform_source'); const createDynamicModule = require( 'internal/modules/esm/create_dynamic_module'); -const { translators } = require('internal/modules/esm/translators'); +const { translators } = require( + 'internal/modules/esm/translators'); const { getOptionValue } = require('internal/options'); const debug = require('internal/util/debuglog').debuglog('esm'); @@ -46,13 +49,23 @@ class Loader { // The resolver has the signature // (specifier : string, parentURL : string, defaultResolve) - // -> Promise<{ url : string, format: string }> + // -> Promise<{ url : string }> // where defaultResolve is ModuleRequest.resolve (having the same // signature itself). + this._resolve = defaultResolve; + // This hook is called after the module is resolved but before a translator + // is chosen to load it; the format returned by this function is the name + // of a translator. // If `.format` on the returned value is 'dynamic', .dynamicInstantiate // will be used as described below. - this._resolve = defaultResolve; - // This hook is only called when resolve(...).format is 'dynamic' and + this._getFormat = defaultGetFormat; + // This hook is called just before the source code of an ES module file + // is loaded. + this._getSource = defaultGetSource; + // This hook is called just after the source code of an ES module file + // is loaded, but before anything is done with the string. + this._transformSource = defaultTransformSource; + // This hook is only called when getFormat is 'dynamic' and // has the signature // (url : string) -> Promise<{ exports: { ... }, execute: function }> // Where `exports` is an object whose property names define the exported @@ -69,27 +82,35 @@ class Loader { if (!isMain) validateString(parentURL, 'parentURL'); - const resolved = await this._resolve(specifier, parentURL, defaultResolve); - - if (typeof resolved !== 'object') + const resolveResponse = await this._resolve( + specifier, { parentURL }, defaultResolve); + if (typeof resolveResponse !== 'object') { throw new ERR_INVALID_RETURN_VALUE( - 'object', 'loader resolve', resolved - ); - - const { url, format } = resolved; + 'object', 'loader resolve', resolveResponse); + } - if (typeof url !== 'string') + const { url } = resolveResponse; + if (typeof url !== 'string') { throw new ERR_INVALID_RETURN_PROPERTY_VALUE( - 'string', 'loader resolve', 'url', url - ); + 'string', 'loader resolve', 'url', url); + } - if (typeof format !== 'string') + const getFormatResponse = await this._getFormat( + url, {}, defaultGetFormat); + if (typeof getFormatResponse !== 'object') { + throw new ERR_INVALID_RETURN_VALUE( + 'object', 'loader getFormat', getFormatResponse); + } + + const { format } = getFormatResponse; + if (typeof format !== 'string') { throw new ERR_INVALID_RETURN_PROPERTY_VALUE( - 'string', 'loader resolve', 'format', format - ); + 'string', 'loader getFormat', 'format', format); + } - if (format === 'builtin') + if (format === 'builtin') { return { url: `node:${url}`, format }; + } if (this._resolve !== defaultResolve) { try { @@ -101,13 +122,15 @@ class Loader { } } - if (format !== 'dynamic' && + if (this._resolve === defaultResolve && + format !== 'dynamic' && !url.startsWith('file:') && !url.startsWith('data:') - ) + ) { throw new ERR_INVALID_RETURN_PROPERTY( 'file: or data: url', 'loader resolve', 'url', url ); + } return { url, format }; } @@ -142,7 +165,7 @@ class Loader { return module.getNamespace(); } - hook({ resolve, dynamicInstantiate }) { + hook({ resolve, dynamicInstantiate, getFormat, getSource, transformSource }) { // Use .bind() to avoid giving access to the Loader instance when called. if (resolve !== undefined) this._resolve = FunctionPrototypeBind(resolve, null); @@ -150,6 +173,15 @@ class Loader { this._dynamicInstantiate = FunctionPrototypeBind(dynamicInstantiate, null); } + if (getFormat !== undefined) { + this._getFormat = FunctionPrototypeBind(getFormat, null); + } + if (getSource !== undefined) { + this._getSource = FunctionPrototypeBind(getSource, null); + } + if (transformSource !== undefined) { + this._transformSource = FunctionPrototypeBind(transformSource, null); + } } async getModuleJob(specifier, parentURL) { diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js new file mode 100644 index 00000000000000..f1045871dddb72 --- /dev/null +++ b/lib/internal/modules/esm/resolve.js @@ -0,0 +1,72 @@ +'use strict'; + +const { + SafeMap, +} = primordials; + +const internalFS = require('internal/fs/utils'); +const { NativeModule } = require('internal/bootstrap/loaders'); +const { realpathSync } = require('fs'); +const { getOptionValue } = require('internal/options'); + +const preserveSymlinks = getOptionValue('--preserve-symlinks'); +const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main'); +const typeFlag = getOptionValue('--input-type'); +const { resolve: moduleWrapResolve } = internalBinding('module_wrap'); +const { URL, pathToFileURL, fileURLToPath } = require('internal/url'); +const { ERR_INPUT_TYPE_NOT_ALLOWED, + ERR_UNSUPPORTED_ESM_URL_SCHEME } = require('internal/errors').codes; + +const realpathCache = new SafeMap(); + +function defaultResolve(specifier, { parentURL } = {}, defaultResolve) { + let parsed; + try { + parsed = new URL(specifier); + if (parsed.protocol === 'data:') { + return { + url: specifier + }; + } + } catch {} + if (parsed && parsed.protocol !== 'file:' && parsed.protocol !== 'data:') + throw new ERR_UNSUPPORTED_ESM_URL_SCHEME(); + if (NativeModule.canBeRequiredByUsers(specifier)) { + return { + url: specifier + }; + } + if (parentURL && parentURL.startsWith('data:')) { + // This is gonna blow up, we want the error + new URL(specifier, parentURL); + } + + const isMain = parentURL === undefined; + if (isMain) { + parentURL = pathToFileURL(`${process.cwd()}/`).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 + // --eval, --print or STDIN string input. It is not allowed with file + // 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(); + } + + let url = moduleWrapResolve(specifier, parentURL); + + if (isMain ? !preserveSymlinksMain : !preserveSymlinks) { + const real = realpathSync(fileURLToPath(url), { + [internalFS.realpathCacheKey]: realpathCache + }); + const old = url; + url = pathToFileURL(real); + url.search = old.search; + url.hash = old.hash; + } + + return { url: `${url}` }; +} +exports.defaultResolve = defaultResolve; diff --git a/lib/internal/modules/esm/transform_source.js b/lib/internal/modules/esm/transform_source.js new file mode 100644 index 00000000000000..2d07dd3607fb66 --- /dev/null +++ b/lib/internal/modules/esm/transform_source.js @@ -0,0 +1,7 @@ +'use strict'; + +function defaultTransformSource(source, { url, format } = {}, + defaultTransformSource) { + return { source }; +} +exports.defaultTransformSource = defaultTransformSource; diff --git a/lib/internal/modules/esm/translators.js b/lib/internal/modules/esm/translators.js index 99e4c014053202..9f3bcfb8e7db9d 100644 --- a/lib/internal/modules/esm/translators.js +++ b/lib/internal/modules/esm/translators.js @@ -9,26 +9,22 @@ const { StringPrototypeReplace, } = primordials; -const { Buffer } = require('buffer'); - const { stripBOM, loadNativeModule } = require('internal/modules/cjs/helpers'); const CJSModule = require('internal/modules/cjs/loader').Module; const internalURLModule = require('internal/url'); +const { defaultGetSource } = require( + 'internal/modules/esm/get_source'); +const { defaultTransformSource } = require( + 'internal/modules/esm/transform_source'); const createDynamicModule = require( 'internal/modules/esm/create_dynamic_module'); -const fs = require('fs'); const { fileURLToPath, URL } = require('url'); const { debuglog } = require('internal/util/debuglog'); -const { promisify, emitExperimentalWarning } = require('internal/util'); -const { - ERR_INVALID_URL, - ERR_INVALID_URL_SCHEME, - ERR_UNKNOWN_BUILTIN_MODULE -} = require('internal/errors').codes; -const readFileAsync = promisify(fs.readFile); +const { emitExperimentalWarning } = require('internal/util'); +const { ERR_UNKNOWN_BUILTIN_MODULE } = require('internal/errors').codes; const { maybeCacheSourceMap } = require('internal/source_map/source_map_cache'); const moduleWrap = internalBinding('module_wrap'); const { ModuleWrap } = moduleWrap; @@ -38,23 +34,6 @@ const debug = debuglog('esm'); const translators = new SafeMap(); exports.translators = translators; -const DATA_URL_PATTERN = /^[^/]+\/[^,;]+(?:[^,]*?)(;base64)?,([\s\S]*)$/; -function getSource(url) { - const parsed = new URL(url); - if (parsed.protocol === 'file:') { - return readFileAsync(parsed); - } else if (parsed.protocol === 'data:') { - const match = DATA_URL_PATTERN.exec(parsed.pathname); - if (!match) { - throw new ERR_INVALID_URL(url); - } - const [ , base64, body ] = match; - return Buffer.from(body, base64 ? 'base64' : 'utf8'); - } else { - throw new ERR_INVALID_URL_SCHEME(['file', 'data']); - } -} - function errPath(url) { const parsed = new URL(url); if (parsed.protocol === 'file:') { @@ -77,7 +56,11 @@ async function importModuleDynamically(specifier, { url }) { // Strategy for loading a standard JavaScript module translators.set('module', async function moduleStrategy(url) { - const source = `${await getSource(url)}`; + let { source } = await this._getSource( + url, { format: 'module' }, defaultGetSource); + source = `${source}`; + ({ source } = await this._transformSource( + source, { url, format: 'module' }, defaultTransformSource)); maybeCacheSourceMap(url, source); debug(`Translating StandardModule ${url}`); const module = new ModuleWrap(url, undefined, source, 0, 0); @@ -150,7 +133,11 @@ translators.set('json', async function jsonStrategy(url) { }); } } - const content = `${await getSource(url)}`; + let { source } = await this._getSource( + url, { format: 'json' }, defaultGetSource); + source = `${source}`; + ({ source } = await this._transformSource( + source, { url, format: 'json' }, defaultTransformSource)); if (pathname) { // 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 @@ -164,7 +151,7 @@ translators.set('json', async function jsonStrategy(url) { } } try { - const exports = JSONParse(stripBOM(content)); + const exports = JSONParse(stripBOM(source)); module = { exports, loaded: true @@ -189,11 +176,14 @@ translators.set('json', async function jsonStrategy(url) { // Strategy for loading a wasm module translators.set('wasm', async function(url) { emitExperimentalWarning('Importing Web Assembly modules'); - const buffer = await getSource(url); + let { source } = await this._getSource( + url, { format: 'wasm' }, defaultGetSource); + ({ source } = await this._transformSource( + source, { url, format: 'wasm' }, defaultTransformSource)); debug(`Translating WASMModule ${url}`); let compiled; try { - compiled = await WebAssembly.compile(buffer); + compiled = await WebAssembly.compile(source); } catch (err) { err.message = errPath(url) + ': ' + err.message; throw err; diff --git a/node.gyp b/node.gyp index 5929785389fc7c..51c424d65bdfe8 100644 --- a/node.gyp +++ b/node.gyp @@ -155,9 +155,12 @@ 'lib/internal/modules/cjs/loader.js', 'lib/internal/modules/esm/loader.js', 'lib/internal/modules/esm/create_dynamic_module.js', - 'lib/internal/modules/esm/default_resolve.js', + 'lib/internal/modules/esm/get_format.js', + 'lib/internal/modules/esm/get_source.js', 'lib/internal/modules/esm/module_job.js', 'lib/internal/modules/esm/module_map.js', + 'lib/internal/modules/esm/resolve.js', + 'lib/internal/modules/esm/transform_source.js', 'lib/internal/modules/esm/translators.js', 'lib/internal/net.js', 'lib/internal/options.js', diff --git a/test/es-module/test-esm-data-urls.js b/test/es-module/test-esm-data-urls.js index 221d91337ec8d1..a7b917ec74e297 100644 --- a/test/es-module/test-esm-data-urls.js +++ b/test/es-module/test-esm-data-urls.js @@ -1,4 +1,4 @@ -// Flags: --experimental-modules +// Flags: --experimental-modules --experimental-json-modules 'use strict'; const common = require('../common'); const assert = require('assert'); diff --git a/test/es-module/test-esm-get-source-loader.mjs b/test/es-module/test-esm-get-source-loader.mjs new file mode 100644 index 00000000000000..12ec5d0173db9e --- /dev/null +++ b/test/es-module/test-esm-get-source-loader.mjs @@ -0,0 +1,6 @@ +// Flags: --experimental-modules --experimental-loader ./test/fixtures/es-module-loaders/get-source.mjs +/* eslint-disable node-core/require-common-first, node-core/required-modules */ +import assert from 'assert'; +import { message } from '../fixtures/es-modules/message.mjs'; + +assert.strictEqual(message, 'WOOHOO!'); diff --git a/test/es-module/test-esm-invalid-extension.js b/test/es-module/test-esm-invalid-extension.js index 9255083b481f36..414b68776278ac 100644 --- a/test/es-module/test-esm-invalid-extension.js +++ b/test/es-module/test-esm-invalid-extension.js @@ -6,10 +6,8 @@ const { spawnSync } = require('child_process'); const fixture = fixtures.path('/es-modules/import-invalid-ext.mjs'); const child = spawnSync(process.execPath, ['--experimental-modules', fixture]); const errMsg = 'TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension'; -const importMsg = `imported from ${fixture}`; assert.strictEqual(child.status, 1); assert.strictEqual(child.signal, null); assert.strictEqual(child.stdout.toString().trim(), ''); -assert(child.stderr.toString().includes(errMsg)); -assert(child.stderr.toString().includes(importMsg)); +assert.ok(child.stderr.toString().includes(errMsg)); diff --git a/test/es-module/test-esm-loader-get-format.mjs b/test/es-module/test-esm-loader-get-format.mjs new file mode 100644 index 00000000000000..4fb40db39f75dd --- /dev/null +++ b/test/es-module/test-esm-loader-get-format.mjs @@ -0,0 +1,12 @@ +// Flags: --experimental-modules --experimental-loader ./test/fixtures/es-module-loaders/loader-get-format.mjs +import { mustCall, mustNotCall } from '../common/index.mjs'; +import assert from 'assert'; + +import('../fixtures/es-modules/package-type-module/extension.unknown') +.then( + mustCall((ns) => { + assert.strictEqual(ns.default, 'unknown'); + }), + // Do not use .catch; want exclusive or + mustNotCall(() => {}) +); diff --git a/test/es-module/test-esm-loader-invalid-format.mjs b/test/es-module/test-esm-loader-invalid-format.mjs index 9e26d646d479a1..d7a91aaa38b8e3 100644 --- a/test/es-module/test-esm-loader-invalid-format.mjs +++ b/test/es-module/test-esm-loader-invalid-format.mjs @@ -4,8 +4,7 @@ import assert from 'assert'; import('../fixtures/es-modules/test-esm-ok.mjs') .then(assert.fail, expectsError({ - code: 'ERR_INVALID_RETURN_PROPERTY_VALUE', - message: 'Expected string to be returned for the "format" from the ' + - '"loader resolve" function but got type undefined.' + code: 'ERR_UNKNOWN_MODULE_FORMAT', + message: /Unknown module format: esm/ })) .then(mustCall()); diff --git a/test/es-module/test-esm-loader-invalid-url.mjs b/test/es-module/test-esm-loader-invalid-url.mjs index f42900c58e049c..74dba3485bb2ab 100644 --- a/test/es-module/test-esm-loader-invalid-url.mjs +++ b/test/es-module/test-esm-loader-invalid-url.mjs @@ -4,9 +4,7 @@ import assert from 'assert'; import('../fixtures/es-modules/test-esm-ok.mjs') .then(assert.fail, expectsError({ - code: 'ERR_INVALID_RETURN_PROPERTY', - message: 'Expected a valid url to be returned for the "url" from the ' + - '"loader resolve" function but got ' + - '../fixtures/es-modules/test-esm-ok.mjs.' + code: 'ERR_INVALID_URL', + message: 'Invalid URL: ../fixtures/es-modules/test-esm-ok.mjs' })) .then(mustCall()); diff --git a/test/es-module/test-esm-loader-search.js b/test/es-module/test-esm-loader-search.js index b5e0d8d656f65f..3c451409b356db 100644 --- a/test/es-module/test-esm-loader-search.js +++ b/test/es-module/test-esm-loader-search.js @@ -3,15 +3,18 @@ // This test ensures that search throws errors appropriately -const common = require('../common'); +require('../common'); -const resolve = require('internal/modules/esm/default_resolve'); +const assert = require('assert'); +const { + defaultResolve: resolve +} = require('internal/modules/esm/resolve'); -common.expectsError( - () => resolve('target', undefined), +assert.throws( + () => resolve('target'), { code: 'ERR_MODULE_NOT_FOUND', - type: Error, + name: 'Error', message: /Cannot find package 'target'/ } ); diff --git a/test/es-module/test-esm-transform-source-loader.mjs b/test/es-module/test-esm-transform-source-loader.mjs new file mode 100644 index 00000000000000..03c7f67431c888 --- /dev/null +++ b/test/es-module/test-esm-transform-source-loader.mjs @@ -0,0 +1,6 @@ +// Flags: --experimental-modules --experimental-loader ./test/fixtures/es-module-loaders/transform-source.mjs +/* eslint-disable node-core/require-common-first, node-core/required-modules */ +import assert from 'assert'; +import { message } from '../fixtures/es-modules/message.mjs'; + +assert.strictEqual(message, 'A MESSAGE'); 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 a944c4fd5ebc67..9f1bc24560b87a 100644 --- a/test/fixtures/es-module-loaders/builtin-named-exports-loader.mjs +++ b/test/fixtures/es-module-loaders/builtin-named-exports-loader.mjs @@ -1,7 +1,16 @@ import module from 'module'; +export function getFormat(url, context, defaultGetFormat) { + if (module.builtinModules.includes(url)) { + return { + format: 'dynamic' + }; + } + return defaultGetFormat(url, context, defaultGetFormat); +} + export function dynamicInstantiate(url) { - const builtinInstance = module._load(url.substr(5)); + const builtinInstance = module._load(url); const builtinExports = ['default', ...Object.keys(builtinInstance)]; return { exports: builtinExports, @@ -12,13 +21,3 @@ export function dynamicInstantiate(url) { } }; } - -export function resolve(specifier, base, defaultResolver) { - if (module.builtinModules.includes(specifier)) { - return { - url: `node:${specifier}`, - format: 'dynamic' - }; - } - return defaultResolver(specifier, base); -} diff --git a/test/fixtures/es-module-loaders/example-loader.mjs b/test/fixtures/es-module-loaders/example-loader.mjs index ed5b0d9be5940d..70f9f28f08e742 100644 --- a/test/fixtures/es-module-loaders/example-loader.mjs +++ b/test/fixtures/es-module-loaders/example-loader.mjs @@ -1,34 +1,44 @@ -import url from 'url'; +import { URL } from 'url'; import path from 'path'; import process from 'process'; import { builtinModules } from 'module'; const JS_EXTENSIONS = new Set(['.js', '.mjs']); -const baseURL = new url.URL('file://'); +const baseURL = new URL('file://'); baseURL.pathname = process.cwd() + '/'; -export function resolve(specifier, parentModuleURL = baseURL /*, defaultResolve */) { +export function resolve(specifier, { parentURL = baseURL }, defaultResolve) { if (builtinModules.includes(specifier)) { return { - url: specifier, - format: 'builtin' + url: specifier }; } if (/^\.{1,2}[/]/.test(specifier) !== true && !specifier.startsWith('file:')) { // For node_modules support: - // return defaultResolve(specifier, parentModuleURL); + // return defaultResolve(specifier, {parentURL}, defaultResolve); throw new Error( `imports must be URLs or begin with './', or '../'; '${specifier}' does not`); } - const resolved = new url.URL(specifier, parentModuleURL); - const ext = path.extname(resolved.pathname); + const resolved = new URL(specifier, parentURL); + return { + url: resolved.href + }; +} + +export function getFormat(url, context, defaultGetFormat) { + if (builtinModules.includes(url)) { + return { + format: 'builtin' + }; + } + const { pathname } = new URL(url); + const ext = path.extname(pathname); if (!JS_EXTENSIONS.has(ext)) { throw new Error( `Cannot load file with non-JavaScript file extension ${ext}.`); } return { - url: resolved.href, format: 'module' }; } diff --git a/test/fixtures/es-module-loaders/get-source.mjs b/test/fixtures/es-module-loaders/get-source.mjs new file mode 100644 index 00000000000000..e5a9c65201aa28 --- /dev/null +++ b/test/fixtures/es-module-loaders/get-source.mjs @@ -0,0 +1,10 @@ +export async function getSource(url, { format }, defaultGetSource) { + if (url.endsWith('fixtures/es-modules/message.mjs')) { + // Oh, I’ve got that one in my cache! + return { + source: `export const message = 'Woohoo!'.toUpperCase();` + } + } else { + return defaultGetSource(url, {format}, defaultGetSource); + } +} diff --git a/test/fixtures/es-module-loaders/js-loader.mjs b/test/fixtures/es-module-loaders/js-loader.mjs index 4b8a0fc365f3ac..2f79475e77e269 100644 --- a/test/fixtures/es-module-loaders/js-loader.mjs +++ b/test/fixtures/es-module-loaders/js-loader.mjs @@ -1,20 +1,9 @@ -import { URL } from 'url'; -import { builtinModules } from 'module'; - -const baseURL = new URL('file://'); -baseURL.pathname = process.cwd() + '/'; - -export function resolve (specifier, base = baseURL) { - if (builtinModules.includes(specifier)) { +export function getFormat(url, context, defaultGetFormat) { + // Load all .js files as ESM, regardless of package scope + if (url.endsWith('.js')) { return { - url: specifier, - format: 'builtin' - }; + format: 'module' + } } - // load all dependencies as esm, regardless of file extension - const url = new URL(specifier, base).href; - return { - url, - format: 'module' - }; + return defaultGetFormat(url, context, defaultGetFormat); } diff --git a/test/fixtures/es-module-loaders/loader-get-format.mjs b/test/fixtures/es-module-loaders/loader-get-format.mjs new file mode 100644 index 00000000000000..7ade70fca0ebe6 --- /dev/null +++ b/test/fixtures/es-module-loaders/loader-get-format.mjs @@ -0,0 +1,10 @@ +export async function getFormat(url, context, defaultGetFormat) { + try { + if (new URL(url).pathname.endsWith('.unknown')) { + return { + format: 'module' + }; + } + } catch {} + return defaultGetFormat(url, context, defaultGetFormat); +} diff --git a/test/fixtures/es-module-loaders/loader-invalid-format.mjs b/test/fixtures/es-module-loaders/loader-invalid-format.mjs index 17a0dcd04daad9..55ae1cec8ee926 100644 --- a/test/fixtures/es-module-loaders/loader-invalid-format.mjs +++ b/test/fixtures/es-module-loaders/loader-invalid-format.mjs @@ -1,8 +1,17 @@ -export async function resolve(specifier, parentModuleURL, defaultResolve) { - if (parentModuleURL && specifier === '../fixtures/es-modules/test-esm-ok.mjs') { +export async function resolve(specifier, { parentURL }, defaultResolve) { + if (parentURL && specifier === '../fixtures/es-modules/test-esm-ok.mjs') { return { url: 'file:///asdf' }; } - return defaultResolve(specifier, parentModuleURL); + return defaultResolve(specifier, {parentURL}, defaultResolve); +} + +export function getFormat(url, context, defaultGetFormat) { + if (url === 'file:///asdf') { + return { + format: 'esm' + } + } + return defaultGetFormat(url, context, defaultGetFormat); } diff --git a/test/fixtures/es-module-loaders/loader-invalid-url.mjs b/test/fixtures/es-module-loaders/loader-invalid-url.mjs index f653155899d6fc..e7de0d4ed92378 100644 --- a/test/fixtures/es-module-loaders/loader-invalid-url.mjs +++ b/test/fixtures/es-module-loaders/loader-invalid-url.mjs @@ -1,10 +1,9 @@ /* eslint-disable node-core/required-modules */ -export async function resolve(specifier, parentModuleURL, defaultResolve) { - if (parentModuleURL && specifier === '../fixtures/es-modules/test-esm-ok.mjs') { +export async function resolve(specifier, { parentURL }, defaultResolve) { + if (parentURL && specifier === '../fixtures/es-modules/test-esm-ok.mjs') { return { - url: specifier, - format: 'esm' + url: specifier }; } - return defaultResolve(specifier, parentModuleURL); + return defaultResolve(specifier, {parentURL}, defaultResolve); } diff --git a/test/fixtures/es-module-loaders/loader-shared-dep.mjs b/test/fixtures/es-module-loaders/loader-shared-dep.mjs index 3acafcce1ecf7a..3576c074d52cec 100644 --- a/test/fixtures/es-module-loaders/loader-shared-dep.mjs +++ b/test/fixtures/es-module-loaders/loader-shared-dep.mjs @@ -5,7 +5,7 @@ import {createRequire} from '../../common/index.mjs'; const require = createRequire(import.meta.url); const dep = require('./loader-dep.js'); -export function resolve(specifier, base, defaultResolve) { +export function resolve(specifier, { parentURL }, defaultResolve) { assert.strictEqual(dep.format, 'module'); - return defaultResolve(specifier, base); + return defaultResolve(specifier, {parentURL}, defaultResolve); } diff --git a/test/fixtures/es-module-loaders/loader-unknown-builtin-module.mjs b/test/fixtures/es-module-loaders/loader-unknown-builtin-module.mjs index e7c6c8ff345617..1a48231966ce5b 100644 --- a/test/fixtures/es-module-loaders/loader-unknown-builtin-module.mjs +++ b/test/fixtures/es-module-loaders/loader-unknown-builtin-module.mjs @@ -1,6 +1,17 @@ -export async function resolve(specifier, parent, defaultResolve) { +export async function resolve(specifier, { parentURL }, defaultResolve) { if (specifier === 'unknown-builtin-module') { - return { url: 'unknown-builtin-module', format: 'builtin' }; + return { + url: 'unknown-builtin-module' + }; } - return defaultResolve(specifier, parent); -} \ No newline at end of file + return defaultResolve(specifier, {parentURL}, defaultResolve); +} + +export async function getFormat(url, context, defaultGetFormat) { + if (url === 'unknown-builtin-module') { + return { + format: 'builtin' + }; + } + return defaultGetFormat(url, context, defaultGetFormat); +} diff --git a/test/fixtures/es-module-loaders/loader-with-dep.mjs b/test/fixtures/es-module-loaders/loader-with-dep.mjs index 5afd3b2e212322..da7d44ae793e22 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 function resolve (specifier, base, defaultResolve) { +export function resolve (specifier, { parentURL }, defaultResolve) { return { - url: defaultResolve(specifier, base).url, + url: defaultResolve(specifier, {parentURL}, defaultResolve).url, format: dep.format }; } diff --git a/test/fixtures/es-module-loaders/missing-dynamic-instantiate-hook.mjs b/test/fixtures/es-module-loaders/missing-dynamic-instantiate-hook.mjs index 6993747fcc0142..ec15eb0bb8fc24 100644 --- a/test/fixtures/es-module-loaders/missing-dynamic-instantiate-hook.mjs +++ b/test/fixtures/es-module-loaders/missing-dynamic-instantiate-hook.mjs @@ -1,6 +1,17 @@ -export function resolve(specifier, parentModule, defaultResolver) { - if (specifier !== 'test') { - return defaultResolver(specifier, parentModule); +export function resolve(specifier, { parentURL }, defaultResolve) { + if (specifier === 'test') { + return { + url: 'file://' + }; } - return { url: 'file://', format: 'dynamic' }; + return defaultResolve(specifier, {parentURL}, defaultResolve); +} + +export function getFormat(url, context, defaultGetFormat) { + if (url === 'file://') { + return { + format: 'dynamic' + } + } + return defaultGetFormat(url, context, defaultGetFormat); } 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 d3eebcd47ec906..7b1d176e4537f6 100644 --- a/test/fixtures/es-module-loaders/not-found-assert-loader.mjs +++ b/test/fixtures/es-module-loaders/not-found-assert-loader.mjs @@ -3,13 +3,13 @@ import assert from '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, base, defaultResolve) { +export async function resolve(specifier, { parentURL }, defaultResolve) { if (mainLoad) { mainLoad = false; - return defaultResolve(specifier, base); + return defaultResolve(specifier, {parentURL}, defaultResolve); } try { - await defaultResolve(specifier, base); + await defaultResolve(specifier, {parentURL}, defaultResolve); } catch (e) { assert.strictEqual(e.code, 'ERR_MODULE_NOT_FOUND'); diff --git a/test/fixtures/es-module-loaders/transform-source.mjs b/test/fixtures/es-module-loaders/transform-source.mjs new file mode 100644 index 00000000000000..ab147c34cb34fd --- /dev/null +++ b/test/fixtures/es-module-loaders/transform-source.mjs @@ -0,0 +1,11 @@ +export async function transformSource( + source, { url, format }, defaultTransformSource) { + if (source && source.replace) { + return { + source: source.replace(`'A message';`, `'A message'.toUpperCase();`) + }; + } else { // source could be a buffer, e.g. for WASM + return defaultTransformSource( + source, {url, format}, defaultTransformSource); + } +} diff --git a/test/fixtures/es-modules/package-type-module/extension.unknown b/test/fixtures/es-modules/package-type-module/extension.unknown index bd2b1aaa1ebece..ff62e978da69e3 100644 --- a/test/fixtures/es-modules/package-type-module/extension.unknown +++ b/test/fixtures/es-modules/package-type-module/extension.unknown @@ -1 +1 @@ -throw new Error('NO, NEVER'); +export default 'unknown'; diff --git a/test/message/esm_loader_not_found.out b/test/message/esm_loader_not_found.out index b03b7641af072b..770ffdc1cb3559 100644 --- a/test/message/esm_loader_not_found.out +++ b/test/message/esm_loader_not_found.out @@ -1,11 +1,11 @@ (node:*) ExperimentalWarning: The ESM module loader is experimental. (node:*) ExperimentalWarning: --experimental-loader is an experimental feature. This feature could change at any time -internal/modules/esm/default_resolve.js:* +internal/modules/esm/resolve.js:* let url = moduleWrapResolve(specifier, parentURL); ^ Error: Cannot find package 'i-dont-exist' imported from * - at Loader.resolve [as _resolve] (internal/modules/esm/default_resolve.js:*:*) + at Loader.defaultResolve [as _resolve] (internal/modules/esm/resolve.js:*:*) at Loader.resolve (internal/modules/esm/loader.js:*:*) at Loader.getModuleJob (internal/modules/esm/loader.js:*:*) at Loader.import (internal/modules/esm/loader.js:*:*)