Skip to content

Commit 45de8d1

Browse files
GeoffreyBoothtargos
authored andcommitted
esm: allow resolve to return import assertions
PR-URL: #46153 Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com> Reviewed-By: Michaël Zasso <targos@protonmail.com> Reviewed-By: Jacob Smith <jacob@frende.me>
1 parent 5ffc90a commit 45de8d1

File tree

4 files changed

+71
-43
lines changed

4 files changed

+71
-43
lines changed

doc/api/esm.md

+16-14
Original file line numberDiff line numberDiff line change
@@ -751,7 +751,8 @@ changes:
751751
* `specifier` {string}
752752
* `context` {Object}
753753
* `conditions` {string\[]} Export conditions of the relevant `package.json`
754-
* `importAssertions` {Object}
754+
* `importAssertions` {Object} An object whose key-value pairs represent the
755+
assertions for the module to import
755756
* `parentURL` {string|undefined} The module importing this one, or undefined
756757
if this is the Node.js entry point
757758
* `nextResolve` {Function} The subsequent `resolve` hook in the chain, or the
@@ -762,23 +763,24 @@ changes:
762763
* `format` {string|null|undefined} A hint to the load hook (it might be
763764
ignored)
764765
`'builtin' | 'commonjs' | 'json' | 'module' | 'wasm'`
766+
* `importAssertions` {Object|undefined} The import assertions to use when
767+
caching the module (optional; if excluded the input will be used)
765768
* `shortCircuit` {undefined|boolean} A signal that this hook intends to
766769
terminate the chain of `resolve` hooks. **Default:** `false`
767770
* `url` {string} The absolute URL to which this input resolves
768771
769-
The `resolve` hook chain is responsible for resolving file URL for a given
770-
module specifier and parent URL, and optionally its format (such as `'module'`)
771-
as a hint to the `load` hook. If a format is specified, the `load` hook is
772-
ultimately responsible for providing the final `format` value (and it is free to
773-
ignore the hint provided by `resolve`); if `resolve` provides a `format`, a
774-
custom `load` hook is required even if only to pass the value to the Node.js
775-
default `load` hook.
776-
777-
The module specifier is the string in an `import` statement or
778-
`import()` expression.
779-
780-
The parent URL is the URL of the module that imported this one, or `undefined`
781-
if this is the main entry point for the application.
772+
The `resolve` hook chain is responsible for telling Node.js where to find and
773+
how to cache a given `import` statement or expression. It can optionally return
774+
its format (such as `'module'`) as a hint to the `load` hook. If a format is
775+
specified, the `load` hook is ultimately responsible for providing the final
776+
`format` value (and it is free to ignore the hint provided by `resolve`); if
777+
`resolve` provides a `format`, a custom `load` hook is required even if only to
778+
pass the value to the Node.js default `load` hook.
779+
780+
Import type assertions are part of the cache key for saving loaded modules into
781+
the internal module cache. The `resolve` hook is responsible for
782+
returning an `importAssertions` object if the module should be cached with
783+
different assertions than were present in the source code.
782784
783785
The `conditions` property in `context` is an array of conditions for
784786
[package exports conditions][Conditional Exports] that apply to this resolution

lib/internal/modules/esm/hooks.js

+29-14
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ class Hooks {
8484
};
8585

8686
// Enable an optimization in ESMLoader.getModuleJob
87-
hasCustomLoadHooks = false;
87+
hasCustomResolveOrLoadHooks = false;
8888

