Skip to content

Commit 14e148e

Browse files
aduh95targos
authored andcommitted
module: ensure successful import returns the same result
PR-URL: #46662 Backport-PR-URL: #50669 Reviewed-By: Michaël Zasso <[email protected]> Reviewed-By: Geoffrey Booth <[email protected]>
1 parent 03c1b5e commit 14e148e

7 files changed

+255
-27
lines changed

benchmark/esm/esm-loader-import.js

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Tests the impact on eager operations required for policies affecting
2+
// general startup, does not test lazy operations
3+
'use strict';
4+
const fs = require('node:fs');
5+
const path = require('node:path');
6+
const common = require('../common.js');
7+
8+
const tmpdir = require('../../test/common/tmpdir.js');
9+
const { pathToFileURL } = require('node:url');
10+
11+
const benchmarkDirectory = pathToFileURL(path.resolve(tmpdir.path, 'benchmark-import'));
12+
13+
const configs = {
14+
n: [1e3],
15+
specifier: [
16+
'data:text/javascript,{i}',
17+
'./relative-existing.js',
18+
'./relative-nonexistent.js',
19+
'node:prefixed-nonexistent',
20+
'node:os',
21+
],
22+
};
23+
24+
const options = {
25+
flags: ['--expose-internals'],
26+
};
27+
28+
const bench = common.createBenchmark(main, configs, options);
29+
30+
async function main(conf) {
31+
tmpdir.refresh();
32+
33+
fs.mkdirSync(benchmarkDirectory, { recursive: true });
34+
fs.writeFileSync(new URL('./relative-existing.js', benchmarkDirectory), '\n');
35+
36+
bench.start();
37+
38+
for (let i = 0; i < conf.n; i++) {
39+
try {
40+
await import(new URL(conf.specifier.replace('{i}', i), benchmarkDirectory));
41+
} catch { /* empty */ }
42+
}
43+
44+
bench.end(conf.n);
45+
}

lib/internal/modules/esm/loader.js

+27-10
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@ const {
2020
} = require('internal/modules/esm/utils');
2121
let defaultResolve, defaultLoad, importMetaInitializer;
2222

