diff --git a/doc/api/errors.md b/doc/api/errors.md index 8ddc9adf59..2ff3e20625 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -703,6 +703,13 @@ by the `node:assert` module. An attempt was made to register something that is not a function as an `AsyncHooks` callback. + + +### `ERR_ASYNC_LOADER_REQUEST_NEVER_SETTLED` + +An operation related to module loading is customized by an asynchronous loader +hook that never settled the promise before the loader thread exits. + ### `ERR_ASYNC_TYPE` diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 23a54754e2..5fa4437b09 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1137,6 +1137,8 @@ E('ERR_AMBIGUOUS_ARGUMENT', 'The "%s" argument is ambiguous. %s', TypeError); E('ERR_ARG_NOT_ITERABLE', '%s must be iterable', TypeError); E('ERR_ASSERTION', '%s', Error); E('ERR_ASYNC_CALLBACK', '%s must be a function', TypeError); +E('ERR_ASYNC_LOADER_REQUEST_NEVER_SETTLED', + 'Async loader request never settled', Error); E('ERR_ASYNC_TYPE', 'Invalid name for async "type": %s', TypeError); E('ERR_BROTLI_INVALID_PARAM', '%s is not a valid Brotli parameter', RangeError); E('ERR_BUFFER_OUT_OF_BOUNDS', diff --git a/lib/internal/modules/esm/hooks.js b/lib/internal/modules/esm/hooks.js index cc66d47a43..ad5a228991 100644 --- a/lib/internal/modules/esm/hooks.js +++ b/lib/internal/modules/esm/hooks.js @@ -23,16 +23,15 @@ const { } = globalThis; const { + ERR_ASYNC_LOADER_REQUEST_NEVER_SETTLED, ERR_INTERNAL_ASSERTION, ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, ERR_INVALID_RETURN_PROPERTY_VALUE, ERR_INVALID_RETURN_VALUE, ERR_LOADER_CHAIN_INCOMPLETE, - ERR_METHOD_NOT_IMPLEMENTED, ERR_WORKER_UNSERIALIZABLE_ERROR, } = require('internal/errors').codes; -const { exitCodes: { kUnsettledTopLevelAwait } } = internalBinding('errors'); const { URLParse } = require('internal/url'); const { canParse: URLCanParse } = internalBinding('url'); const { receiveMessageOnPort } = require('worker_threads'); @@ -117,15 +116,16 @@ function defineImportAssertionAlias(context) { * via `ModuleLoader.#setAsyncLoaderHooks()`. * @typedef {object} AsyncLoaderHooks * @property {boolean} allowImportMetaResolve Whether to allow the use of `import.meta.resolve`. + * @property {boolean} isForAsyncLoaderHookWorker Whether the instance is running on the loader hook worker thread. * @property {(url: string, context: object, defaultLoad: Function) => Promise} load * Calling the asynchronous `load` hook asynchronously. - * @property {(url: string, context: object, defaultLoad: Function) => LoadResult} loadSync + * @property {(url: string, context: object, defaultLoad: Function) => LoadResult} [loadSync] * Calling the asynchronous `load` hook synchronously. * @property {(originalSpecifier: string, parentURL: string, * importAttributes: Record) => Promise} resolve * Calling the asynchronous `resolve` hook asynchronously. * @property {(originalSpecifier: string, parentURL: string, - * importAttributes: Record) => ResolveResult} resolveSync + * importAttributes: Record) => ResolveResult} [resolveSync] * Calling the asynchronous `resolve` hook synchronously. * @property {(specifier: string, parentURL: string) => any} register Register asynchronous loader hooks * @property {() => void} waitForLoaderHookInitialization Force loading of hooks. @@ -169,6 +169,8 @@ class AsyncLoaderHooksOnLoaderHookWorker { allowImportMetaResolve = false; + isForAsyncLoaderHookWorker = true; + /** * Import and register custom/user-defined module loader hook(s). * @param {string} urlOrSpecifier @@ -350,10 +352,6 @@ class AsyncLoaderHooksOnLoaderHookWorker { }; } - resolveSync(_originalSpecifier, _parentURL, _importAttributes) { - throw new ERR_METHOD_NOT_IMPLEMENTED('resolveSync()'); - } - /** * Provide source that is understood by one of Node's translators. * @@ -560,7 +558,10 @@ class AsyncLoaderHookWorker { debug('wait for signal from worker'); AtomicsWait(this.#lock, WORKER_TO_MAIN_THREAD_NOTIFICATION, 0); const response = this.#worker.receiveMessageSync(); - if (response == null || response.message.status === 'exit') { return; } + if (response == null) { return; } + if (response.message.status === 'exit') { + process.exit(response.message.body); + } // ! This line catches initialization errors in the worker thread. this.#unwrapMessage(response); @@ -647,10 +648,13 @@ class AsyncLoaderHookWorker { this.#workerNotificationLastId = AtomicsLoad(this.#lock, WORKER_TO_MAIN_THREAD_NOTIFICATION); response = this.#worker.receiveMessageSync(); + debug('got sync message from worker', { method, args, response }); } while (response == null); - debug('got sync response from worker', { method, args }); + if (response.message.status === 'never-settle') { - process.exit(kUnsettledTopLevelAwait); + const error = new ERR_ASYNC_LOADER_REQUEST_NEVER_SETTLED(); + error.details = { method, args }; + throw error; } else if (response.message.status === 'exit') { process.exit(response.message.body); } @@ -819,6 +823,8 @@ class AsyncLoaderHooksProxiedToLoaderHookWorker { allowImportMetaResolve = true; + isForAsyncLoaderHookWorker = false; + /** * Instantiate a module loader that uses user-provided custom loader hooks. */ diff --git a/lib/internal/modules/esm/initialize_import_meta.js b/lib/internal/modules/esm/initialize_import_meta.js index 82edb59c41..9646c7c8b6 100644 --- a/lib/internal/modules/esm/initialize_import_meta.js +++ b/lib/internal/modules/esm/initialize_import_meta.js @@ -33,7 +33,8 @@ function createImportMetaResolve(defaultParentURL, loader, allowParentURL) { } try { - ({ url } = loader.resolveSync(specifier, parentURL)); + const request = { specifier, __proto__: null }; + ({ url } = loader.resolveSync(parentURL, request)); return url; } catch (error) { switch (error?.code) { diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index d536d8215c..abfe88c272 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -7,6 +7,8 @@ const { FunctionPrototypeCall, JSONStringify, ObjectSetPrototypeOf, + Promise, + PromisePrototypeThen, RegExpPrototypeSymbolReplace, encodeURIComponent, hardenRegExp, @@ -25,17 +27,20 @@ const { ERR_REQUIRE_ASYNC_MODULE, ERR_REQUIRE_CYCLE_MODULE, ERR_REQUIRE_ESM, - ERR_NETWORK_IMPORT_DISALLOWED, ERR_UNKNOWN_MODULE_FORMAT, } = require('internal/errors').codes; const { getOptionValue } = require('internal/options'); -const { isURL, pathToFileURL, URLParse } = require('internal/url'); +const { isURL, pathToFileURL } = require('internal/url'); const { kEmptyObject } = require('internal/util'); const { compileSourceTextModule, getDefaultConditions, shouldSpawnLoaderHookWorker, + requestTypes: { kImportInRequiredESM, kRequireInImportedCJS, kImportInImportedESM }, } = require('internal/modules/esm/utils'); +/** + * @typedef {import('./utils.js').ModuleRequestType} ModuleRequestType + */ const { kImplicitTypeAttribute } = require('internal/modules/esm/assert'); const { ModuleWrap, @@ -111,15 +116,16 @@ function getTranslators() { * async linking. * @param {string} filename Filename of the module being required. * @param {string|undefined} parentFilename Filename of the module calling require(). + * @param {boolean} isForAsyncLoaderHookWorker Whether this is for the async loader hook worker. * @returns {string} Error message. */ -function getRaceMessage(filename, parentFilename) { - let raceMessage = `Cannot require() ES Module ${filename} because it is not yet fully loaded. `; +function getRaceMessage(filename, parentFilename, isForAsyncLoaderHookWorker) { + let raceMessage = `Cannot require() ES Module ${filename} because it is not yet fully loaded.\n`; raceMessage += 'This may be caused by a race condition if the module is simultaneously dynamically '; - raceMessage += 'import()-ed via Promise.all(). Try await-ing the import() sequentially in a loop instead.'; - if (parentFilename) { - raceMessage += ` (from ${parentFilename})`; - } + raceMessage += 'import()-ed via Promise.all().\n'; + raceMessage += 'Try await-ing the import() sequentially in a loop instead.\n'; + raceMessage += ` (From ${parentFilename ? `${parentFilename} in ` : ' '}`; + raceMessage += `${isForAsyncLoaderHookWorker ? 'loader hook worker thread' : 'non-loader-hook thread'})`; return raceMessage; } @@ -184,6 +190,12 @@ class ModuleLoader { */ allowImportMetaResolve; + /** + * @see {AsyncLoaderHooks.isForAsyncLoaderHookWorker} + * Shortcut to this.#asyncLoaderHooks.isForAsyncLoaderHookWorker. + */ + isForAsyncLoaderHookWorker = false; + /** * Asynchronous loader hooks to pass requests to. * @@ -223,8 +235,10 @@ class ModuleLoader { this.#asyncLoaderHooks = asyncLoaderHooks; if (asyncLoaderHooks) { this.allowImportMetaResolve = asyncLoaderHooks.allowImportMetaResolve; + this.isForAsyncLoaderHookWorker = asyncLoaderHooks.isForAsyncLoaderHookWorker; } else { this.allowImportMetaResolve = true; + this.isForAsyncLoaderHookWorker = false; } } @@ -249,7 +263,7 @@ class ModuleLoader { async executeModuleJob(url, wrap, isEntryPoint = false) { const { ModuleJob } = require('internal/modules/esm/module_job'); const module = await onImport.tracePromise(async () => { - const job = new ModuleJob(this, url, undefined, wrap, kEvaluationPhase, false, false); + const job = new ModuleJob(this, url, undefined, wrap, kEvaluationPhase, false, false, kImportInImportedESM); this.loadCache.set(url, undefined, job); const { module } = await job.run(isEntryPoint); return module; @@ -279,62 +293,6 @@ class ModuleLoader { return this.executeModuleJob(url, wrap, isEntryPoint); } - /** - * Get a (possibly not yet fully linked) module job from the cache, or create one and return its Promise. - * @param {string} specifier The module request of the module to be resolved. Typically, what's - * requested by `import ''` or `import('')`. - * @param {string} [parentURL] The URL of the module where the module request is initiated. - * It's undefined if it's from the root module. - * @param {ImportAttributes} importAttributes Attributes from the import statement or expression. - * @param {number} phase Import phase. - * @returns {Promise} - */ - async getModuleJobForImport(specifier, parentURL, importAttributes, phase) { - const resolveResult = await this.resolve(specifier, parentURL, importAttributes); - return this.#getJobFromResolveResult(resolveResult, parentURL, importAttributes, phase, false); - } - - /** - * Similar to {@link getModuleJobForImport} but it's used for `require()` resolved by the ESM loader - * in imported CJS modules. This runs synchronously and when it returns, the module job's module - * requests are all linked. - * @param {string} specifier See {@link getModuleJobForImport} - * @param {string} [parentURL] See {@link getModuleJobForImport} - * @param {ImportAttributes} importAttributes See {@link getModuleJobForImport} - * @param {number} phase Import phase. - * @returns {Promise} - */ - getModuleJobForRequireInImportedCJS(specifier, parentURL, importAttributes, phase) { - const resolveResult = this.resolveSync(specifier, parentURL, importAttributes); - return this.#getJobFromResolveResult(resolveResult, parentURL, importAttributes, phase, true); - } - - /** - * Given a resolved module request, obtain a ModuleJobBase from it - if it's already cached, - * return the cached ModuleJobBase. Otherwise, load its source and translate it into a ModuleWrap first. - * @param {{ format: string, url: string }} resolveResult Resolved module request. - * @param {string} [parentURL] See {@link getModuleJobForImport} - * @param {ImportAttributes} importAttributes See {@link getModuleJobForImport} - * @param {number} phase Import phase. - * @param {boolean} isForRequireInImportedCJS Whether this is done for require() in imported CJS. - * @returns {ModuleJobBase} - */ - #getJobFromResolveResult(resolveResult, parentURL, importAttributes, phase, - isForRequireInImportedCJS = false) { - const { url, format } = resolveResult; - const resolvedImportAttributes = resolveResult.importAttributes ?? importAttributes; - let job = this.loadCache.get(url, resolvedImportAttributes.type); - - if (job === undefined) { - job = this.#createModuleJob(url, resolvedImportAttributes, phase, parentURL, format, - isForRequireInImportedCJS); - } else { - job.ensurePhase(phase); - } - - return job; - } - /** * This constructs (creates, instantiates and evaluates) a module graph that * is require()'d. @@ -347,6 +305,10 @@ class ModuleLoader { */ importSyncForRequire(mod, filename, source, isMain, parent) { const url = pathToFileURL(filename).href; + if (!getOptionValue('--experimental-require-module')) { + throw new ERR_REQUIRE_ESM(url, true); + } + let job = this.loadCache.get(url, kImplicitTypeAttribute); // This module job is already created: // 1. If it was loaded by `require()` before, at this point the instantiation @@ -364,9 +326,9 @@ class ModuleLoader { if (job !== undefined) { mod[kRequiredModuleSymbol] = job.module; const parentFilename = urlToFilename(parent?.filename); - // TODO(node:55782): this race may stop to happen when the ESM resolution and loading become synchronous. + // This race should only be possible on the loader hook thread. See https://github.com/nodejs/node/issues/59666 if (!job.module) { - assert.fail(getRaceMessage(filename, parentFilename)); + assert.fail(getRaceMessage(filename, parentFilename), this.isForAsyncLoaderHookWorker); } const status = job.module.getStatus(); debug('Module status', job, status); @@ -419,92 +381,67 @@ class ModuleLoader { const inspectBrk = (isMain && getOptionValue('--inspect-brk')); const { ModuleJobSync } = require('internal/modules/esm/module_job'); - job = new ModuleJobSync(this, url, kEmptyObject, wrap, kEvaluationPhase, isMain, inspectBrk); + job = new ModuleJobSync(this, url, kEmptyObject, wrap, kEvaluationPhase, isMain, inspectBrk, + kImportInRequiredESM); this.loadCache.set(url, kImplicitTypeAttribute, job); mod[kRequiredModuleSymbol] = job.module; return { wrap: job.module, namespace: job.runSync(parent).namespace }; } /** - * Resolve individual module requests and create or get the cached ModuleWraps for - * each of them. This is only used to create a module graph being require()'d. - * @param {string} specifier Specifier of the the imported module. - * @param {string} parentURL Where the import comes from. - * @param {object} importAttributes import attributes from the import statement. - * @param {number} phase The import phase. - * @returns {ModuleJobBase} + * Check invariants on a cached module job when require()'d from ESM. + * @param {string} specifier The first parameter of require(). + * @param {string} url URL of the module being required. + * @param {string|undefined} parentURL URL of the module calling require(). + * @param {ModuleJobBase} job The cached module job. */ - getModuleJobForRequire(specifier, parentURL, importAttributes, phase) { - const parsed = URLParse(specifier); - if (parsed != null) { - const protocol = parsed.protocol; - if (protocol === 'https:' || protocol === 'http:') { - throw new ERR_NETWORK_IMPORT_DISALLOWED(specifier, parentURL, - 'ES modules cannot be loaded by require() from the network'); + #checkCachedJobForRequireESM(specifier, url, parentURL, job) { + // This race should only be possible on the loader hook thread. See https://github.com/nodejs/node/issues/59666 + if (!job.module) { + assert.fail(getRaceMessage(url, parentURL, this.isForAsyncLoaderHookWorker)); + } + // This module is being evaluated, which means it's imported in a previous link + // in a cycle. + if (job.module.getStatus() === kEvaluating) { + const parentFilename = urlToFilename(parentURL); + let message = `Cannot import Module ${specifier} in a cycle.`; + if (parentFilename) { + message += ` (from ${parentFilename})`; } - assert(protocol === 'file:' || protocol === 'node:' || protocol === 'data:'); + throw new ERR_REQUIRE_CYCLE_MODULE(message); } - // TODO(joyeecheung): consolidate cache behavior and use resolveSync() and - // loadSync() here. - const resolveResult = this.#cachedResolveSync(specifier, parentURL, importAttributes); - const { url, format } = resolveResult; - if (!getOptionValue('--experimental-require-module')) { - throw new ERR_REQUIRE_ESM(url, true); - } - const resolvedImportAttributes = resolveResult.importAttributes ?? importAttributes; - let job = this.loadCache.get(url, resolvedImportAttributes.type); - if (job !== undefined) { - // TODO(node:55782): this race may stop happening when the ESM resolution and loading become synchronous. - if (!job.module) { - assert.fail(getRaceMessage(url, parentURL)); - } - // This module is being evaluated, which means it's imported in a previous link - // in a cycle. - if (job.module.getStatus() === kEvaluating) { - const parentFilename = urlToFilename(parentURL); - let message = `Cannot import Module ${specifier} in a cycle.`; - if (parentFilename) { - message += ` (from ${parentFilename})`; - } - throw new ERR_REQUIRE_CYCLE_MODULE(message); - } - job.ensurePhase(phase); - // Otherwise the module could be imported before but the evaluation may be already - // completed (e.g. the require call is lazy) so it's okay. We will return the - // module now and check asynchronicity of the entire graph later, after the - // graph is instantiated. - return job; - } + // Otherwise the module could be imported before but the evaluation may be already + // completed (e.g. the require call is lazy) so it's okay. We will return the + // job and check asynchronicity of the entire graph later, after the + // graph is instantiated. + } - const loadResult = this.#loadSync(url, { format, importAttributes }); - - const formatFromLoad = loadResult.format; + /** + * Load a module and translate it into a ModuleWrap for require(esm). + * This is run synchronously, and the translator always return a ModuleWrap synchronously. + * @param {string} url URL of the module to be translated. + * @param {object} loadContext See {@link load} + * @param {string|undefined} parentURL URL of the parent module. Undefined if it's the entry point. + * @param {ModuleRequest} request Module request. + * @returns {ModuleWrap} + */ + loadAndTranslateForImportInRequiredESM(url, loadContext, parentURL, request) { + const loadResult = this.#loadSync(url, loadContext); // Use the synchronous commonjs translator which can deal with cycles. + const formatFromLoad = loadResult.format; const translatorKey = (formatFromLoad === 'commonjs' || formatFromLoad === 'commonjs-typescript') ? 'commonjs-sync' : formatFromLoad; - - if (translatorKey === 'wasm') { - assert.fail('WASM is currently unsupported by require(esm)'); - } - - const { source } = loadResult; - const isMain = (parentURL === undefined); - const translateContext = { format: formatFromLoad, source, translatorKey, __proto__: null }; + const translateContext = { ...loadResult, translatorKey, __proto__: null }; const wrap = this.#translate(url, translateContext, parentURL); assert(wrap instanceof ModuleWrap, `Translator used for require(${url}) should not be async`); - if (process.env.WATCH_REPORT_DEPENDENCIES && process.send) { - process.send({ 'watch:import': [url] }); - } - const cjsModule = wrap[imported_cjs_symbol]; if (cjsModule) { - assert(translatorKey === 'commonjs-sync'); // Check if the ESM initiating import CJS is being required by the same CJS module. if (cjsModule?.[kIsExecuting]) { const parentFilename = urlToFilename(parentURL); - let message = `Cannot import CommonJS Module ${specifier} in a cycle.`; + let message = `Cannot import CommonJS Module ${request.specifier} in a cycle.`; if (parentFilename) { message += ` (from ${parentFilename})`; } @@ -512,12 +449,7 @@ class ModuleLoader { } } - const inspectBrk = (isMain && getOptionValue('--inspect-brk')); - const { ModuleJobSync } = require('internal/modules/esm/module_job'); - job = new ModuleJobSync(this, url, importAttributes, wrap, phase, isMain, inspectBrk); - - this.loadCache.set(url, importAttributes.type, job); - return job; + return wrap; } /** @@ -540,6 +472,9 @@ class ModuleLoader { const result = FunctionPrototypeCall(translator, this, url, translateContext, parentURL); assert(result instanceof ModuleWrap, `The ${format} module returned is not a ModuleWrap`); + if (format === 'commonjs' || format === 'commonjs-sync' || format === 'require-commonjs') { + result.isCommonJS = true; + } return result; } @@ -594,54 +529,72 @@ class ModuleLoader { return this.#translate(url, translateContext, parentURL); }; if (isPromise(maybePromise)) { - return maybePromise.then(afterLoad); + return PromisePrototypeThen(maybePromise, afterLoad); } return afterLoad(maybePromise); } /** - * Load a module and translate it into a ModuleWrap, and create a ModuleJob from it. - * This runs synchronously. If isForRequireInImportedCJS is true, the module should be linked - * by the time this returns. Otherwise it may still have pending module requests. - * @param {string} url The URL that was resolved for this module. - * @param {ImportAttributes} importAttributes See {@link getModuleJobForImport} - * @param {number} phase Import phase. - * @param {string} [parentURL] See {@link getModuleJobForImport} - * @param {string} [format] The format hint possibly returned by the `resolve` hook - * @param {boolean} isForRequireInImportedCJS Whether this module job is created for require() - * in imported CJS. + * Given a resolved module request, obtain a ModuleJobBase from it - if it's already cached, + * return the cached ModuleJobBase. Otherwise, load its source and translate it into a ModuleWrap first. + * This runs synchronously. On any thread that is not an async loader hook worker thread, + * the module should be linked by the time this returns. Otherwise it may still have + * pending module requests. + * @param {string} parentURL See {@link getOrCreateModuleJob} + * @param {{format: string, url: string}} resolveResult + * @param {ModuleRequest} request Module request. + * @param {ModuleRequestType} requestType Type of the module request. * @returns {ModuleJobBase} The (possibly pending) module job */ - #createModuleJob(url, importAttributes, phase, parentURL, format, isForRequireInImportedCJS) { - const context = { format, importAttributes }; + #getOrCreateModuleJobAfterResolve(parentURL, resolveResult, request, requestType) { + const { url, format } = resolveResult; + // TODO(joyeecheung): update the module requests to use importAttributes as property names. + const importAttributes = resolveResult.importAttributes ?? request.attributes; + let job = this.loadCache.get(url, importAttributes.type); + + if (job !== undefined) { + if (requestType === kImportInRequiredESM) { + this.#checkCachedJobForRequireESM(request.specifier, url, parentURL, job); + } + job.ensurePhase(request.phase, requestType); + return job; + } + + const context = { format, importAttributes, __proto__: null }; - const isMain = parentURL === undefined; let moduleOrModulePromise; - if (isForRequireInImportedCJS) { + if (requestType === kRequireInImportedCJS) { moduleOrModulePromise = this.loadAndTranslateForRequireInImportedCJS(url, context, parentURL); + } else if (requestType === kImportInRequiredESM) { + moduleOrModulePromise = this.loadAndTranslateForImportInRequiredESM(url, context, parentURL, request); } else { moduleOrModulePromise = this.loadAndTranslate(url, context, parentURL); } - const inspectBrk = ( - isMain && - getOptionValue('--inspect-brk') - ); - - if (process.env.WATCH_REPORT_DEPENDENCIES && process.send) { - process.send({ 'watch:import': [url] }); + if (requestType === kImportInRequiredESM || requestType === kRequireInImportedCJS || + !this.isForAsyncLoaderHookWorker) { + assert(moduleOrModulePromise instanceof ModuleWrap, `Expected ModuleWrap for loading ${url}`); } - const { ModuleJob } = require('internal/modules/esm/module_job'); - const job = new ModuleJob( + if (process.env.WATCH_REPORT_DEPENDENCIES && process.send) { + const type = requestType === kRequireInImportedCJS ? 'require' : 'import'; + process.send({ [`watch:${type}`]: [url] }); + } + + const { ModuleJob, ModuleJobSync } = require('internal/modules/esm/module_job'); + // TODO(joyeecheung): use ModuleJobSync for kRequireInImportedCJS too. + const ModuleJobCtor = (requestType === kImportInRequiredESM ? ModuleJobSync : ModuleJob); + const isMain = (parentURL === undefined); + const inspectBrk = (isMain && getOptionValue('--inspect-brk')); + job = new ModuleJobCtor( this, url, importAttributes, moduleOrModulePromise, - phase, + request.phase, isMain, inspectBrk, - isForRequireInImportedCJS, + requestType, ); this.loadCache.set(url, importAttributes.type, job); @@ -649,6 +602,34 @@ class ModuleLoader { return job; } + /** + * Get a (possibly not yet fully linked) module job from the cache, or create one and return its Promise. + * @param {string} [parentURL] The URL of the module where the module request is initiated. + * It's undefined if it's from the root module. + * @param {ModuleRequest} request Module request. + * @param {ModuleRequestType} requestType Type of the module request. + * @returns {Promise|ModuleJobBase} + */ + getOrCreateModuleJob(parentURL, request, requestType) { + let maybePromise; + if (requestType === kRequireInImportedCJS || requestType === kImportInRequiredESM) { + // In these two cases, resolution must be synchronous. + maybePromise = this.resolveSync(parentURL, request); + assert(!isPromise(maybePromise)); + } else { + maybePromise = this.#resolve(parentURL, request); + } + + const afterResolve = (resolveResult) => { + return this.#getOrCreateModuleJobAfterResolve(parentURL, resolveResult, request, requestType); + }; + + if (isPromise(maybePromise)) { + return PromisePrototypeThen(maybePromise, afterResolve); + } + return afterResolve(maybePromise); + } + /** * This method is usually called indirectly as part of the loading processes. * Use directly with caution. @@ -662,8 +643,16 @@ class ModuleLoader { */ async import(specifier, parentURL, importAttributes, phase = kEvaluationPhase, isEntryPoint = false) { return onImport.tracePromise(async () => { - const moduleJob = await this.getModuleJobForImport(specifier, parentURL, importAttributes, - phase); + const request = { specifier, phase, attributes: importAttributes, __proto__: null }; + let moduleJob; + try { + moduleJob = await this.getOrCreateModuleJob(parentURL, request); + } catch (e) { + if (e?.code === 'ERR_ASYNC_LOADER_REQUEST_NEVER_SETTLED') { + return new Promise(() => {}); + } + throw e; + } if (phase === kSourcePhase) { const module = await moduleJob.modulePromise; return module.getModuleSourceObject(); @@ -695,28 +684,20 @@ class ModuleLoader { /** * Resolve a module request to a URL identifying the location of the module. Handles customization hooks, * if any. - * @param {string|URL} specifier The module request of the module to be resolved. Typically, what's - * requested by `import specifier`, `import(specifier)` or `import.meta.resolve(specifier)`. * @param {string} [parentURL] The URL of the module where the module request is initiated. * It's undefined if it's from the root module. - * @param {ImportAttributes} importAttributes Attributes from the import statement or expression. - * @returns {Promise<{format: string, url: string}>} + * @param {ModuleRequest} request Module request. + * @returns {Promise<{format: string, url: string}>|{format: string, url: string}} */ - resolve(specifier, parentURL, importAttributes) { - specifier = `${specifier}`; - if (syncResolveHooks.length) { - // Has module.registerHooks() hooks, use the synchronous variant that can handle both hooks. - return this.resolveSync(specifier, parentURL, importAttributes); - } - if (this.#asyncLoaderHooks) { // Only has module.register hooks. + #resolve(parentURL, request) { + if (this.isForAsyncLoaderHookWorker) { + const specifier = `${request.specifier}`; + const importAttributes = request.attributes ?? kEmptyObject; + // TODO(joyeecheung): invoke the synchronous hooks in the default step on loader thread. return this.#asyncLoaderHooks.resolve(specifier, parentURL, importAttributes); } - return this.#cachedDefaultResolve(specifier, { - __proto__: null, - conditions: this.#defaultConditions, - parentURL, - importAttributes, - }); + + return this.resolveSync(parentURL, request); } /** @@ -739,25 +720,6 @@ class ModuleLoader { return result; } - /** - * Either return a cached resolution, or perform the synchronous resolution, and - * cache the result. - * @param {string} specifier See {@link resolve}. - * @param {string} [parentURL] See {@link resolve}. - * @param {ImportAttributes} importAttributes See {@link resolve}. - * @returns {{ format: string, url: string }} - */ - #cachedResolveSync(specifier, parentURL, importAttributes) { - const requestKey = this.#resolveCache.serializeKey(specifier, importAttributes); - const cachedResult = this.#resolveCache.get(requestKey, parentURL); - if (cachedResult != null) { - return cachedResult; - } - const result = this.resolveSync(specifier, parentURL, importAttributes); - this.#resolveCache.set(requestKey, parentURL, result); - return result; - } - /** * This is the default resolve step for module.registerHooks(), which incorporates asynchronous hooks * from module.register() which are run in a blocking fashion for it to be synchronous. @@ -767,7 +729,7 @@ class ModuleLoader { * @returns {{ format: string, url: string }} */ #resolveAndMaybeBlockOnLoaderThread(specifier, context) { - if (this.#asyncLoaderHooks) { + if (this.#asyncLoaderHooks?.resolveSync) { return this.#asyncLoaderHooks.resolveSync(specifier, context.parentURL, context.importAttributes); } return this.#cachedDefaultResolve(specifier, context); @@ -779,46 +741,43 @@ class ModuleLoader { * from the loader thread for this to be synchronous. * This is here to support `import.meta.resolve()`, `require()` in imported CJS, and * `module.registerHooks()` hooks. - * - * TODO(joyeecheung): consolidate the cache behavior and use this in require(esm). - * @param {string|URL} specifier See {@link resolve}. * @param {string} [parentURL] See {@link resolve}. - * @param {ImportAttributes} [importAttributes] See {@link resolve}. + * @param {ModuleRequest} request See {@link resolve}. * @returns {{ format: string, url: string }} */ - resolveSync(specifier, parentURL, importAttributes = { __proto__: null }) { - specifier = `${specifier}`; + resolveSync(parentURL, request) { + const specifier = `${request.specifier}`; + const importAttributes = request.attributes ?? kEmptyObject; + if (syncResolveHooks.length) { // Has module.registerHooks() hooks, chain the asynchronous hooks in the default step. return resolveWithSyncHooks(specifier, parentURL, importAttributes, this.#defaultConditions, this.#resolveAndMaybeBlockOnLoaderThread.bind(this)); } - return this.#resolveAndMaybeBlockOnLoaderThread(specifier, { - __proto__: null, + const context = { + ...request, conditions: this.#defaultConditions, parentURL, importAttributes, - }); + __proto__: null, + }; + return this.#resolveAndMaybeBlockOnLoaderThread(specifier, context); } /** * Provide source that is understood by one of Node's translators. Handles customization hooks, * if any. + * @typedef { {format: ModuleFormat, source: ModuleSource }} LoadResult * @param {string} url The URL of the module to be loaded. * @param {object} context Metadata about the module - * @returns {Promise<{ format: ModuleFormat, source: ModuleSource }> | { format: ModuleFormat, source: ModuleSource }} + * @returns {Promise | LoadResult}} */ load(url, context) { - if (syncLoadHooks.length) { - // Has module.registerHooks() hooks, use the synchronous variant that can handle both hooks. - return this.#loadSync(url, context); - } - if (this.#asyncLoaderHooks) { + if (this.isForAsyncLoaderHookWorker) { + // TODO(joyeecheung): invoke the synchronous hooks in the default step on loader thread. return this.#asyncLoaderHooks.load(url, context); } - - defaultLoadSync ??= require('internal/modules/esm/load').defaultLoadSync; - return defaultLoadSync(url, context); + return this.#loadSync(url, context); } /** @@ -829,7 +788,7 @@ class ModuleLoader { * @returns {{ format: ModuleFormat, source: ModuleSource }} */ #loadAndMaybeBlockOnLoaderThread(url, context) { - if (this.#asyncLoaderHooks) { + if (this.#asyncLoaderHooks?.loadSync) { return this.#asyncLoaderHooks.loadSync(url, context); } defaultLoadSync ??= require('internal/modules/esm/load').defaultLoadSync; @@ -841,8 +800,6 @@ class ModuleLoader { * from module.register(), this blocks on the loader thread for it to return synchronously. * * This is here to support `require()` in imported CJS and `module.registerHooks()` hooks. - * - * TODO(joyeecheung): consolidate the cache behavior and use this in require(esm). * @param {string} url See {@link load} * @param {object} [context] See {@link load} * @returns {{ format: ModuleFormat, source: ModuleSource }} diff --git a/lib/internal/modules/esm/module_job.js b/lib/internal/modules/esm/module_job.js index f032b2eeb1..8459ab5f47 100644 --- a/lib/internal/modules/esm/module_job.js +++ b/lib/internal/modules/esm/module_job.js @@ -3,6 +3,7 @@ const { Array, ArrayPrototypeJoin, + ArrayPrototypePush, ArrayPrototypeSome, FunctionPrototype, ObjectSetPrototypeOf, @@ -36,7 +37,10 @@ const { entry_point_module_private_symbol, }, } = internalBinding('util'); -const { decorateErrorStack, kEmptyObject } = require('internal/util'); +/** + * @typedef {import('./utils.js').ModuleRequestType} ModuleRequestType + */ +const { decorateErrorStack } = require('internal/util'); const { isPromise } = require('internal/util/types'); const { getSourceMapsSupport, @@ -106,8 +110,9 @@ const explainCommonJSGlobalLikeNotDefinedError = (e, url, hasTopLevelAwait) => { }; class ModuleJobBase { - constructor(url, importAttributes, phase, isMain, inspectBrk) { + constructor(loader, url, importAttributes, phase, isMain, inspectBrk) { assert(typeof phase === 'number'); + this.loader = loader; this.importAttributes = importAttributes; this.phase = phase; this.isMain = isMain; @@ -115,13 +120,65 @@ class ModuleJobBase { this.url = url; } + + /** + * Synchronously link the module and its dependencies. + * @param {ModuleRequestType} requestType Type of the module request. + * @returns {ModuleJobBase[]} + */ + syncLink(requestType) { + // Store itself into the cache first before linking in case there are circular + // references in the linking. + this.loader.loadCache.set(this.url, this.type, this); + const moduleRequests = this.module.getModuleRequests(); + // Modules should be aligned with the moduleRequests array in order. + const modules = Array(moduleRequests.length); + const evaluationDepJobs = []; + this.commonJsDeps = Array(moduleRequests.length); + try { + for (let idx = 0; idx < moduleRequests.length; idx++) { + const request = moduleRequests[idx]; + // TODO(joyeecheung): split this into two iterators, one for resolving and one for loading so + // that hooks can pre-fetch sources off-thread. + const job = this.loader.getOrCreateModuleJob(this.url, request, requestType); + debug(`ModuleJobBase.syncLink() ${this.url} -> ${request.specifier}`, job); + assert(!isPromise(job)); + assert(job.module instanceof ModuleWrap); + if (request.phase === kEvaluationPhase) { + ArrayPrototypePush(evaluationDepJobs, job); + } + modules[idx] = job.module; + this.commonJsDeps[idx] = job.module.isCommonJS; + } + this.module.link(modules); + } finally { + // Restore it - if it succeeds, we'll reset in the caller; Otherwise it's + // not cached and if the error is caught, subsequent attempt would still fail. + this.loader.loadCache.delete(this.url, this.type); + } + + return evaluationDepJobs; + } + + /** + * Ensure that this ModuleJob is moving towards the required phase + * (does not necessarily mean it is ready at that phase - run does that) + * @param {number} phase + */ + ensurePhase(phase, requestType) { + if (this.phase < phase) { + this.phase = phase; + this.linked = this.link(requestType); + if (isPromise(this.linked)) { + PromisePrototypeThen(this.linked, undefined, noop); + } + } + } } /* A ModuleJob tracks the loading of a single Module, and the ModuleJobs of * its dependencies, over time. */ class ModuleJob extends ModuleJobBase { - #loader = null; - /** * @param {ModuleLoader} loader The ESM loader. * @param {string} url URL of the module to be wrapped in ModuleJob. @@ -131,12 +188,11 @@ class ModuleJob extends ModuleJobBase { * @param {boolean} isMain Whether the module is the entry point. * @param {boolean} inspectBrk Whether this module should be evaluated with the * first line paused in the debugger (because --inspect-brk is passed). - * @param {boolean} isForRequireInImportedCJS Whether this is created for require() in imported CJS. + * @param {ModuleRequestType} requestType Type of the module request. */ constructor(loader, url, importAttributes = { __proto__: null }, moduleOrModulePromise, - phase = kEvaluationPhase, isMain, inspectBrk, isForRequireInImportedCJS = false) { - super(url, importAttributes, phase, isMain, inspectBrk); - this.#loader = loader; + phase = kEvaluationPhase, isMain, inspectBrk, requestType) { + super(loader, url, importAttributes, phase, isMain, inspectBrk); // Expose the promise to the ModuleWrap directly for linking below. if (isPromise(moduleOrModulePromise)) { @@ -148,10 +204,12 @@ class ModuleJob extends ModuleJobBase { if (this.phase === kEvaluationPhase) { // Promise for the list of all dependencyJobs. - this.linked = this.#link(); + this.linked = this.link(requestType); // This promise is awaited later anyway, so silence // 'unhandled rejection' warnings. - PromisePrototypeThen(this.linked, undefined, noop); + if (isPromise(this.linked)) { + PromisePrototypeThen(this.linked, undefined, noop); + } } // instantiated == deep dependency jobs wrappers are instantiated, @@ -160,79 +218,69 @@ class ModuleJob extends ModuleJobBase { } /** - * Ensure that this ModuleJob is moving towards the required phase - * (does not necessarily mean it is ready at that phase - run does that) - * @param {number} phase + * @param {ModuleRequestType} requestType Type of the module request. + * @returns {ModuleJobBase[]|Promise} */ - ensurePhase(phase) { - if (this.phase < phase) { - this.phase = phase; - this.linked = this.#link(); - PromisePrototypeThen(this.linked, undefined, noop); + link(requestType) { + if (this.loader.isForAsyncLoaderHookWorker) { + return this.#asyncLink(requestType); } + return this.syncLink(requestType); } /** - * Iterates the module requests and links with the loader. - * @returns {Promise} Dependency module jobs. + * @param {ModuleRequestType} requestType Type of the module request. + * @returns {Promise} */ - async #link() { + async #asyncLink(requestType) { + assert(this.loader.isForAsyncLoaderHookWorker); this.module = await this.modulePromise; assert(this.module instanceof ModuleWrap); - const moduleRequests = this.module.getModuleRequests(); - // Explicitly keeping track of dependency jobs is needed in order - // to flatten out the dependency graph below in `_instantiate()`, - // so that circular dependencies can't cause a deadlock by two of - // these `link` callbacks depending on each other. // Create an ArrayLike to avoid calling into userspace with `.then` // when returned from the async function. - const evaluationDepJobs = Array(moduleRequests.length); - ObjectSetPrototypeOf(evaluationDepJobs, null); - // Modules should be aligned with the moduleRequests array in order. const modulePromises = Array(moduleRequests.length); - // Track each loop for whether it is an evaluation phase or source phase request. - let isEvaluation; - // Iterate with index to avoid calling into userspace with `Symbol.iterator`. - for ( - let idx = 0, eidx = 0; - // Use the let-scoped eidx value to update the executionDepJobs length at the end of the loop. - idx < moduleRequests.length || (evaluationDepJobs.length = eidx, false); - idx++, eidx += isEvaluation - ) { - const { specifier, phase, attributes } = moduleRequests[idx]; - isEvaluation = phase === kEvaluationPhase; - // TODO(joyeecheung): resolve all requests first, then load them in another - // loop so that hooks can pre-fetch sources off-thread. - const dependencyJobPromise = this.#loader.getModuleJobForImport( - specifier, this.url, attributes, phase, - ); + const evaluationDepJobs = []; + this.commonJsDeps = Array(moduleRequests.length); + for (let idx = 0; idx < moduleRequests.length; idx++) { + const request = moduleRequests[idx]; + // Explicitly keeping track of dependency jobs is needed in order + // to flatten out the dependency graph below in `asyncInstantiate()`, + // so that circular dependencies can't cause a deadlock by two of + // these `link` callbacks depending on each other. + // TODO(joyeecheung): split this into two iterators, one for resolving and one for loading so + // that hooks can pre-fetch sources off-thread. + const dependencyJobPromise = this.loader.getOrCreateModuleJob(this.url, request, requestType); const modulePromise = PromisePrototypeThen(dependencyJobPromise, (job) => { - debug(`async link() ${this.url} -> ${specifier}`, job); - if (phase === kEvaluationPhase) { - evaluationDepJobs[eidx] = job; + debug(`ModuleJob.asyncLink() ${this.url} -> ${request.specifier}`, job); + if (request.phase === kEvaluationPhase) { + ArrayPrototypePush(evaluationDepJobs, job); } return job.modulePromise; }); modulePromises[idx] = modulePromise; } - const modules = await SafePromiseAllReturnArrayLike(modulePromises); - this.module.link(modules); + for (let idx = 0; idx < moduleRequests.length; idx++) { + this.commonJsDeps[idx] = modules[idx].isCommonJS; + } + this.module.link(modules); return evaluationDepJobs; } #instantiate() { if (this.instantiated === undefined) { - this.instantiated = this.#_instantiate(); + this.instantiated = this.#asyncInstantiate(); } return this.instantiated; } - async #_instantiate() { + async #asyncInstantiate() { const jobsInGraph = new SafeSet(); + // TODO(joyeecheung): if it's not on the async loader thread, consider this already + // linked. const addJobsToDependencyGraph = async (moduleJob) => { debug(`async addJobsToDependencyGraph() ${this.url}`, moduleJob); @@ -240,7 +288,7 @@ class ModuleJob extends ModuleJobBase { return; } jobsInGraph.add(moduleJob); - const dependencyJobs = await moduleJob.linked; + const dependencyJobs = isPromise(moduleJob.linked) ? await moduleJob.linked : moduleJob.linked; return SafePromiseAllReturnVoid(dependencyJobs, addJobsToDependencyGraph); }; await addJobsToDependencyGraph(this); @@ -263,31 +311,19 @@ class ModuleJob extends ModuleJobBase { StringPrototypeIncludes(e.message, ' does not provide an export named')) { const splitStack = StringPrototypeSplit(e.stack, '\n', 2); - const parentFileUrl = RegExpPrototypeSymbolReplace( - /:\d+$/, - splitStack[0], - '', - ); const { 1: childSpecifier, 2: name } = RegExpPrototypeExec( /module '(.*)' does not provide an export named '(.+)'/, e.message); - const { url: childFileURL } = await this.#loader.resolve( - childSpecifier, - parentFileUrl, - kEmptyObject, - ); - let format; - try { - // This might throw for non-CommonJS modules because we aren't passing - // in the import attributes and some formats require them; but we only - // care about CommonJS for the purposes of this error message. - ({ format } = - await this.#loader.load(childFileURL)); - } catch { - // Continue regardless of error. + const moduleRequests = this.module.getModuleRequests(); + let isCommonJS = false; + for (let i = 0; i < moduleRequests.length; ++i) { + if (moduleRequests[i].specifier === childSpecifier) { + isCommonJS = this.commonJsDeps[i]; + break; + } } - if (format === 'commonjs') { + if (isCommonJS) { const importStatement = splitStack[1]; // TODO(@ctavan): The original error stack only provides the single // line which causes the error. For multi-line import statements we @@ -394,8 +430,6 @@ class ModuleJob extends ModuleJobBase { * TODO(joyeecheung): consolidate this with the isForRequireInImportedCJS variant of ModuleJob. */ class ModuleJobSync extends ModuleJobBase { - #loader = null; - /** * @param {ModuleLoader} loader The ESM loader. * @param {string} url URL of the module to be wrapped in ModuleJob. @@ -407,57 +441,26 @@ class ModuleJobSync extends ModuleJobBase { * first line paused in the debugger (because --inspect-brk is passed). */ constructor(loader, url, importAttributes, moduleWrap, phase = kEvaluationPhase, isMain, - inspectBrk) { - super(url, importAttributes, phase, isMain, inspectBrk, true); + inspectBrk, requestType) { + super(loader, url, importAttributes, phase, isMain, inspectBrk); - this.#loader = loader; this.module = moduleWrap; assert(this.module instanceof ModuleWrap); this.linked = undefined; this.type = importAttributes.type; if (phase === kEvaluationPhase) { - this.#link(); + this.linked = this.link(requestType); } } /** - * Ensure that this ModuleJob is at the required phase - * @param {number} phase + * @param {ModuleRequestType} requestType Type of the module request. + * @returns {ModuleJobBase[]} */ - ensurePhase(phase) { - if (this.phase < phase) { - this.phase = phase; - this.#link(); - } - } - - #link() { - // Store itself into the cache first before linking in case there are circular - // references in the linking. - this.#loader.loadCache.set(this.url, this.type, this); - try { - const moduleRequests = this.module.getModuleRequests(); - // Modules should be aligned with the moduleRequests array in order. - const modules = Array(moduleRequests.length); - const evaluationDepJobs = Array(moduleRequests.length); - let j = 0; - for (let i = 0; i < moduleRequests.length; ++i) { - const { specifier, attributes, phase } = moduleRequests[i]; - const job = this.#loader.getModuleJobForRequire(specifier, this.url, attributes, phase); - modules[i] = job.module; - if (phase === kEvaluationPhase) { - evaluationDepJobs[j++] = job; - } - } - evaluationDepJobs.length = j; - this.module.link(modules); - this.linked = evaluationDepJobs; - } finally { - // Restore it - if it succeeds, we'll reset in the caller; Otherwise it's - // not cached and if the error is caught, subsequent attempt would still fail. - this.#loader.loadCache.delete(this.url, this.type); - } + link(requestType) { + // Synchronous linking is always used for ModuleJobSync. + return this.syncLink(requestType); } get modulePromise() { diff --git a/lib/internal/modules/esm/translators.js b/lib/internal/modules/esm/translators.js index 446349113e..1716328c7a 100644 --- a/lib/internal/modules/esm/translators.js +++ b/lib/internal/modules/esm/translators.js @@ -57,7 +57,7 @@ const { } = require('internal/errors').codes; const { maybeCacheSourceMap } = require('internal/source_map/source_map_cache'); const moduleWrap = internalBinding('module_wrap'); -const { ModuleWrap } = moduleWrap; +const { ModuleWrap, kEvaluationPhase } = moduleWrap; // Lazy-loading to avoid circular dependencies. let getSourceSync; @@ -112,6 +112,7 @@ translators.set('module', function moduleStrategy(url, translateContext, parentU return module; }); +const { requestTypes: { kRequireInImportedCJS } } = require('internal/modules/esm/utils'); /** * Loads a CommonJS module via the ESM Loader sync CommonJS translator. * This translator creates its own version of the `require` function passed into CommonJS modules. @@ -131,7 +132,6 @@ function loadCJSModule(module, source, url, filename, isMain) { if (sourceMapURL) { maybeCacheSourceMap(url, source, module, false, sourceURL, sourceMapURL); } - const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader(); const __dirname = dirname(filename); // eslint-disable-next-line func-name-matching,func-style @@ -154,7 +154,8 @@ function loadCJSModule(module, source, url, filename, isMain) { // FIXME(node:59666) Currently, the ESM loader re-invents require() here for imported CJS and this // requires a separate cache to be populated as well as introducing several quirks. This is not ideal. - const job = cascadedLoader.getModuleJobForRequireInImportedCJS(specifier, url, importAttributes); + const request = { specifier, attributes: importAttributes, phase: kEvaluationPhase, __proto__: null }; + const job = cascadedLoader.getOrCreateModuleJob(url, request, kRequireInImportedCJS); job.runSync(); let mod = cjsCache.get(job.url); assert(job.module, `Imported CJS module ${url} failed to load module ${job.url} using require() due to race condition`); @@ -185,7 +186,9 @@ function loadCJSModule(module, source, url, filename, isMain) { specifier = `${pathToFileURL(path)}`; } } - const { url: resolvedURL } = cascadedLoader.resolveSync(specifier, url, kEmptyObject); + + const request = { specifier, __proto__: null, attributes: kEmptyObject }; + const { url: resolvedURL } = cascadedLoader.resolveSync(url, request); return urlToFilename(resolvedURL); }); setOwnProperty(requireFn, 'main', process.mainModule); diff --git a/lib/internal/modules/esm/utils.js b/lib/internal/modules/esm/utils.js index a9076a7ae9..0af25ebbf6 100644 --- a/lib/internal/modules/esm/utils.js +++ b/lib/internal/modules/esm/utils.js @@ -5,6 +5,7 @@ const { ObjectFreeze, SafeSet, SafeWeakMap, + Symbol, } = primordials; const { @@ -332,6 +333,16 @@ function compileSourceTextModule(url, source, cascadedLoader, context = kEmptyOb return wrap; } + +const kImportInImportedESM = Symbol('kImportInImportedESM'); +const kImportInRequiredESM = Symbol('kImportInRequiredESM'); +const kRequireInImportedCJS = Symbol('kRequireInImportedCJS'); + +/** + * @typedef {kImportInImportedESM | kImportInRequiredESM | kRequireInImportedCJS} ModuleRequestType + */ +const requestTypes = { kImportInImportedESM, kImportInRequiredESM, kRequireInImportedCJS }; + module.exports = { registerModule, initializeESM, @@ -339,4 +350,5 @@ module.exports = { getConditionsSet, shouldSpawnLoaderHookWorker, compileSourceTextModule, + requestTypes, }; diff --git a/lib/internal/test_runner/mock/mock.js b/lib/internal/test_runner/mock/mock.js index fac97e51b6..c018d1690f 100644 --- a/lib/internal/test_runner/mock/mock.js +++ b/lib/internal/test_runner/mock/mock.js @@ -646,9 +646,8 @@ class MockTracker { // If the caller is already a file URL, use it as is. Otherwise, convert it. const hasFileProtocol = StringPrototypeStartsWith(filename, 'file://'); const caller = hasFileProtocol ? filename : pathToFileURL(filename).href; - const { format, url } = sharedState.moduleLoader.resolveSync( - mockSpecifier, caller, kEmptyObject, - ); + const request = { __proto__: null, specifier: mockSpecifier, attributes: kEmptyObject }; + const { format, url } = sharedState.moduleLoader.resolveSync(caller, request); debug('module mock, url = "%s", format = "%s", caller = "%s"', url, format, caller); if (format) { // Format is not yet known for ambiguous files when detection is enabled. validateOneOf(format, 'format', kSupportedFormats); diff --git a/test/es-module/test-esm-loader-hooks.mjs b/test/es-module/test-esm-loader-hooks.mjs index 9e25232146..f2726c8c15 100644 --- a/test/es-module/test-esm-loader-hooks.mjs +++ b/test/es-module/test-esm-loader-hooks.mjs @@ -119,7 +119,7 @@ describe('Loader hooks', { concurrency: !process.env.TEST_PARALLEL }, () => { assert.strictEqual(signal, null); }); - it('import.meta.resolve of a never-settling resolve', async () => { + it('import.meta.resolve of a never-settling resolve should throw', async () => { const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ '--no-warnings', '--experimental-loader', @@ -129,7 +129,7 @@ describe('Loader hooks', { concurrency: !process.env.TEST_PARALLEL }, () => { assert.strictEqual(stderr, ''); assert.match(stdout, /^should be output\r?\n$/); - assert.strictEqual(code, 13); + assert.strictEqual(code, 0); assert.strictEqual(signal, null); }); }); @@ -668,13 +668,17 @@ describe('Loader hooks', { concurrency: !process.env.TEST_PARALLEL }, () => { '--eval', ` import {register} from 'node:module'; - register('data:text/javascript,export function initialize(){return new Promise(()=>{})}'); + try { + register('data:text/javascript,export function initialize(){return new Promise(()=>{})}'); + } catch (e) { + console.log('caught', e.code); + } `, ]); assert.strictEqual(stderr, ''); - assert.strictEqual(stdout, ''); - assert.strictEqual(code, 13); + assert.strictEqual(stdout.trim(), 'caught ERR_ASYNC_LOADER_REQUEST_NEVER_SETTLED'); + assert.strictEqual(code, 0); assert.strictEqual(signal, null); }); diff --git a/test/es-module/test-esm-loader-modulemap.js b/test/es-module/test-esm-loader-modulemap.js index f581c7f507..b582f4b2aa 100644 --- a/test/es-module/test-esm-loader-modulemap.js +++ b/test/es-module/test-esm-loader-modulemap.js @@ -17,12 +17,9 @@ const stubJsModule = createDynamicModule([], ['default'], jsModuleDataUrl); const stubJsonModule = createDynamicModule([], ['default'], jsonModuleDataUrl); const loader = createModuleLoader(); -const jsModuleJob = new ModuleJob(loader, stubJsModule.module, undefined, - () => new Promise(() => {})); -const jsonModuleJob = new ModuleJob(loader, stubJsonModule.module, - { type: 'json' }, - () => new Promise(() => {})); - +const jsModuleJob = new ModuleJob(loader, jsModuleDataUrl, {}, stubJsModule.module); +const jsonModuleJob = new ModuleJob(loader, jsonModuleDataUrl, + { type: 'json' }, stubJsonModule.module); // LoadCache.set and LoadCache.get store and retrieve module jobs for a // specified url/type tuple; LoadCache.has correctly reports whether such jobs diff --git a/test/fixtures/es-module-loaders/never-settling-resolve-step/import.meta.never-resolve.mjs b/test/fixtures/es-module-loaders/never-settling-resolve-step/import.meta.never-resolve.mjs index fc3a077abe..2bc389cc3e 100644 --- a/test/fixtures/es-module-loaders/never-settling-resolve-step/import.meta.never-resolve.mjs +++ b/test/fixtures/es-module-loaders/never-settling-resolve-step/import.meta.never-resolve.mjs @@ -1,5 +1,8 @@ +import assert from 'node:assert'; console.log('should be output'); -import.meta.resolve('never-settle-resolve'); - -console.log('should not be output'); +assert.throws(() => { + import.meta.resolve('never-settle-resolve'); +}, { + code: 'ERR_ASYNC_LOADER_REQUEST_NEVER_SETTLED' +}); diff --git a/test/fixtures/module-hooks/logger-async-hooks.mjs b/test/fixtures/module-hooks/logger-async-hooks.mjs new file mode 100644 index 0000000000..f26d0ce2ae --- /dev/null +++ b/test/fixtures/module-hooks/logger-async-hooks.mjs @@ -0,0 +1,19 @@ +import fs from 'node:fs'; + +export async function resolve(specifier, context, nextResolve) { + fs.writeSync(1, `resolve ${specifier} from ${context.parentURL}\n`); + const result = await nextResolve(specifier, context); + result.shortCircuit = true; + return result; +} + +export async function load(url, context, nextLoad) { + fs.writeSync(1, `load ${url}\n`); + const result = await nextLoad(url, context); + result.shortCircuit = true; + // Handle the async loader hook quirk where source can be nullish for CommonJS. + // If this returns that nullish value verbatim, the `require` in imported + // CJS won't get the hook invoked. + result.source ??= fs.readFileSync(new URL(url), 'utf8'); + return result; +} diff --git a/test/fixtures/module-hooks/register-logger-async-hooks.mjs b/test/fixtures/module-hooks/register-logger-async-hooks.mjs new file mode 100644 index 0000000000..28a6ae5f22 --- /dev/null +++ b/test/fixtures/module-hooks/register-logger-async-hooks.mjs @@ -0,0 +1,2 @@ +import { register } from 'node:module'; +register('./logger-async-hooks.mjs', import.meta.url); diff --git a/test/fixtures/module-hooks/require-esm/cjs.cjs b/test/fixtures/module-hooks/require-esm/cjs.cjs new file mode 100644 index 0000000000..c4a229c0cc --- /dev/null +++ b/test/fixtures/module-hooks/require-esm/cjs.cjs @@ -0,0 +1 @@ +exports.cjsValue = require('./inner.cjs'); \ No newline at end of file diff --git a/test/fixtures/module-hooks/require-esm/esm.mjs b/test/fixtures/module-hooks/require-esm/esm.mjs new file mode 100644 index 0000000000..832a099e38 --- /dev/null +++ b/test/fixtures/module-hooks/require-esm/esm.mjs @@ -0,0 +1,2 @@ +export { esmValue } from './inner.mjs' +export { cjsValue } from './cjs.cjs' \ No newline at end of file diff --git a/test/fixtures/module-hooks/require-esm/inner.cjs b/test/fixtures/module-hooks/require-esm/inner.cjs new file mode 100644 index 0000000000..89de7dbd40 --- /dev/null +++ b/test/fixtures/module-hooks/require-esm/inner.cjs @@ -0,0 +1 @@ +module.exports = 'commonjs'; diff --git a/test/fixtures/module-hooks/require-esm/inner.mjs b/test/fixtures/module-hooks/require-esm/inner.mjs new file mode 100644 index 0000000000..ed57030dff --- /dev/null +++ b/test/fixtures/module-hooks/require-esm/inner.mjs @@ -0,0 +1 @@ +export const esmValue = 'esm'; diff --git a/test/fixtures/module-hooks/require-esm/main.cjs b/test/fixtures/module-hooks/require-esm/main.cjs new file mode 100644 index 0000000000..d7997a1735 --- /dev/null +++ b/test/fixtures/module-hooks/require-esm/main.cjs @@ -0,0 +1,4 @@ +const { esmValue, cjsValue } = require('./esm.mjs'); +console.log('esmValue in main.cjs:', esmValue); +console.log('cjsValue in main.cjs:', cjsValue); +module.exports = { esmValue, cjsValue }; diff --git a/test/fixtures/module-hooks/require-esm/main.mjs b/test/fixtures/module-hooks/require-esm/main.mjs new file mode 100644 index 0000000000..c875685e0d --- /dev/null +++ b/test/fixtures/module-hooks/require-esm/main.mjs @@ -0,0 +1,3 @@ +import { esmValue, cjsValue } from './main.cjs'; +console.log('esmValue in main.mjs:', esmValue); +console.log('cjsValue in main.mjs:', cjsValue); diff --git a/test/module-hooks/test-module-hooks-require-esm.js b/test/module-hooks/test-module-hooks-require-esm.js new file mode 100644 index 0000000000..a3bb0dd2c4 --- /dev/null +++ b/test/module-hooks/test-module-hooks-require-esm.js @@ -0,0 +1,72 @@ +'use strict'; + +// This tests that the async loader hooks can be invoked for require(esm). + +require('../common'); +const common = require('../common'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const fixtures = require('../common/fixtures'); +const assert = require('assert'); + +function assertSubGraph(output) { + // FIXME(node:59666): the async resolve hook invoked for require() in imported CJS only get the URL, + // not the specifier. This may be fixable if we re-implement the async loader hooks from within + // the synchronous loader hooks and use the original require() implementation. + assert.match(output, /resolve .+esm\.mjs from file:.+main\.cjs/); + assert.match(output, /load file:.+esm\.mjs/); + + assert.match(output, /resolve \.\/inner\.mjs from file:.+esm\.mjs/); + assert.match(output, /load file:.+inner\.mjs/); + + assert.match(output, /resolve \.\/cjs\.cjs from file:.+esm\.mjs/); + assert.match(output, /load file:.+cjs\.cjs/); + + // FIXME(node:59666): see above. + assert.match(output, /resolve .+inner\.cjs from file:.+cjs\.cjs/); + assert.match(output, /load file:.+inner\.cjs/); + + assert.match(output, /esmValue in main\.cjs: esm/); + assert.match(output, /cjsValue in main\.cjs: commonjs/); +} + +spawnSyncAndAssert(process.execPath, [ + '--import', + fixtures.fileURL('module-hooks', 'register-logger-async-hooks.mjs'), + fixtures.path('module-hooks', 'require-esm', 'main.cjs'), +], { + stdout: common.mustCall((output) => { + // The graph is: + // main.cjs + // -> esm.mjs + // -> inner.mjs + // -> cjs.cjs + // -> inner.cjs + assert.match(output, /resolve .+main\.cjs from undefined/); + assert.match(output, /load file:.+main\.cjs/); + + assertSubGraph(output); + }), +}); + +spawnSyncAndAssert(process.execPath, [ + '--import', + fixtures.fileURL('module-hooks', 'register-logger-async-hooks.mjs'), + fixtures.path('module-hooks', 'require-esm', 'main.mjs'), +], { + stdout: common.mustCall((output) => { + // The graph is: + // main.mjs + // -> main.cjs + // -> esm.mjs + // -> inner.mjs + // -> cjs.cjs + // -> inner.cjs + assert.match(output, /resolve .+main\.mjs from undefined/); + assert.match(output, /load file:.+main\.mjs/); + + assert.match(output, /resolve .+main\.cjs from file:.+main\.mjs/); + assert.match(output, /load file:.+main\.cjs/); + + assertSubGraph(output); + }), +});