Skip to content

Commit 9bf820c

Browse files
committed
module: ensure successful import returns the same result
1 parent cad0ae7 commit 9bf820c

File tree

5 files changed

+181
-81
lines changed

5 files changed

+181
-81
lines changed

lib/internal/modules/esm/loader.js

Lines changed: 100 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
require('internal/modules/cjs/loader');
55

66
const {
7-
Array,
8-
ArrayIsArray,
7+
ArrayPrototypeJoin,
8+
ArrayPrototypeMap,
9+
ArrayPrototypeSort,
910
FunctionPrototypeCall,
11+
JSONStringify,
12+
ObjectKeys,
1013
ObjectSetPrototypeOf,
1114
PromisePrototypeThen,
12-
SafePromiseAllReturnArrayLike,
15+
SafeMap,
16+
PromiseResolve,
1317
SafeWeakMap,
1418
} = primordials;
1519

@@ -78,6 +82,11 @@ class DefaultModuleLoader {
7882
*/
7983
#defaultConditions = getDefaultConditions();
8084

85+
/**
86+
* Import cache
87+
*/
88+
#importCache = new SafeMap();
89+
8190
/**
8291
* Map of already-loaded CJS modules to use
8392
*/
@@ -119,8 +128,8 @@ class DefaultModuleLoader {
119128
const { setCallbackForWrap } = require('internal/modules/esm/utils');
120129
const module = new ModuleWrap(url, undefined, source, 0, 0);
121130
setCallbackForWrap(module, {
122-
importModuleDynamically: (specifier, { url }, importAssertions) => {
123-
return this.import(specifier, url, importAssertions);
131+
importModuleDynamically: (specifier, { url }, importAttributes) => {
132+
return this.import(specifier, url, importAttributes);
124133
},
125134
});
126135

@@ -147,18 +156,17 @@ class DefaultModuleLoader {
147156
* @param {string | undefined} parentURL The URL of the module importing this
148157
* one, unless this is the Node.js entry
149158
* point.
150-
* @param {Record<string, string>} importAssertions Validations for the
151-
* module import.
159+
* @param {Record<string, string>} importAttributes The import attributes.
152160
* @returns {ModuleJob} The (possibly pending) module job
153161
*/
154-
getModuleJob(specifier, parentURL, importAssertions) {
155-
const resolveResult = this.resolve(specifier, parentURL, importAssertions);
156-
return this.getJobFromResolveResult(resolveResult, parentURL, importAssertions);
162+
getModuleJob(specifier, parentURL, importAttributes) {
163+
const resolveResult = this.resolve(specifier, parentURL, importAttributes);
164+
return this.getJobFromResolveResult(resolveResult, parentURL, importAttributes);
157165
}
158166

159-
getJobFromResolveResult(resolveResult, parentURL, importAssertions) {
167+
getJobFromResolveResult(resolveResult, parentURL, importAttributes) {
160168
const { url, format } = resolveResult;
161-
const resolvedImportAssertions = resolveResult.importAssertions ?? importAssertions;
169+
const resolvedImportAssertions = resolveResult.importAttributes ?? importAttributes;
162170

163171
let job = this.moduleMap.get(url, resolvedImportAssertions.type);
164172

@@ -177,23 +185,23 @@ class DefaultModuleLoader {
177185
/**
178186
* Create and cache an object representing a loaded module.
179187
* @param {string} url The absolute URL that was resolved for this module
180-
* @param {Record<string, string>} importAssertions Validations for the
188+
* @param {Record<string, string>} importAttributes Validations for the
181189
* module import.
182190
* @param {string} [parentURL] The absolute URL of the module importing this
183191
* one, unless this is the Node.js entry point
184192
* @param {string} [format] The format hint possibly returned by the
185193
* `resolve` hook
186194
* @returns {Promise<ModuleJob>} The (possibly pending) module job
187195
*/
188-
#createModuleJob(url, importAssertions, parentURL, format) {
196+
#createModuleJob(url, importAttributes, parentURL, format) {
189197
const moduleProvider = async (url, isMain) => {
190198
const {
191199
format: finalFormat,
192200
responseURL,
193201
source,
194202
} = await this.load(url, {
195203
format,
196-
importAssertions,
204+
importAttributes,
197205
});
198206

199207
const translator = getTranslators().get(finalFormat);
@@ -218,69 +226,95 @@ class DefaultModuleLoader {
218226
const job = new ModuleJob(
219227
this,
220228
url,
221-
importAssertions,
229+
importAttributes,
222230
moduleProvider,
223231
parentURL === undefined,
224232
inspectBrk,
225233
);
226234

227-
this.moduleMap.set(url, importAssertions.type, job);
235+
this.moduleMap.set(url, importAttributes.type, job);
228236

229237
return job;
230238
}
231239

240+
#serializeCache(specifier, parentURL, importAttributes) {
241+
let cache = this.#importCache.get(parentURL);
242+
let specifierCache;
243+
if (cache == null) {
244+
this.#importCache.set(parentURL, cache = new SafeMap());
245+
} else {
246+
specifierCache = cache.get(specifier);
247+
}
248+
249+
if (specifierCache == null) {
250+
cache.set(specifier, specifierCache = { __proto__: null });
251+
}
252+
253+
const serializedAttributes = ArrayPrototypeJoin(
254+
ArrayPrototypeMap(
255+
ArrayPrototypeSort(ObjectKeys(importAttributes)),
256+
(key) => JSONStringify(key) + JSONStringify(importAttributes[key])),
257+
',');
258+
259+
return { specifierCache, serializedAttributes };
260+
}
261+
262+
cacheStatic(specifier, parentURL, importAttributes, result) {
263+
const { specifierCache, serializedAttributes } = this.#serializeCache(specifier, parentURL, importAttributes);
264+
265+
specifierCache[serializedAttributes] = result;
266+
}
267+
268+
async import(specifier, parentURL, importAttributes) {
269+
const { specifierCache, serializedAttributes } = this.#serializeCache(specifier, parentURL, importAttributes);
270+
const removeCache = () => {
271+
delete specifierCache[serializedAttributes];
272+
};
273+
if (specifierCache[serializedAttributes] != null) {
274+
if (PromiseResolve(specifierCache[serializedAttributes]) !== specifierCache[serializedAttributes]) {
275+
const { module } = await specifierCache[serializedAttributes].run();
276+
return module.getNamespace();
277+
}
278+
const fallback = () => {
279+
if (specifierCache[serializedAttributes] != null) {
280+
return PromisePrototypeThen(specifierCache[serializedAttributes], undefined, fallback);
281+
}
282+
const result = this.#import(specifier, parentURL, importAttributes);
283+
specifierCache[serializedAttributes] = result;
284+
PromisePrototypeThen(result, undefined, removeCache);
285+
return result;
286+
};
287+
return PromisePrototypeThen(specifierCache[serializedAttributes], undefined, fallback);
288+
}
289+
const result = this.#import(specifier, parentURL, importAttributes);
290+
specifierCache[serializedAttributes] = result;
291+
PromisePrototypeThen(result, undefined, removeCache);
292+
return result;
293+
}
294+
232295
/**
233296
* This method is usually called indirectly as part of the loading processes.
234297
* Internally, it is used directly to add loaders. Use directly with caution.
235298
*
236299
* This method must NOT be renamed: it functions as a dynamic import on a
237300
* loader module.
238301
*
239-
* @param {string | string[]} specifiers Path(s) to the module.
302+
* @param {string} specifier The first parameter of an `import()` expression.
240303
* @param {string} parentURL Path of the parent importing the module.
241-
* @param {Record<string, string>} importAssertions Validations for the
304+
* @param {Record<string, string>} importAttributes Validations for the
242305
* module import.
243-
* @returns {Promise<ExportedHooks | KeyedExports[]>}
306+
* @returns {Promise<ExportedHooks>}
244307
* A collection of module export(s) or a list of collections of module
245308
* export(s).
246309
*/
247-
async import(specifiers, parentURL, importAssertions) {
248-
// For loaders, `import` is passed multiple things to process, it returns a
249-
// list pairing the url and exports collected. This is especially useful for
250-
// error messaging, to identity from where an export came. But, in most
251-
// cases, only a single url is being "imported" (ex `import()`), so there is
252-
// only 1 possible url from which the exports were collected and it is
253-
// already known to the caller. Nesting that in a list would only ever
254-
// create redundant work for the caller, so it is later popped off the
255-
// internal list.
256-
const wasArr = ArrayIsArray(specifiers);
257-
if (!wasArr) { specifiers = [specifiers]; }
258-
259-
const count = specifiers.length;
260-
const jobs = new Array(count);
261-
262-
for (let i = 0; i < count; i++) {
263-
jobs[i] = PromisePrototypeThen(
264-
this
265-
.getModuleJob(specifiers[i], parentURL, importAssertions)
266-
.run(),
267-
({ module }) => module.getNamespace(),
268-
);
269-
}
270-
271-
const namespaces = await SafePromiseAllReturnArrayLike(jobs);
310+
async #import(specifier, parentURL, importAttributes) {
272311

273-
if (!wasArr) { return namespaces[0]; } // We can skip the pairing below
274-
275-
for (let i = 0; i < count; i++) {
276-
namespaces[i] = {
277-
__proto__: null,
278-
url: specifiers[i],
279-
exports: namespaces[i],
280-
};
281-
}
312+
const moduleJob = this.getModuleJob(specifier, parentURL, importAttributes);
313+
const { module } = await moduleJob.run();
282314

283-
return namespaces;
315+
const { specifierCache, serializedAttributes } = this.#serializeCache(specifier, parentURL, importAttributes);
316+
specifierCache[serializedAttributes] = moduleJob;
317+
return module.getNamespace();
284318
}
285319

286320
/**
@@ -289,17 +323,17 @@ class DefaultModuleLoader {
289323
* @param {string} originalSpecifier The specified URL path of the module to
290324
* be resolved.
291325
* @param {string} [parentURL] The URL path of the module's parent.
292-
* @param {ImportAssertions} importAssertions Assertions from the import
326+
* @param {ImportAssertions} importAttributes Assertions from the import
293327
* statement or expression.
294328
* @returns {{ format: string, url: URL['href'] }}
295329
*/
296-
resolve(originalSpecifier, parentURL, importAssertions) {
330+
resolve(originalSpecifier, parentURL, importAttributes) {
297331
defaultResolve ??= require('internal/modules/esm/resolve').defaultResolve;
298332

299333
const context = {
300334
__proto__: null,
301335
conditions: this.#defaultConditions,
302-
importAssertions,
336+
importAttributes,
303337
parentURL,
304338
};
305339

@@ -357,21 +391,21 @@ class CustomizedModuleLoader extends DefaultModuleLoader {
357391
* @param {string} originalSpecifier The specified URL path of the module to
358392
* be resolved.
359393
* @param {string} [parentURL] The URL path of the module's parent.
360-
* @param {ImportAssertions} importAssertions Assertions from the import
394+
* @param {ImportAssertions} importAttributes Assertions from the import
361395
* statement or expression.
362396
* @returns {{ format: string, url: URL['href'] }}
363397
*/
364-
resolve(originalSpecifier, parentURL, importAssertions) {
365-
return hooksProxy.makeSyncRequest('resolve', originalSpecifier, parentURL, importAssertions);
398+
resolve(originalSpecifier, parentURL, importAttributes) {
399+
return hooksProxy.makeSyncRequest('resolve', originalSpecifier, parentURL, importAttributes);
366400
}
367401

368-
async #getModuleJob(specifier, parentURL, importAssertions) {
369-
const resolveResult = await hooksProxy.makeAsyncRequest('resolve', specifier, parentURL, importAssertions);
402+
async #getModuleJob(specifier, parentURL, importAttributes) {
403+
const resolveResult = await hooksProxy.makeAsyncRequest('resolve', specifier, parentURL, importAttributes);
370404

371-
return this.getJobFromResolveResult(resolveResult, parentURL, importAssertions);
405+
return this.getJobFromResolveResult(resolveResult, parentURL, importAttributes);
372406
}
373-
getModuleJob(specifier, parentURL, importAssertions) {
374-
const jobPromise = this.#getModuleJob(specifier, parentURL, importAssertions);
407+
getModuleJob(specifier, parentURL, importAttributes) {
408+
const jobPromise = this.#getModuleJob(specifier, parentURL, importAttributes);
375409

376410
return {
377411
run() {

lib/internal/modules/esm/module_job.js

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ const isCommonJSGlobalLikeNotDefinedError = (errorMessage) =>
5050
class ModuleJob {
5151
// `loader` is the Loader instance used for loading dependencies.
5252
// `moduleProvider` is a function
53-
constructor(loader, url, importAssertions = { __proto__: null },
53+
constructor(loader, url, importAttributes = { __proto__: null },
5454
moduleProvider, isMain, inspectBrk) {
5555
this.loader = loader;
56-
this.importAssertions = importAssertions;
56+
this.importAttributes = importAttributes;
5757
this.isMain = isMain;
5858
this.inspectBrk = inspectBrk;
5959

@@ -72,10 +72,12 @@ class ModuleJob {
7272
// so that circular dependencies can't cause a deadlock by two of
7373
// these `link` callbacks depending on each other.
7474
const dependencyJobs = [];
75-
const promises = this.module.link((specifier, assertions) => {
76-
const job = this.loader.getModuleJob(specifier, url, assertions);
75+
const promises = this.module.link(async (specifier, attributes) => {
76+
const job = this.loader.getModuleJob(specifier, url, attributes);
7777
ArrayPrototypePush(dependencyJobs, job);
78-
return job.modulePromise;
78+
const result = await job.modulePromise;
79+
this.loader.cacheStatic(specifier, url, attributes, job);
80+
return result;
7981
});
8082

8183
if (promises !== undefined)
@@ -95,10 +97,7 @@ class ModuleJob {
9597
}
9698

9799
instantiate() {
98-
if (this.instantiated === undefined) {
99-
this.instantiated = this._instantiate();
100-
}
101-
return this.instantiated;
100+
return this.instantiated ??= this._instantiate();
102101
}
103102

104103
async _instantiate() {

lib/internal/modules/esm/utils.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,12 @@ async function initializeHooks() {
117117
const { DefaultModuleLoader } = require('internal/modules/esm/loader');
118118
class ModuleLoader extends DefaultModuleLoader {
119119
loaderType = 'internal';
120-
async #getModuleJob(specifier, parentURL, importAssertions) {
121-
const resolveResult = await hooks.resolve(specifier, parentURL, importAssertions);
122-
return this.getJobFromResolveResult(resolveResult, parentURL, importAssertions);
120+
async #getModuleJob(specifier, parentURL, importAttributes) {
121+
const resolveResult = await hooks.resolve(specifier, parentURL, importAttributes);
122+
return this.getJobFromResolveResult(resolveResult, parentURL, importAttributes);
123123
}
124-
getModuleJob(specifier, parentURL, importAssertions) {
125-
const jobPromise = this.#getModuleJob(specifier, parentURL, importAssertions);
124+
getModuleJob(specifier, parentURL, importAttributes) {
125+
const jobPromise = this.#getModuleJob(specifier, parentURL, importAttributes);
126126
return {
127127
run() {
128128
return PromisePrototypeThen(jobPromise, (job) => job.run());
@@ -146,7 +146,7 @@ async function initializeHooks() {
146146

147147
// Importation must be handled by internal loader to avoid polluting user-land
148148
const keyedExportsSublist = await privateModuleLoader.import(
149-
[customLoaderPath], // Import can handle multiple paths, but custom loaders must be sequential
149+
customLoaderPath,
150150
parentURL,
151151
kEmptyObject,
152152
);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use strict';
2+
const common = require('../common');
3+
const tmpdir = require('../common/tmpdir');
4+
5+
const assert = require('node:assert');
6+
const fs = require('node:fs/promises');
7+
const { pathToFileURL } = require('node:url');
8+
9+
tmpdir.refresh();
10+
const tmpDir = pathToFileURL(tmpdir.path);
11+
12+
const target = new URL('./target.mjs', tmpDir);
13+
14+
(async () => {
15+
16+
await assert.rejects(import(target), { code: 'ERR_MODULE_NOT_FOUND' });
17+
18+
await fs.writeFile(target, 'export default "actual target"\n');
19+
20+
const moduleRecord = await import(target);
21+
22+
await fs.rm(target);
23+
24+
assert.strictEqual(await import(target), moduleRecord);
25+
})().then(common.mustCall());

0 commit comments

Comments
 (0)