23-
function newModuleMap() {
24-
const ModuleMap = require('internal/modules/esm/module_map');
25-
return new ModuleMap();
23+
function newResolveCache() {
24+
const { ResolveCache } = require('internal/modules/esm/module_map');
25+
return new ResolveCache();
26+
}
27+
28+
function newLoadCache() {
29+
const { LoadCache } = require('internal/modules/esm/module_map');
30+
return new LoadCache();
2631
}
2732

2833
function getTranslators() {
@@ -73,10 +78,15 @@ class ModuleLoader {
7378
*/
7479
evalIndex = 0;
7580

81+
/**
82+
* Registry of resolved specifiers
83+
*/
84+
#resolveCache = newResolveCache();
85+
7686
/**
7787
* Registry of loaded modules, akin to `require.cache`
7888
*/
79-
moduleMap = newModuleMap();
89+
loadCache = newLoadCache();
8090

8191
/**
8292
* Methods which translate input code or other information into ES modules
@@ -195,7 +205,7 @@ class ModuleLoader {
195205
const ModuleJob = require('internal/modules/esm/module_job');
196206
const job = new ModuleJob(
197207
this, url, undefined, evalInstance, false, false);
198-
this.moduleMap.set(url, undefined, job);
208+
this.loadCache.set(url, undefined, job);
199209
const { module } = await job.run();
200210

201211
return {
@@ -224,11 +234,11 @@ class ModuleLoader {
224234
getJobFromResolveResult(resolveResult, parentURL, importAssertions) {
225235
const { url, format } = resolveResult;
226236
const resolvedImportAssertions = resolveResult.importAssertions ?? importAssertions;
227-
let job = this.moduleMap.get(url, resolvedImportAssertions.type);
237+
let job = this.loadCache.get(url, resolvedImportAssertions.type);
228238

229239
// CommonJS will set functions for lazy job evaluation.
230240
if (typeof job === 'function') {
231-
this.moduleMap.set(url, undefined, job = job());
241+
this.loadCache.set(url, undefined, job = job());
232242
}
233243

234244
if (job === undefined) {
@@ -288,7 +298,7 @@ class ModuleLoader {
288298
inspectBrk,
289299
);
290300

291-
this.moduleMap.set(url, importAssertions.type, job);
301+
this.loadCache.set(url, importAssertions.type, job);
292302

293303
return job;
294304
}
@@ -326,13 +336,20 @@ class ModuleLoader {
326336
* @param {string} [parentURL] The URL path of the module's parent.
327337
* @param {ImportAssertions} importAssertions Assertions from the import
328338
* statement or expression.
329-
* @returns {Promise<{ format: string, url: URL['href'] }>}
339+
* @returns {{ format: string, url: URL['href'] }}
330340
*/
331341
resolve(originalSpecifier, parentURL, importAssertions) {
332342
if (this.#customizations) {
333343
return this.#customizations.resolve(originalSpecifier, parentURL, importAssertions);
334344
}
335-
return this.defaultResolve(originalSpecifier, parentURL, importAssertions);
345+
const requestKey = this.#resolveCache.serializeKey(originalSpecifier, importAssertions);
346+
const cachedResult = this.#resolveCache.get(requestKey, parentURL);
347+
if (cachedResult != null) {
348+
return cachedResult;
349+
}
350+
const result = this.defaultResolve(originalSpecifier, parentURL, importAssertions);
351+
this.#resolveCache.set(requestKey, parentURL, result);
352+
return result;
336353
}
337354

338355
/**

lib/internal/modules/esm/module_job.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const {
2222

2323
const { ModuleWrap } = internalBinding('module_wrap');
2424

25-
const { decorateErrorStack } = require('internal/util');
25+
const { decorateErrorStack, kEmptyObject } = require('internal/util');
2626
const {
2727
getSourceMapsEnabled,
2828
} = require('internal/source_map/source_map_cache');
@@ -141,7 +141,9 @@ class ModuleJob {
141141
/module '(.*)' does not provide an export named '(.+)'/,
142142
e.message);
143143
const { url: childFileURL } = await this.loader.resolve(
144-
childSpecifier, parentFileUrl,
144+
childSpecifier,
145+
parentFileUrl,
146+
kEmptyObject,
145147
);
146148
let format;
147149
try {

lib/internal/modules/esm/module_map.js

+84-5
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,93 @@
11
'use strict';
22

3-
const { kImplicitAssertType } = require('internal/modules/esm/assert');
43
const {
4+
ArrayPrototypeJoin,
5+
ArrayPrototypeMap,
6+
ArrayPrototypeSort,
7+
JSONStringify,
58
ObjectCreate,
9+
ObjectKeys,
610
SafeMap,
711
} = primordials;
12+
const { kImplicitAssertType } = require('internal/modules/esm/assert');
813
let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
914
debug = fn;
1015
});
1116
const { ERR_INVALID_ARG_TYPE } = require('internal/errors').codes;
1217
const { validateString } = require('internal/validators');
1318

14-
// Tracks the state of the loader-level module cache
15-
class ModuleMap extends SafeMap {
19+
/**
20+
* Cache the results of the `resolve` step of the module resolution and loading process.
21+
* Future resolutions of the same input (specifier, parent URL and import assertions)
22+
* must return the same result if the first attempt was successful, per
23+
* https://tc39.es/ecma262/#sec-HostLoadImportedModule.
24+
* This cache is *not* used when custom loaders are registered.
25+
*/
26+
class ResolveCache extends SafeMap {
27+
constructor(i) { super(i); } // eslint-disable-line no-useless-constructor
28+
29+
/**
30+
* Generates the internal serialized cache key and returns it along the actual cache object.
31+
*
32+
* It is exposed to allow more efficient read and overwrite a cache entry.
33+
* @param {string} specifier
34+
* @param {Record<string,string>} importAssertions
35+
* @returns {string}
36+
*/
37+
serializeKey(specifier, importAssertions) {
38+
// To serialize the ModuleRequest (specifier + list of import assertions),
39+
// we need to sort the assertions by key, then stringifying,
40+
// so that different import statements with the same assertions are always treated
41+
// as identical.
42+
const keys = ObjectKeys(importAssertions);
43+
44+
if (keys.length === 0) {
45+
return specifier + '::';
46+
}
47+
48+
return specifier + '::' + ArrayPrototypeJoin(
49+
ArrayPrototypeMap(
50+
ArrayPrototypeSort(keys),
51+
(key) => JSONStringify(key) + JSONStringify(importAssertions[key])),
52+
',');
53+
}
54+
55+
#getModuleCachedImports(parentURL) {
56+
let internalCache = super.get(parentURL);
57+
if (internalCache == null) {
58+
super.set(parentURL, internalCache = { __proto__: null });
59+
}
60+
return internalCache;
61+
}
62+
63+
/**
64+
* @param {string} serializedKey
65+
* @param {string} parentURL
66+
* @returns {import('./loader').ModuleExports | Promise<import('./loader').ModuleExports>}
67+
*/
68+
get(serializedKey, parentURL) {
69+
return this.#getModuleCachedImports(parentURL)[serializedKey];
70+
}
71+
72+
/**
73+
* @param {string} serializedKey
74+
* @param {string} parentURL
75+
* @param {{ format: string, url: URL['href'] }} result
76+
*/
77+
set(serializedKey, parentURL, result) {
78+
this.#getModuleCachedImports(parentURL)[serializedKey] = result;
79+
return this;
80+
}
81+
82+
has(serializedKey, parentURL) {
83+
return serializedKey in this.#getModuleCachedImports(parentURL);
84+
}
85+
}
86+
87+
/**
88+
* Cache the results of the `load` step of the module resolution and loading process.
89+
*/
90+
class LoadCache extends SafeMap {
1691
constructor(i) { super(i); } // eslint-disable-line no-useless-constructor
1792
get(url, type = kImplicitAssertType) {
1893
validateString(url, 'url');
@@ -30,7 +105,7 @@ class ModuleMap extends SafeMap {
30105
}
31106
debug(`Storing ${url} (${
32107
type === kImplicitAssertType ? 'implicit type' : type
33-
}) in ModuleMap`);
108+
}) in ModuleLoadMap`);
34109
const cachedJobsForUrl = super.get(url) ?? ObjectCreate(null);
35110
cachedJobsForUrl[type] = job;
36111
return super.set(url, cachedJobsForUrl);
@@ -41,4 +116,8 @@ class ModuleMap extends SafeMap {
41116
return super.get(url)?.[type] !== undefined;
42117
}
43118
}
44-
module.exports = ModuleMap;
119+
120+
module.exports = {
121+
LoadCache,
122+
ResolveCache,
123+
};
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(`./${Math.random()}.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());
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { spawnPromisified } from '../common/index.mjs';
2+
import tmpdir from '../common/tmpdir.js';
3+
4+
import assert from 'node:assert';
5+
import fs from 'node:fs/promises';
6+
import { execPath } from 'node:process';
7+
import { pathToFileURL } from 'node:url';
8+
9+
tmpdir.refresh();
10+
const tmpDir = pathToFileURL(tmpdir.path);
11+
12+
const target = new URL(`./${Math.random()}.mjs`, tmpDir);
13+
14+
await assert.rejects(import(target), { code: 'ERR_MODULE_NOT_FOUND' });
15+
16+
await fs.writeFile(target, 'export default "actual target"\n');
17+
18+
const moduleRecord = await import(target);
19+
20+
await fs.rm(target);
21+
22+
assert.strictEqual(await import(target), moduleRecord);
23+
24+
// Add the file back, it should be deleted by the subprocess.
25+
await fs.writeFile(target, 'export default "actual target"\n');
26+
27+
assert.deepStrictEqual(
28+
await spawnPromisified(execPath, [
29+
'--input-type=module',
30+
'--eval',
31+
[`import * as d from${JSON.stringify(target)};`,
32+
'import{rm}from"node:fs/promises";',
33+
`await rm(new URL(${JSON.stringify(target)}));`,
34+
'import{strictEqual}from"node:assert";',
35+
`strictEqual(JSON.stringify(await import(${JSON.stringify(target)})),JSON.stringify(d));`].join(''),
36+
]),
37+
{
38+
code: 0,
39+
signal: null,
40+
stderr: '',
41+
stdout: '',
42+
});

0 commit comments

Comments
 (0)