module: loader getSource, getFormat, transform hooks

PR-URL: https://github.com/nodejs/node/pull/30986
Reviewed-By: Guy Bedford <guybedford@gmail.com>
Reviewed-By: Bradley Farias <bradley.meck@gmail.com>
This commit is contained in:
Geoffrey Booth 2019-12-14 22:27:48 -05:00 committed by Guy Bedford
parent 20fd12310f
commit 2551a21553
36 changed files with 744 additions and 345 deletions

View File

@ -1383,6 +1383,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 loader]: 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

View File

@ -994,7 +994,7 @@ node --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.**
@ -1006,39 +1006,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:
#### <code>resolve</code> 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.
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
import { URL, pathToFileURL } from 'url';
const baseURL = pathToFileURL(process.cwd()).href;
/**
* @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: <string>}
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.
#### <code>getFormat</code> 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 |
| --- | --- |
@ -1046,74 +1056,120 @@ 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';
const builtins = Module.builtinModules;
const JS_EXTENSIONS = new Set(['.js', '.mjs']);
const baseURL = pathToFileURL(process.cwd()).href;
/**
* @param {string} specifier
* @param {string} parentModuleURL
* @param {function} defaultResolver
* @param {string} url
* @param {object} context (currently empty)
* @param {function} defaultGetFormat
* @returns {object} response
* @returns {string} response.format
*/
export async function resolve(specifier,
parentModuleURL = baseURL,
defaultResolver) {
if (builtins.includes(specifier)) {
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: <string>}, where the
// format is one of the strings in the table above.
return {
url: specifier,
format: 'builtin'
format: 'module'
};
}
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 defaultGetFormat(url, context, defaultGetFormat);
}
```
With this loader, running:
#### <code>getSource</code> hook
```console
NODE_OPTIONS='--experimental-loader ./custom-loader.mjs' node x.js
> 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 `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} url
* @param {object} context
* @param {string} context.format
* @param {function} defaultGetSource
* @returns {object} response
* @returns {string|buffer} response.source
*/
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: <string|buffer>}.
return {
source: '...'
};
}
// Defer to Node.js for all other URLs.
return defaultGetSource(url, context, defaultGetSource);
}
```
would load the module `x.js` as an ES module with relative resolution support
(with `node_modules` loading skipped in this example).
#### <code>transformSource</code> 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.
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: <string|buffer>}.
return {
source: '...'
};
}
// Defer to Node.js for all other sources.
return defaultTransformSource(
source, context, defaultTransformSource);
}
```
#### <code>dynamicInstantiate</code> 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'],
@ -1129,6 +1185,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 doesnt 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
@ -1409,11 +1638,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

View File

@ -1334,7 +1334,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);

View File

@ -51,8 +51,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) {

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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
);
'object', 'loader resolve', resolveResponse);
}
const { url, format } = resolved;
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) {

View File

@ -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;

View File

@ -0,0 +1,7 @@
'use strict';
function defaultTransformSource(source, { url, format } = {},
defaultTransformSource) {
return { source };
}
exports.defaultTransformSource = defaultTransformSource;

View File

@ -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;

View File

@ -156,9 +156,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',

View File

@ -1,3 +1,4 @@
// Flags: --experimental-json-modules
'use strict';
const common = require('../common');
const assert = require('assert');

View File

@ -0,0 +1,6 @@
// Flags: --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!');

View File

@ -6,10 +6,8 @@ const { spawnSync } = require('child_process');
const fixture = fixtures.path('/es-modules/import-invalid-ext.mjs');
const child = spawnSync(process.execPath, [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));

View File

@ -0,0 +1,12 @@
// Flags: --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(() => {})
);

View File

@ -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());

View File

@ -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());

View File

@ -6,10 +6,12 @@
require('../common');
const assert = require('assert');
const resolve = require('internal/modules/esm/default_resolve');
const {
defaultResolve: resolve
} = require('internal/modules/esm/resolve');
assert.throws(
() => resolve('target', undefined),
() => resolve('target'),
{
code: 'ERR_MODULE_NOT_FOUND',
name: 'Error',

View File

@ -0,0 +1,6 @@
// Flags: --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');

View File

@ -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);
}

View File

@ -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'
};
}

View File

@ -0,0 +1,10 @@
export async function getSource(url, { format }, defaultGetSource) {
if (url.endsWith('fixtures/es-modules/message.mjs')) {
// Oh, Ive got that one in my cache!
return {
source: `export const message = 'Woohoo!'.toUpperCase();`
}
} else {
return defaultGetSource(url, {format}, defaultGetSource);
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}
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);
}

View File

@ -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
};
}

View File

@ -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);
}

View File

@ -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');

View File

@ -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);
}
}

View File

@ -1 +1 @@
throw new Error('NO, NEVER');
export default 'unknown';

View File

@ -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:*:*)

View File

@ -50,10 +50,13 @@ const expectedModules = new Set([
'NativeModule internal/modules/cjs/helpers',
'NativeModule internal/modules/cjs/loader',
'NativeModule internal/modules/esm/create_dynamic_module',
'NativeModule internal/modules/esm/default_resolve',
'NativeModule internal/modules/esm/get_format',
'NativeModule internal/modules/esm/get_source',
'NativeModule internal/modules/esm/loader',
'NativeModule internal/modules/esm/module_job',
'NativeModule internal/modules/esm/module_map',
'NativeModule internal/modules/esm/resolve',
'NativeModule internal/modules/esm/transform_source',
'NativeModule internal/modules/esm/translators',
'NativeModule internal/process/esm_loader',
'NativeModule internal/options',