8989
// Cache URLs we've already validated to avoid repeated validation
9090
#validatedUrls = new SafeSet();
@@ -125,6 +125,7 @@ class Hooks {
125125
);
126126
}
127127
if (resolve) {
128+
this.hasCustomResolveOrLoadHooks = true;
128129
ArrayPrototypePush(
129130
this.#hooks.resolve,
130131
{
@@ -134,7 +135,7 @@ class Hooks {
134135
);
135136
}
136137
if (load) {
137-
this.hasCustomLoadHooks = true;
138+
this.hasCustomResolveOrLoadHooks = true;
138139
ArrayPrototypePush(
139140
this.#hooks.load,
140141
{
@@ -317,21 +318,10 @@ class Hooks {
317318

318319
const {
319320
format,
321+
importAssertions: resolvedImportAssertions,
320322
url,
321323
} = resolution;
322324

323-
if (
324-
format != null &&
325-
typeof format !== 'string' // [2]
326-
) {
327-
throw new ERR_INVALID_RETURN_PROPERTY_VALUE(
328-
'a string',
329-
hookErrIdentifier,
330-
'format',
331-
format,
332-
);
333-
}
334-
335325
if (typeof url !== 'string') {
336326
// non-strings can be coerced to a URL string
337327
// validateString() throws a less-specific error
@@ -358,9 +348,34 @@ class Hooks {
358348
}
359349
}
360350

351+
if (
352+
resolvedImportAssertions != null &&
353+
typeof resolvedImportAssertions !== 'object'
354+
) {
355+
throw new ERR_INVALID_RETURN_PROPERTY_VALUE(
356+
'an object',
357+
hookErrIdentifier,
358+
'importAssertions',
359+
resolvedImportAssertions,
360+
);
361+
}
362+
363+
if (
364+
format != null &&
365+
typeof format !== 'string' // [2]
366+
) {
367+
throw new ERR_INVALID_RETURN_PROPERTY_VALUE(
368+
'a string',
369+
hookErrIdentifier,
370+
'format',
371+
format,
372+
);
373+
}
374+
361375
return {
362376
__proto__: null,
363377
format,
378+
importAssertions: resolvedImportAssertions,
364379
url,
365380
};
366381
}

lib/internal/modules/esm/loader.js

+9-5
Original file line numberDiff line numberDiff line change
@@ -171,25 +171,28 @@ class ESMLoader {
171171

172172
// We can skip cloning if there are no user-provided loaders because
173173
// the Node.js default resolve hook does not use import assertions.
174-
if (this.#hooks?.hasCustomLoadHooks) {
174+
if (this.#hooks?.hasCustomResolveOrLoadHooks) {
175+
// This method of cloning only works so long as import assertions cannot contain objects as values,
176+
// which they currently cannot per spec.
175177
importAssertionsForResolve = {
176178
__proto__: null,
177179
...importAssertions,
178180
};
179181
}
180182

181-
const { format, url } =
182-
await this.resolve(specifier, parentURL, importAssertionsForResolve);
183+
const resolveResult = await this.resolve(specifier, parentURL, importAssertionsForResolve);
184+
const { url, format } = resolveResult;
185+
const resolvedImportAssertions = resolveResult.importAssertions ?? importAssertions;
183186

184-
let job = this.moduleMap.get(url, importAssertions.type);
187+
let job = this.moduleMap.get(url, resolvedImportAssertions.type);
185188

186189
// CommonJS will set functions for lazy job evaluation.
187190
if (typeof job === 'function') {
188191
this.moduleMap.set(url, undefined, job = job());
189192
}
190193

191194
if (job === undefined) {
192-
job = this.#createModuleJob(url, importAssertions, parentURL, format);
195+
job = this.#createModuleJob(url, resolvedImportAssertions, parentURL, format);
193196
}
194197

195198
return job;
@@ -234,6 +237,7 @@ class ESMLoader {
234237
if (process.env.WATCH_REPORT_DEPENDENCIES && process.send) {
235238
process.send({ 'watch:import': [url] });
236239
}
240+
237241
const ModuleJob = require('internal/modules/esm/module_job');
238242
const job = new ModuleJob(
239243
this,
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
const DATA_URL_PATTERN = /^data:application\/json(?:[^,]*?)(;base64)?,([\s\S]*)$/;
2-
const JSON_URL_PATTERN = /\.json(\?[^#]*)?(#.*)?$/;
2+
const JSON_URL_PATTERN = /^[^?]+\.json(\?[^#]*)?(#.*)?$/;
3+
4+
export async function resolve(specifier, context, next) {
5+
const noAssertionSpecified = context.importAssertions.type == null;
36

4-
export function resolve(url, context, next) {
57
// Mutation from resolve hook should be discarded.
68
context.importAssertions.type = 'whatever';
7-
return next(url);
8-
}
99

10-
export function load(url, context, next) {
11-
if (context.importAssertions.type == null &&
12-
(DATA_URL_PATTERN.test(url) || JSON_URL_PATTERN.test(url))) {
13-
const { importAssertions } = context;
14-
importAssertions.type = 'json';
10+
// This fixture assumes that no other resolve hooks in the chain will error on invalid import assertions
11+
// (as defaultResolve doesn't).
12+
const result = await next(specifier, context);
13+
14+
if (noAssertionSpecified &&
15+
(DATA_URL_PATTERN.test(result.url) || JSON_URL_PATTERN.test(result.url))) {
16+
// Clean new import assertions object to ensure that this test isn't passing due to mutation.
17+
result.importAssertions = {
18+
...(result.importAssertions ?? context.importAssertions),
19+
type: 'json',
20+
};
1521
}
16-
return next(url);
22+
23+
return result;
1724
}

0 commit comments

Comments
 (0)