From 48734fa4c4545b80b9df0ecb4d4f2cc370d9c028 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Thu, 25 Apr 2024 01:54:59 +0200 Subject: [PATCH] esm: unflag `Module.register` and allow nested loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major functional changes: - Allow `import()` to work within loaders that require other loaders, - Unflag the use of `Module.register`. A new interface `Customizations` has been created in order to unify `ModuleLoader` (previously `DefaultModuleLoader`), `Hooks` and `CustomizedModuleLoader` all of which now implement it: ```ts interface LoadResult { format: ModuleFormat; source: ModuleSource; } interface ResolveResult { format: string; url: URL['href']; } interface Customizations { allowImportMetaResolve: boolean; load(url: string, context: object): Promise resolve( originalSpecifier: string, parentURL: string, importAssertions: Record ): Promise resolveSync( originalSpecifier: string, parentURL: string, importAssertions: Record ) ResolveResult; register(specifier: string, parentUrl: string): any; forceLoadHooks(): void; importMetaInitialize(meta, context, loader): void; } ``` The `ModuleLoader` class now has `setCustomizations` which takes an object of this shape and delegates its responsibilities to this object if present. Note that two properties `allowImportMetaResolve` and `resolveSync` exist now as a mechanism for `import.meta.resolve` – since `Hooks` does not implement `resolveSync` other loaders cannot use `import.meta.resolve`; `allowImportMetaResolve` is a way of checking for that case instead of invoking `resolveSync` and erroring. Fixes https://github.com/nodejs/node/issues/48515 Closes https://github.com/nodejs/node/pull/48439 PR-URL: https://github.com/nodejs/node/pull/48559 Backport-PR-URL: https://github.com/nodejs/node/pull/50669 Reviewed-By: Jacob Smith Reviewed-By: Geoffrey Booth --- graal-nodejs/doc/api/errors.md | 17 -- graal-nodejs/lib/internal/errors.js | 5 - .../lib/internal/modules/esm/hooks.js | 62 +++--- .../modules/esm/initialize_import_meta.js | 4 +- .../lib/internal/modules/esm/loader.js | 203 ++++++++++++------ .../lib/internal/modules/esm/module_job.js | 4 +- .../lib/internal/modules/esm/utils.js | 41 +--- .../es-module/test-esm-loader-chaining.mjs | 34 +++ .../test-esm-loader-programmatically.mjs | 24 ++- .../loader-load-dynamic-import.mjs | 14 ++ .../loader-resolve-dynamic-import.mjs | 14 ++ 11 files changed, 265 insertions(+), 157 deletions(-) create mode 100644 graal-nodejs/test/fixtures/es-module-loaders/loader-load-dynamic-import.mjs create mode 100644 graal-nodejs/test/fixtures/es-module-loaders/loader-resolve-dynamic-import.mjs diff --git a/graal-nodejs/doc/api/errors.md b/graal-nodejs/doc/api/errors.md index cb10d48f2dd..c589fcd3751 100644 --- a/graal-nodejs/doc/api/errors.md +++ b/graal-nodejs/doc/api/errors.md @@ -1234,23 +1234,6 @@ provided. Encoding provided to `TextDecoder()` API was not one of the [WHATWG Supported Encodings][]. - - -### `ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE` - - - -Programmatically registering custom ESM loaders -currently requires at least one custom loader to have been -registered via the `--experimental-loader` flag. A no-op -loader registered via CLI is sufficient -(for example: `--experimental-loader data:text/javascript,`; -do not omit the necessary trailing comma). -A future version of Node.js will support the programmatic -registration of loaders without needing to also use the flag. - ### `ERR_EVAL_ESM_CANNOT_PRINT` diff --git a/graal-nodejs/lib/internal/errors.js b/graal-nodejs/lib/internal/errors.js index d78278c2430..7bc7998f918 100644 --- a/graal-nodejs/lib/internal/errors.js +++ b/graal-nodejs/lib/internal/errors.js @@ -1039,11 +1039,6 @@ E('ERR_ENCODING_INVALID_ENCODED_DATA', function(encoding, ret) { }, TypeError); E('ERR_ENCODING_NOT_SUPPORTED', 'The "%s" encoding is not supported', RangeError); -E('ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE', 'Programmatically registering custom ESM loaders ' + - 'currently requires at least one custom loader to have been registered via the --experimental-loader ' + - 'flag. A no-op loader registered via CLI is sufficient (for example: `--experimental-loader ' + - '"data:text/javascript,"` with the necessary trailing comma). A future version of Node.js ' + - 'will remove this requirement.', Error); E('ERR_EVAL_ESM_CANNOT_PRINT', '--print cannot be used with ESM input', Error); E('ERR_EVENT_RECURSION', 'The event "%s" is already being dispatched', Error); E('ERR_FALSY_VALUE_REJECTION', function(reason) { diff --git a/graal-nodejs/lib/internal/modules/esm/hooks.js b/graal-nodejs/lib/internal/modules/esm/hooks.js index 25985ef9275..50c5fb68a50 100644 --- a/graal-nodejs/lib/internal/modules/esm/hooks.js +++ b/graal-nodejs/lib/internal/modules/esm/hooks.js @@ -31,6 +31,7 @@ const { ERR_INVALID_RETURN_PROPERTY_VALUE, ERR_INVALID_RETURN_VALUE, ERR_LOADER_CHAIN_INCOMPLETE, + ERR_METHOD_NOT_IMPLEMENTED, ERR_UNKNOWN_BUILTIN_MODULE, ERR_WORKER_UNSERIALIZABLE_ERROR, } = require('internal/errors').codes; @@ -64,7 +65,7 @@ const { let debug = require('internal/util/debuglog').debuglog('esm', (fn) => { debug = fn; }); - +let importMetaInitializer; /** * @typedef {object} ExportedHooks @@ -81,7 +82,6 @@ let debug = require('internal/util/debuglog').debuglog('esm', (fn) => { // [2] `validate...()`s throw the wrong error - class Hooks { #chains = { /** @@ -120,6 +120,8 @@ class Hooks { // Cache URLs we've already validated to avoid repeated validation #validatedUrls = new SafeSet(); + allowImportMetaResolve = false; + /** * Import and register custom/user-defined module loader hook(s). * @param {string} urlOrSpecifier @@ -127,13 +129,11 @@ class Hooks { */ async register(urlOrSpecifier, parentURL) { const moduleLoader = require('internal/process/esm_loader').esmLoader; - const keyedExports = await moduleLoader.import( urlOrSpecifier, parentURL, kEmptyObject, ); - this.addCustomLoader(urlOrSpecifier, keyedExports); } @@ -151,13 +151,15 @@ class Hooks { } = pluckHooks(exports); if (globalPreload) { - ArrayPrototypePush(this.#chains.globalPreload, { fn: globalPreload, url }); + ArrayPrototypePush(this.#chains.globalPreload, { __proto__: null, fn: globalPreload, url }); } if (resolve) { - ArrayPrototypePush(this.#chains.resolve, { fn: resolve, url }); + const next = this.#chains.resolve[this.#chains.resolve.length - 1]; + ArrayPrototypePush(this.#chains.resolve, { __proto__: null, fn: resolve, url, next }); } if (load) { - ArrayPrototypePush(this.#chains.load, { fn: load, url }); + const next = this.#chains.load[this.#chains.load.length - 1]; + ArrayPrototypePush(this.#chains.load, { __proto__: null, fn: load, url, next }); } } @@ -234,7 +236,6 @@ class Hooks { chainFinished: null, context, hookErrIdentifier: '', - hookIndex: chain.length - 1, hookName: 'resolve', shortCircuited: false, }; @@ -257,7 +258,7 @@ class Hooks { } }; - const nextResolve = nextHookFactory(chain, meta, { validateArgs, validateOutput }); + const nextResolve = nextHookFactory(chain[chain.length - 1], meta, { validateArgs, validateOutput }); const resolution = await nextResolve(originalSpecifier, context); const { hookErrIdentifier } = meta; // Retrieve the value after all settled @@ -334,6 +335,10 @@ class Hooks { }; } + resolveSync(_originalSpecifier, _parentURL, _importAssertions) { + throw new ERR_METHOD_NOT_IMPLEMENTED('resolveSync()'); + } + /** * Provide source that is understood by one of Node's translators. * @@ -350,7 +355,6 @@ class Hooks { chainFinished: null, context, hookErrIdentifier: '', - hookIndex: chain.length - 1, hookName: 'load', shortCircuited: false, }; @@ -392,7 +396,7 @@ class Hooks { } }; - const nextLoad = nextHookFactory(chain, meta, { validateArgs, validateOutput }); + const nextLoad = nextHookFactory(chain[chain.length - 1], meta, { validateArgs, validateOutput }); const loaded = await nextLoad(url, context); const { hookErrIdentifier } = meta; // Retrieve the value after all settled @@ -467,6 +471,16 @@ class Hooks { source, }; } + + forceLoadHooks() { + // No-op + } + + importMetaInitialize(meta, context, loader) { + importMetaInitializer ??= require('internal/modules/esm/initialize_import_meta').initializeImportMeta; + meta = importMetaInitializer(meta, context, loader); + return meta; + } } ObjectSetPrototypeOf(Hooks.prototype, null); @@ -716,15 +730,14 @@ function pluckHooks({ * A utility function to iterate through a hook chain, track advancement in the * chain, and generate and supply the `next` argument to the custom * hook. - * @param {KeyedHook[]} chain The whole hook chain. + * @param {Hook} current The (currently) first hook in the chain (this shifts + * on every call). * @param {object} meta Properties that change as the current hook advances * along the chain. * @param {boolean} meta.chainFinished Whether the end of the chain has been * reached AND invoked. * @param {string} meta.hookErrIdentifier A user-facing identifier to help * pinpoint where an error occurred. Ex "file:///foo.mjs 'resolve'". - * @param {number} meta.hookIndex A non-negative integer tracking the current - * position in the hook chain. * @param {string} meta.hookName The kind of hook the chain is (ex 'resolve') * @param {boolean} meta.shortCircuited Whether a hook signaled a short-circuit. * @param {(hookErrIdentifier, hookArgs) => void} validate A wrapper function @@ -732,13 +745,14 @@ function pluckHooks({ * validation within MUST throw. * @returns {function next(...hookArgs)} The next hook in the chain. */ -function nextHookFactory(chain, meta, { validateArgs, validateOutput }) { +function nextHookFactory(current, meta, { validateArgs, validateOutput }) { // First, prepare the current const { hookName } = meta; const { fn: hook, url: hookFilePath, - } = chain[meta.hookIndex]; + next, + } = current; // ex 'nextResolve' const nextHookName = `next${ @@ -746,16 +760,9 @@ function nextHookFactory(chain, meta, { validateArgs, validateOutput }) { StringPrototypeSlice(hookName, 1) }`; - // When hookIndex is 0, it's reached the default, which does not call next() - // so feed it a noop that blows up if called, so the problem is obvious. - const generatedHookIndex = meta.hookIndex; let nextNextHook; - if (meta.hookIndex > 0) { - // Now, prepare the next: decrement the pointer so the next call to the - // factory generates the next link in the chain. - meta.hookIndex--; - - nextNextHook = nextHookFactory(chain, meta, { validateArgs, validateOutput }); + if (next) { + nextNextHook = nextHookFactory(next, meta, { validateArgs, validateOutput }); } else { // eslint-disable-next-line func-name-matching nextNextHook = function chainAdvancedTooFar() { @@ -772,17 +779,16 @@ function nextHookFactory(chain, meta, { validateArgs, validateOutput }) { validateArgs(`${meta.hookErrIdentifier} hook's ${nextHookName}()`, arg0, context); - const outputErrIdentifier = `${chain[generatedHookIndex].url} '${hookName}' hook's ${nextHookName}()`; + const outputErrIdentifier = `${hookFilePath} '${hookName}' hook's ${nextHookName}()`; // Set when next is actually called, not just generated. - if (generatedHookIndex === 0) { meta.chainFinished = true; } + if (!next) { meta.chainFinished = true; } if (context) { // `context` has already been validated, so no fancy check needed. ObjectAssign(meta.context, context); } const output = await hook(arg0, meta.context, nextNextHook); - validateOutput(outputErrIdentifier, output); if (output?.shortCircuit === true) { meta.shortCircuited = true; } diff --git a/graal-nodejs/lib/internal/modules/esm/initialize_import_meta.js b/graal-nodejs/lib/internal/modules/esm/initialize_import_meta.js index c548f71bef8..631231dd9ba 100644 --- a/graal-nodejs/lib/internal/modules/esm/initialize_import_meta.js +++ b/graal-nodejs/lib/internal/modules/esm/initialize_import_meta.js @@ -14,7 +14,7 @@ function createImportMetaResolve(defaultParentUrl, loader) { let url; try { - ({ url } = loader.resolve(specifier, parentUrl)); + ({ url } = loader.resolveSync(specifier, parentUrl)); } catch (error) { if (error?.code === 'ERR_UNSUPPORTED_DIR_IMPORT') { ({ url } = error); @@ -38,7 +38,7 @@ function initializeImportMeta(meta, context, loader) { const { url } = context; // Alphabetical - if (experimentalImportMetaResolve && loader.loaderType !== 'internal') { + if (experimentalImportMetaResolve && loader.allowImportMetaResolve) { meta.resolve = createImportMetaResolve(url, loader); } diff --git a/graal-nodejs/lib/internal/modules/esm/loader.js b/graal-nodejs/lib/internal/modules/esm/loader.js index d7ea78b3e45..12b6fbfcbe4 100644 --- a/graal-nodejs/lib/internal/modules/esm/loader.js +++ b/graal-nodejs/lib/internal/modules/esm/loader.js @@ -6,12 +6,10 @@ require('internal/modules/cjs/loader'); const { FunctionPrototypeCall, ObjectSetPrototypeOf, - PromisePrototypeThen, SafeWeakMap, } = primordials; const { - ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE, ERR_UNKNOWN_MODULE_FORMAT, } = require('internal/errors').codes; const { getOptionValue } = require('internal/options'); @@ -55,10 +53,11 @@ let hooksProxy; let emittedSpecifierResolutionWarning = false; /** - * This class covers the default case of an module loader instance where no custom user loaders are used. - * The below CustomizedModuleLoader class extends this one to support custom user loader hooks. + * This class covers the base machinery of module loading. To add custom + * behavior you can pass a customizations object and this object will be + * used to do the loading/resolving/registration process. */ -class DefaultModuleLoader { +class ModuleLoader { /** * The conditions for resolving packages if `--conditions` is not used. */ @@ -85,12 +84,24 @@ class DefaultModuleLoader { translators = getTranslators(); /** - * Type of loader. - * @type {'default' | 'internal'} + * Truthy to allow the use of `import.meta.resolve`. This is needed + * currently because the `Hooks` class does not have `resolveSync` + * implemented and `import.meta.resolve` requires it. */ - loaderType = 'default'; + allowImportMetaResolve; - constructor() { + /** + * Customizations to pass requests to. + * + * Note that this value _MUST_ be set with `setCustomizations` + * because it needs to copy `customizations.allowImportMetaResolve` + * to this property and failure to do so will cause undefined + * behavior when invoking `import.meta.resolve`. + * @see {ModuleLoader.setCustomizations} + */ + #customizations; + + constructor(customizations) { if (getOptionValue('--experimental-network-imports')) { emitExperimentalWarning('Network Imports'); } @@ -104,6 +115,64 @@ class DefaultModuleLoader { ); emittedSpecifierResolutionWarning = true; } + this.setCustomizations(customizations); + } + + /** + * Change the currently activate customizations for this module + * loader to be the provided `customizations`. + * + * If present, this class customizes its core functionality to the + * `customizations` object, including registration, loading, and resolving. + * There are some responsibilities that this class _always_ takes + * care of, like validating outputs, so that the customizations object + * does not have to do so. + * + * The customizations object has the shape: + * + * ```ts + * interface LoadResult { + * format: ModuleFormat; + * source: ModuleSource; + * } + * + * interface ResolveResult { + * format: string; + * url: URL['href']; + * } + * + * interface Customizations { + * allowImportMetaResolve: boolean; + * load(url: string, context: object): Promise + * resolve( + * originalSpecifier: + * string, parentURL: string, + * importAssertions: Record + * ): Promise + * resolveSync( + * originalSpecifier: + * string, parentURL: string, + * importAssertions: Record + * ) ResolveResult; + * register(specifier: string, parentUrl: string): any; + * forceLoadHooks(): void; + * } + * ``` + * + * Note that this class _also_ implements the `Customizations` + * interface, as does `CustomizedModuleLoader` and `Hooks`. + * + * Calling this function alters how modules are loaded and should be + * invoked with care. + * @param {object} customizations + */ + setCustomizations(customizations) { + this.#customizations = customizations; + if (customizations) { + this.allowImportMetaResolve = customizations.allowImportMetaResolve; + } else { + this.allowImportMetaResolve = true; + } } async eval( @@ -145,17 +214,16 @@ class DefaultModuleLoader { * point. * @param {Record} importAssertions Validations for the * module import. - * @returns {ModuleJob} The (possibly pending) module job + * @returns {Promise} The (possibly pending) module job */ - getModuleJob(specifier, parentURL, importAssertions) { - const resolveResult = this.resolve(specifier, parentURL, importAssertions); + async getModuleJob(specifier, parentURL, importAssertions) { + const resolveResult = await this.resolve(specifier, parentURL, importAssertions); return this.getJobFromResolveResult(resolveResult, parentURL, importAssertions); } getJobFromResolveResult(resolveResult, parentURL, importAssertions) { const { url, format } = resolveResult; const resolvedImportAssertions = resolveResult.importAssertions ?? importAssertions; - let job = this.moduleMap.get(url, resolvedImportAssertions.type); // CommonJS will set functions for lazy job evaluation. @@ -235,11 +303,22 @@ class DefaultModuleLoader { * @returns {Promise} */ async import(specifier, parentURL, importAssertions) { - const moduleJob = this.getModuleJob(specifier, parentURL, importAssertions); + const moduleJob = await this.getModuleJob(specifier, parentURL, importAssertions); const { module } = await moduleJob.run(); return module.getNamespace(); } + register(specifier, parentUrl) { + if (!this.#customizations) { + // `CustomizedModuleLoader` is defined at the bottom of this file and + // available well before this line is ever invoked. This is here in + // order to preserve the git diff instead of moving the class. + // eslint-disable-next-line no-use-before-define + this.setCustomizations(new CustomizedModuleLoader()); + } + return this.#customizations.register(specifier, parentUrl); + } + /** * Resolve the location of the module. * @param {string} originalSpecifier The specified URL path of the module to @@ -247,9 +326,32 @@ class DefaultModuleLoader { * @param {string} [parentURL] The URL path of the module's parent. * @param {ImportAssertions} importAssertions Assertions from the import * statement or expression. - * @returns {{ format: string, url: URL['href'] }} + * @returns {Promise<{ format: string, url: URL['href'] }>} */ resolve(originalSpecifier, parentURL, importAssertions) { + if (this.#customizations) { + return this.#customizations.resolve(originalSpecifier, parentURL, importAssertions); + } + return this.defaultResolve(originalSpecifier, parentURL, importAssertions); + } + + /** + * Just like `resolve` except synchronous. This is here specifically to support + * `import.meta.resolve` which must happen synchronously. + */ + resolveSync(originalSpecifier, parentURL, importAssertions) { + if (this.#customizations) { + return this.#customizations.resolveSync(originalSpecifier, parentURL, importAssertions); + } + return this.defaultResolve(originalSpecifier, parentURL, importAssertions); + } + + /** + * Our `defaultResolve` is synchronous and can be used in both + * `resolve` and `resolveSync`. This function is here just to avoid + * repeating the same code block twice in those functions. + */ + defaultResolve(originalSpecifier, parentURL, importAssertions) { defaultResolve ??= require('internal/modules/esm/resolve').defaultResolve; const context = { @@ -270,8 +372,9 @@ class DefaultModuleLoader { */ async load(url, context) { defaultLoad ??= require('internal/modules/esm/load').defaultLoad; - - const result = await defaultLoad(url, context); + const result = this.#customizations ? + await this.#customizations.load(url, context) : + await defaultLoad(url, context); this.validateLoadResult(url, result?.format); return result; } @@ -283,6 +386,9 @@ class DefaultModuleLoader { } importMetaInitialize(meta, context) { + if (this.#customizations) { + return this.#customizations.importMetaInitialize(meta, context, this); + } importMetaInitializer ??= require('internal/modules/esm/initialize_import_meta').initializeImportMeta; meta = importMetaInitializer(meta, context, this); return meta; @@ -291,18 +397,20 @@ class DefaultModuleLoader { /** * No-op when no hooks have been supplied. */ - forceLoadHooks() {} + forceLoadHooks() { + this.#customizations?.forceLoadHooks(); + } } -ObjectSetPrototypeOf(DefaultModuleLoader.prototype, null); +ObjectSetPrototypeOf(ModuleLoader.prototype, null); + +class CustomizedModuleLoader { + allowImportMetaResolve = true; -class CustomizedModuleLoader extends DefaultModuleLoader { /** * Instantiate a module loader that uses user-provided custom loader hooks. */ constructor() { - super(); - getHooksProxy(); } @@ -328,28 +436,12 @@ class CustomizedModuleLoader extends DefaultModuleLoader { * @returns {{ format: string, url: URL['href'] }} */ resolve(originalSpecifier, parentURL, importAssertions) { - return hooksProxy.makeSyncRequest('resolve', originalSpecifier, parentURL, importAssertions); - } - - async #getModuleJob(specifier, parentURL, importAssertions) { - const resolveResult = await hooksProxy.makeAsyncRequest('resolve', specifier, parentURL, importAssertions); - - return this.getJobFromResolveResult(resolveResult, parentURL, importAssertions); + return hooksProxy.makeAsyncRequest('resolve', originalSpecifier, parentURL, importAssertions); } - getModuleJob(specifier, parentURL, importAssertions) { - const jobPromise = this.#getModuleJob(specifier, parentURL, importAssertions); - return { - run() { - return PromisePrototypeThen(jobPromise, (job) => job.run()); - }, - get modulePromise() { - return PromisePrototypeThen(jobPromise, (job) => job.modulePromise); - }, - get linked() { - return PromisePrototypeThen(jobPromise, (job) => job.linked); - }, - }; + resolveSync(originalSpecifier, parentURL, importAssertions) { + // This happens only as a result of `import.meta.resolve` calls, which must be sync per spec. + return hooksProxy.makeSyncRequest('resolve', originalSpecifier, parentURL, importAssertions); } /** @@ -358,15 +450,12 @@ class CustomizedModuleLoader extends DefaultModuleLoader { * @param {object} [context] Metadata about the module * @returns {Promise<{ format: ModuleFormat, source: ModuleSource }>} */ - async load(url, context) { - const result = await hooksProxy.makeAsyncRequest('load', url, context); - this.validateLoadResult(url, result?.format); - - return result; + load(url, context) { + return hooksProxy.makeAsyncRequest('load', url, context); } - importMetaInitialize(meta, context) { - hooksProxy.importMetaInitialize(meta, context, this); + importMetaInitialize(meta, context, loader) { + hooksProxy.importMetaInitialize(meta, context, loader); } forceLoadHooks() { @@ -374,16 +463,16 @@ class CustomizedModuleLoader extends DefaultModuleLoader { } } - let emittedExperimentalWarning = false; /** * A loader instance is used as the main entry point for loading ES modules. Currently, this is a singleton; there is * only one used for loading the main module and everything in its dependency graph, though separate instances of this * class might be instantiated as part of bootstrap for other purposes. * @param {boolean} useCustomLoadersIfPresent If the user has provided loaders via the --loader flag, use them. - * @returns {DefaultModuleLoader | CustomizedModuleLoader} + * @returns {ModuleLoader} */ function createModuleLoader(useCustomLoadersIfPresent = true) { + let customizations = null; if (useCustomLoadersIfPresent && // Don't spawn a new worker if we're already in a worker thread created by instantiating CustomizedModuleLoader; // doing so would cause an infinite loop. @@ -394,13 +483,14 @@ function createModuleLoader(useCustomLoadersIfPresent = true) { emitExperimentalWarning('Custom ESM Loaders'); emittedExperimentalWarning = true; } - return new CustomizedModuleLoader(); + customizations = new CustomizedModuleLoader(); } } - return new DefaultModuleLoader(); + return new ModuleLoader(customizations); } + /** * Get the HooksProxy instance. If it is not defined, then create a new one. * @returns {HooksProxy} @@ -428,18 +518,11 @@ function getHooksProxy() { * ``` */ function register(specifier, parentURL = 'data:') { - // TODO: Remove this limitation in a follow-up before `register` is released publicly - if (getOptionValue('--experimental-loader').length < 1) { - throw new ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE(); - } - const moduleLoader = require('internal/process/esm_loader').esmLoader; - moduleLoader.register(`${specifier}`, parentURL); } module.exports = { - DefaultModuleLoader, createModuleLoader, getHooksProxy, register, diff --git a/graal-nodejs/lib/internal/modules/esm/module_job.js b/graal-nodejs/lib/internal/modules/esm/module_job.js index 7f05b6c6473..2f00feb09b7 100644 --- a/graal-nodejs/lib/internal/modules/esm/module_job.js +++ b/graal-nodejs/lib/internal/modules/esm/module_job.js @@ -73,8 +73,8 @@ class ModuleJob { // so that circular dependencies can't cause a deadlock by two of // these `link` callbacks depending on each other. const dependencyJobs = []; - const promises = this.module.link((specifier, assertions) => { - const job = this.loader.getModuleJob(specifier, url, assertions); + const promises = this.module.link(async (specifier, assertions) => { + const job = await this.loader.getModuleJob(specifier, url, assertions); ArrayPrototypePush(dependencyJobs, job); return job.modulePromise; }); diff --git a/graal-nodejs/lib/internal/modules/esm/utils.js b/graal-nodejs/lib/internal/modules/esm/utils.js index 4e919cd8330..3da53dd2557 100644 --- a/graal-nodejs/lib/internal/modules/esm/utils.js +++ b/graal-nodejs/lib/internal/modules/esm/utils.js @@ -2,7 +2,6 @@ const { ArrayIsArray, - PromisePrototypeThen, SafeSet, SafeWeakMap, ObjectFreeze, @@ -14,7 +13,6 @@ const { } = require('internal/errors').codes; const { getOptionValue } = require('internal/options'); const { pathToFileURL } = require('internal/url'); -const { kEmptyObject } = require('internal/util'); const { setImportModuleDynamicallyCallback, setInitializeImportMetaObjectCallback, @@ -120,46 +118,17 @@ async function initializeHooks() { const { Hooks } = require('internal/modules/esm/hooks'); + const esmLoader = require('internal/process/esm_loader').esmLoader; + const hooks = new Hooks(); + esmLoader.setCustomizations(hooks); - const { DefaultModuleLoader } = require('internal/modules/esm/loader'); - class ModuleLoader extends DefaultModuleLoader { - loaderType = 'internal'; - async #getModuleJob(specifier, parentURL, importAssertions) { - const resolveResult = await hooks.resolve(specifier, parentURL, importAssertions); - return this.getJobFromResolveResult(resolveResult, parentURL, importAssertions); - } - getModuleJob(specifier, parentURL, importAssertions) { - const jobPromise = this.#getModuleJob(specifier, parentURL, importAssertions); - return { - run() { - return PromisePrototypeThen(jobPromise, (job) => job.run()); - }, - get modulePromise() { - return PromisePrototypeThen(jobPromise, (job) => job.modulePromise); - }, - get linked() { - return PromisePrototypeThen(jobPromise, (job) => job.linked); - }, - }; - } - load(url, context) { return hooks.load(url, context); } - } - const privateModuleLoader = new ModuleLoader(); const parentURL = pathToFileURL(cwd).href; - - // TODO(jlenon7): reuse the `Hooks.register()` method for registering loaders. for (let i = 0; i < customLoaderURLs.length; i++) { - const customLoaderURL = customLoaderURLs[i]; - - // Importation must be handled by internal loader to avoid polluting user-land - const keyedExports = await privateModuleLoader.import( - customLoaderURL, + await hooks.register( + customLoaderURLs[i], parentURL, - kEmptyObject, ); - - hooks.addCustomLoader(customLoaderURL, keyedExports); } const preloadScripts = hooks.initializeGlobalPreload(); diff --git a/graal-nodejs/test/es-module/test-esm-loader-chaining.mjs b/graal-nodejs/test/es-module/test-esm-loader-chaining.mjs index 0f67d71ece0..b43ac740500 100644 --- a/graal-nodejs/test/es-module/test-esm-loader-chaining.mjs +++ b/graal-nodejs/test/es-module/test-esm-loader-chaining.mjs @@ -470,4 +470,38 @@ describe('ESM: loader chaining', { concurrency: true }, () => { assert.match(stderr, /'load' hook's nextLoad\(\) context/); assert.strictEqual(code, 1); }); + + it('should allow loaders to influence subsequent loader `import()` calls in `resolve`', async () => { + const { code, stderr, stdout } = await spawnPromisified( + execPath, + [ + '--loader', + fixtures.fileURL('es-module-loaders', 'loader-resolve-strip-xxx.mjs'), + '--loader', + fixtures.fileURL('es-module-loaders', 'loader-resolve-dynamic-import.mjs'), + ...commonArgs, + ], + { encoding: 'utf8' }, + ); + assert.strictEqual(stderr, ''); + assert.match(stdout, /resolve dynamic import/); // It did go thru resolve-dynamic + assert.strictEqual(code, 0); + }); + + it('should allow loaders to influence subsequent loader `import()` calls in `load`', async () => { + const { code, stderr, stdout } = await spawnPromisified( + execPath, + [ + '--loader', + fixtures.fileURL('es-module-loaders', 'loader-resolve-strip-xxx.mjs'), + '--loader', + fixtures.fileURL('es-module-loaders', 'loader-load-dynamic-import.mjs'), + ...commonArgs, + ], + { encoding: 'utf8' }, + ); + assert.strictEqual(stderr, ''); + assert.match(stdout, /load dynamic import/); // It did go thru load-dynamic + assert.strictEqual(code, 0); + }); }); diff --git a/graal-nodejs/test/es-module/test-esm-loader-programmatically.mjs b/graal-nodejs/test/es-module/test-esm-loader-programmatically.mjs index 0c20bbcb751..43569e3366d 100644 --- a/graal-nodejs/test/es-module/test-esm-loader-programmatically.mjs +++ b/graal-nodejs/test/es-module/test-esm-loader-programmatically.mjs @@ -182,15 +182,19 @@ describe('ESM: programmatically register loaders', { concurrency: true }, () => const lines = stdout.split('\n'); + // Resolve occurs twice because it is first used to resolve the `load` loader + // _AND THEN_ the `register` module. assert.match(lines[0], /resolve passthru/); - assert.match(lines[1], /load passthru/); - assert.match(lines[2], /Hello from dynamic import/); + assert.match(lines[1], /resolve passthru/); + assert.match(lines[2], /load passthru/); + assert.match(lines[3], /Hello from dynamic import/); - assert.strictEqual(lines[3], ''); + assert.strictEqual(lines[4], ''); }); - it('does not work without dummy CLI loader', async () => { + it('works without a CLI flag', async () => { const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', '--input-type=module', '--eval', "import { register } from 'node:module';" + @@ -198,10 +202,16 @@ describe('ESM: programmatically register loaders', { concurrency: true }, () => commonEvals.dynamicImport('console.log("Hello from dynamic import");'), ]); - assert.strictEqual(stdout, ''); - assert.strictEqual(code, 1); + assert.strictEqual(stderr, ''); + assert.strictEqual(code, 0); assert.strictEqual(signal, null); - assert.match(stderr, /ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE/); + + const lines = stdout.split('\n'); + + assert.match(lines[0], /load passthru/); + assert.match(lines[1], /Hello from dynamic import/); + + assert.strictEqual(lines[2], ''); }); it('does not work with a loader specifier that does not exist', async () => { diff --git a/graal-nodejs/test/fixtures/es-module-loaders/loader-load-dynamic-import.mjs b/graal-nodejs/test/fixtures/es-module-loaders/loader-load-dynamic-import.mjs new file mode 100644 index 00000000000..96af5507d17 --- /dev/null +++ b/graal-nodejs/test/fixtures/es-module-loaders/loader-load-dynamic-import.mjs @@ -0,0 +1,14 @@ +import { writeSync } from 'node:fs'; + + +export async function load(url, context, next) { + if (url === 'node:fs' || url.includes('loader')) { + return next(url); + } + + // Here for asserting dynamic import + await import('xxx/loader-load-passthru.mjs'); + + writeSync(1, 'load dynamic import' + '\n'); // Signal that this specific hook ran + return next(url, context); +} diff --git a/graal-nodejs/test/fixtures/es-module-loaders/loader-resolve-dynamic-import.mjs b/graal-nodejs/test/fixtures/es-module-loaders/loader-resolve-dynamic-import.mjs new file mode 100644 index 00000000000..edc2303ed9a --- /dev/null +++ b/graal-nodejs/test/fixtures/es-module-loaders/loader-resolve-dynamic-import.mjs @@ -0,0 +1,14 @@ +import { writeSync } from 'node:fs'; + + +export async function resolve(specifier, context, next) { + if (specifier === 'node:fs' || specifier.includes('loader')) { + return next(specifier); + } + + // Here for asserting dynamic import + await import('xxx/loader-resolve-passthru.mjs'); + + writeSync(1, 'resolve dynamic import' + '\n'); // Signal that this specific hook ran + return next(specifier); +}