Skip to content

Commit a5322c4

Browse files
aduh95RafaelGSS
authored andcommittedAug 17, 2023
module: ensure successful import returns the same result
PR-URL: #46662 Reviewed-By: Michaël Zasso <[email protected]> Reviewed-By: Geoffrey Booth <[email protected]>
1 parent 62a46d9 commit a5322c4

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() {
@@ -71,10 +76,15 @@ class ModuleLoader {
7176
*/
7277
evalIndex = 0;
7378

79+
/**
80+
* Registry of resolved specifiers
81+
*/
82+
#resolveCache = newResolveCache();
83+
7484
/**
7585
* Registry of loaded modules, akin to `require.cache`
7686
*/
77-
moduleMap = newModuleMap();
87+
loadCache = newLoadCache();
7888

7989
/**
8090
* Methods which translate input code or other information into ES modules
@@ -183,7 +193,7 @@ class ModuleLoader {
183193
const ModuleJob = require('internal/modules/esm/module_job');
184194
const job = new ModuleJob(
185195
this, url, undefined, evalInstance, false, false);
186-
this.moduleMap.set(url, undefined, job);
196+
this.loadCache.set(url, undefined, job);
187197
const { module } = await job.run();
188198

189199
return {
@@ -213,11 +223,11 @@ class ModuleLoader {
213223
getJobFromResolveResult(resolveResult, parentURL, importAssertions) {
214224
const { url, format } = resolveResult;
215225
const resolvedImportAssertions = resolveResult.importAssertions ?? importAssertions;
216-
let job = this.moduleMap.get(url, resolvedImportAssertions.type);
226+
let job = this.loadCache.get(url, resolvedImportAssertions.type);
217227

218228
// CommonJS will set functions for lazy job evaluation.
219229
if (typeof job === 'function') {
220-
this.moduleMap.set(url, undefined, job = job());
230+
this.loadCache.set(url, undefined, job = job());
221231
}
222232

223233
if (job === undefined) {
@@ -277,7 +287,7 @@ class ModuleLoader {
277287
inspectBrk,
278288
);
279289

280-
this.moduleMap.set(url, importAssertions.type, job);
290+
this.loadCache.set(url, importAssertions.type, job);
281291

282292
return job;
283293
}
@@ -315,13 +325,20 @@ class ModuleLoader {
315325
* @param {string} [parentURL] The URL path of the module's parent.
316326
* @param {ImportAssertions} importAssertions Assertions from the import
317327
* statement or expression.
318-
* @returns {Promise<{ format: string, url: URL['href'] }>}
328+
* @returns {{ format: string, url: URL['href'] }}
319329
*/
320330
resolve(originalSpecifier, parentURL, importAssertions) {
321331
if (this.#customizations) {
322332
return this.#customizations.resolve(originalSpecifier, parentURL, importAssertions);
323333
}
324-
return this.defaultResolve(originalSpecifier, parentURL, importAssertions);
334+
const requestKey = this.#resolveCache.serializeKey(originalSpecifier, importAssertions);
335+
const cachedResult = this.#resolveCache.get(requestKey, parentURL);
336+
if (cachedResult != null) {
337+
return cachedResult;
338+
}
339+
const result = this.defaultResolve(originalSpecifier, parentURL, importAssertions);
340+
this.#resolveCache.set(requestKey, parentURL, result);
341+
return result;
325342
}
326343

327344
/**

‎lib/internal/modules/esm/module_job.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const {
2121

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

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

‎lib/internal/modules/esm/module_map.js

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

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

13-
// Tracks the state of the loader-level module cache
14-
class ModuleMap extends SafeMap {
18+
/**
19+
* Cache the results of the `resolve` step of the module resolution and loading process.
20+
* Future resolutions of the same input (specifier, parent URL and import assertions)
21+
* must return the same result if the first attempt was successful, per
22+
* https://tc39.es/ecma262/#sec-HostLoadImportedModule.
23+
* This cache is *not* used when custom loaders are registered.
24+
*/
25+
class ResolveCache extends SafeMap {
26+
constructor(i) { super(i); } // eslint-disable-line no-useless-constructor
27+
28+
/**
29+
* Generates the internal serialized cache key and returns it along the actual cache object.
30+
*
31+
* It is exposed to allow more efficient read and overwrite a cache entry.
32+
* @param {string} specifier
33+
* @param {Record<string,string>} importAssertions
34+
* @returns {string}
35+
*/
36+
serializeKey(specifier, importAssertions) {
37+
// To serialize the ModuleRequest (specifier + list of import assertions),
38+
// we need to sort the assertions by key, then stringifying,
39+
// so that different import statements with the same assertions are always treated
40+
// as identical.
41+
const keys = ObjectKeys(importAssertions);
42+
43+
if (keys.length === 0) {
44+
return specifier + '::';
45+
}
46+
47+
return specifier + '::' + ArrayPrototypeJoin(
48+
ArrayPrototypeMap(
49+
ArrayPrototypeSort(keys),
50+
(key) => JSONStringify(key) + JSONStringify(importAssertions[key])),
51+
',');
52+
}
53+
54+
#getModuleCachedImports(parentURL) {
55+
let internalCache = super.get(parentURL);
56+
if (internalCache == null) {
57+
super.set(parentURL, internalCache = { __proto__: null });
58+
}
59+
return internalCache;
60+
}
61+
62+
/**
63+
* @param {string} serializedKey
64+
* @param {string} parentURL
65+
* @returns {import('./loader').ModuleExports | Promise<import('./loader').ModuleExports>}
66+
*/
67+
get(serializedKey, parentURL) {
68+
return this.#getModuleCachedImports(parentURL)[serializedKey];
69+
}
70+
71+
/**
72+
* @param {string} serializedKey
73+
* @param {string} parentURL
74+
* @param {{ format: string, url: URL['href'] }} result
75+
*/
76+
set(serializedKey, parentURL, result) {
77+
this.#getModuleCachedImports(parentURL)[serializedKey] = result;
78+
return this;
79+
}
80+
81+
has(serializedKey, parentURL) {
82+
return serializedKey in this.#getModuleCachedImports(parentURL);
83+
}
84+
}
85+
86+
/**
87+
* Cache the results of the `load` step of the module resolution and loading process.
88+
*/
89+
class LoadCache extends SafeMap {
1590
constructor(i) { super(i); } // eslint-disable-line no-useless-constructor
1691
get(url, type = kImplicitAssertType) {
1792
validateString(url, 'url');
@@ -29,7 +104,7 @@ class ModuleMap extends SafeMap {
29104
}
30105
debug(`Storing ${url} (${
31106
type === kImplicitAssertType ? 'implicit type' : type
32-
}) in ModuleMap`);
107+
}) in ModuleLoadMap`);
33108
const cachedJobsForUrl = super.get(url) ?? { __proto__: null };
34109
cachedJobsForUrl[type] = job;
35110
return super.set(url, cachedJobsForUrl);
@@ -40,4 +115,8 @@ class ModuleMap extends SafeMap {
40115
return super.get(url)?.[type] !== undefined;
41116
}
42117
}
43-
module.exports = ModuleMap;
118+
119+
module.exports = {
120+
LoadCache,
121+
ResolveCache,
122+
};
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)