Skip to content

Commit 52d276e

Browse files
committed
module: change default resolver to not throw on unknown scheme
Fixes nodejs/loaders#138
1 parent ab434d2 commit 52d276e

File tree

9 files changed

+137
-99
lines changed

9 files changed

+137
-99
lines changed

doc/api/esm.md

Lines changed: 47 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,28 +1029,6 @@ and there is no security.
10291029
// https-loader.mjs
10301030
import { get } from 'node:https';
10311031
1032-
export function resolve(specifier, context, nextResolve) {
1033-
const { parentURL = null } = context;
1034-
1035-
// Normally Node.js would error on specifiers starting with 'https://', so
1036-
// this hook intercepts them and converts them into absolute URLs to be
1037-
// passed along to the later hooks below.
1038-
if (specifier.startsWith('https://')) {
1039-
return {
1040-
shortCircuit: true,
1041-
url: specifier,
1042-
};
1043-
} else if (parentURL && parentURL.startsWith('https://')) {
1044-
return {
1045-
shortCircuit: true,
1046-
url: new URL(specifier, parentURL).href,
1047-
};
1048-
}
1049-
1050-
// Let Node.js handle all other specifiers.
1051-
return nextResolve(specifier);
1052-
}
1053-
10541032
export function load(url, context, nextLoad) {
10551033
// For JavaScript to be loaded over the network, we need to fetch and
10561034
// return it.
@@ -1091,9 +1069,7 @@ prints the current version of CoffeeScript per the module at the URL in
10911069
#### Transpiler loader
10921070

10931071
Sources that are in formats Node.js doesn't understand can be converted into
1094-
JavaScript using the [`load` hook][load hook]. Before that hook gets called,
1095-
however, a [`resolve` hook][resolve hook] needs to tell Node.js not to
1096-
throw an error on unknown file types.
1072+
JavaScript using the [`load` hook][load hook].
10971073
10981074
This is less performant than transpiling source files before running
10991075
Node.js; a transpiler loader should only be used for development and testing
@@ -1109,25 +1085,6 @@ import CoffeeScript from 'coffeescript';
11091085
11101086
const baseURL = pathToFileURL(`${cwd()}/`).href;
11111087
1112-
// CoffeeScript files end in .coffee, .litcoffee, or .coffee.md.
1113-
const extensionsRegex = /\.coffee$|\.litcoffee$|\.coffee\.md$/;
1114-
1115-
export function resolve(specifier, context, nextResolve) {
1116-
if (extensionsRegex.test(specifier)) {
1117-
const { parentURL = baseURL } = context;
1118-
1119-
// Node.js normally errors on unknown file extensions, so return a URL for
1120-
// specifiers ending in the CoffeeScript file extensions.
1121-
return {
1122-
shortCircuit: true,
1123-
url: new URL(specifier, parentURL).href,
1124-
};
1125-
}
1126-
1127-
// Let Node.js handle all other specifiers.
1128-
return nextResolve(specifier);
1129-
}
1130-
11311088
export async function load(url, context, nextLoad) {
11321089
if (extensionsRegex.test(url)) {
11331090
// Now that we patched resolve to let CoffeeScript URLs through, we need to
@@ -1220,6 +1177,50 @@ loaded from disk but before Node.js executes it; and so on for any `.coffee`,
12201177
`.litcoffee` or `.coffee.md` files referenced via `import` statements of any
12211178
loaded file.
12221179
1180+
#### Overriding loader
1181+
1182+
The above two loaders hooked into the "load" phase of the module loader.
1183+
This loader hooks into the "resolution" phase. This loader reads an
1184+
`overrides.json` file that specifies which specifiers to override to another
1185+
url.
1186+
1187+
```js
1188+
// overriding-loader.js
1189+
import fs from 'node:fs/promises';
1190+
1191+
const overrides = JSON.parse(await fs.readFile('overrides.json'));
1192+
1193+
export async function resolve(specifier, context, nextResolve) {
1194+
if (specifier in overrides) {
1195+
return nextResolve(overrides[specifier], context);
1196+
}
1197+
1198+
return nextResolve(specifier, context);
1199+
}
1200+
```
1201+
1202+
Let's assume we have these files:
1203+
1204+
```js
1205+
// main.js
1206+
import 'a-module-to-override';
1207+
```
1208+
1209+
```json
1210+
// overrides.json
1211+
{
1212+
"a-module-to-override": "./module-override.js"
1213+
}
1214+
```
1215+
1216+
```js
1217+
// module-override.js
1218+
console.log('module overridden!');
1219+
```
1220+
1221+
If you run `node --experimental-loader ./overriding-loader.js main.js`
1222+
the output will be `module overriden!`.
1223+
12231224
## Resolution algorithm
12241225
12251226
### Features
@@ -1506,9 +1507,9 @@ _isImports_, _conditions_)
15061507
> 7. If _pjson?.type_ exists and is _"module"_, then
15071508
> 1. If _url_ ends in _".js"_, then
15081509
> 1. Return _"module"_.
1509-
> 2. Throw an _Unsupported File Extension_ error.
1510+
> 2. return **undefined**.
15101511
> 8. Otherwise,
1511-
> 1. Throw an _Unsupported File Extension_ error.
1512+
> 1. return **undefined**.
15121513
15131514
**LOOKUP\_PACKAGE\_SCOPE**(_url_)
15141515
@@ -1581,7 +1582,6 @@ for ESM specifiers is [commonjs-extension-resolution-loader][].
15811582
[custom https loader]: #https-loader
15821583
[load hook]: #loadurl-context-nextload
15831584
[percent-encoded]: url.md#percent-encoding-in-urls
1584-
[resolve hook]: #resolvespecifier-context-nextresolve
15851585
[special scheme]: https://url.spec.whatwg.org/#special-scheme
15861586
[status code]: process.md#exit-codes
15871587
[the official standard format]: https://tc39.github.io/ecma262/#sec-modules

lib/internal/modules/esm/load.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ async function defaultLoad(url, context = kEmptyObject) {
7979
source,
8080
} = context;
8181

82+
throwIfUnsupportedURLScheme(new URL(url), experimentalNetworkImports);
83+
8284
if (format == null) {
8385
format = await defaultGetFormat(url, context);
8486
}
@@ -102,6 +104,36 @@ async function defaultLoad(url, context = kEmptyObject) {
102104
};
103105
}
104106

107+
/**
108+
* throws an error if the protocol is not one of the protocols
109+
* that can be loaded in the default loader
110+
*
111+
* @param {URL} parsed
112+
* @param {boolean} experimentalNetworkImports
113+
*/
114+
function throwIfUnsupportedURLScheme(parsed, experimentalNetworkImports) {
115+
// Avoid accessing the `protocol` property due to the lazy getters.
116+
const protocol = parsed?.protocol;
117+
if (
118+
protocol &&
119+
protocol !== 'file:' &&
120+
protocol !== 'data:' &&
121+
protocol !== 'node:' &&
122+
(
123+
!experimentalNetworkImports ||
124+
(
125+
protocol !== 'https:' &&
126+
protocol !== 'http:'
127+
)
128+
)
129+
) {
130+
const schemes = ['file', 'data', 'node'];
131+
if (experimentalNetworkImports) {
132+
ArrayPrototypePush(schemes, 'https', 'http');
133+
}
134+
throw new ERR_UNSUPPORTED_ESM_URL_SCHEME(parsed, schemes);
135+
}
136+
}
105137

106138
/**
107139
* For a falsy `format` returned from `load`, throw an error.

lib/internal/modules/esm/resolve.js

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -941,37 +941,6 @@ function throwIfInvalidParentURL(parentURL) {
941941
}
942942
}
943943

944-
function throwIfUnsupportedURLProtocol(url) {
945-
// Avoid accessing the `protocol` property due to the lazy getters.
946-
const protocol = url.protocol;
947-
if (protocol !== 'file:' && protocol !== 'data:' &&
948-
protocol !== 'node:') {
949-
throw new ERR_UNSUPPORTED_ESM_URL_SCHEME(url);
950-
}
951-
}
952-
953-
function throwIfUnsupportedURLScheme(parsed, experimentalNetworkImports) {
954-
// Avoid accessing the `protocol` property due to the lazy getters.
955-
const protocol = parsed?.protocol;
956-
if (
957-
protocol &&
958-
protocol !== 'file:' &&
959-
protocol !== 'data:' &&
960-
(
961-
!experimentalNetworkImports ||
962-
(
963-
protocol !== 'https:' &&
964-
protocol !== 'http:'
965-
)
966-
)
967-
) {
968-
const schemes = ['file', 'data'];
969-
if (experimentalNetworkImports) {
970-
ArrayPrototypePush(schemes, 'https', 'http');
971-
}
972-
throw new ERR_UNSUPPORTED_ESM_URL_SCHEME(parsed, schemes);
973-
}
974-
}
975944

976945
function defaultResolve(specifier, context = {}) {
977946
let { parentURL, conditions } = context;
@@ -1048,7 +1017,6 @@ function defaultResolve(specifier, context = {}) {
10481017
// This must come after checkIfDisallowedImport
10491018
if (parsed && parsed.protocol === 'node:') return { __proto__: null, url: specifier };
10501019

1051-
throwIfUnsupportedURLScheme(parsed, experimentalNetworkImports);
10521020

10531021
const isMain = parentURL === undefined;
10541022
if (isMain) {
@@ -1095,8 +1063,6 @@ function defaultResolve(specifier, context = {}) {
10951063
throw error;
10961064
}
10971065

1098-
throwIfUnsupportedURLProtocol(url);
1099-
11001066
return {
11011067
__proto__: null,
11021068
// Do NOT cast `url` to a string: that will work even when there are real

test/es-module/test-esm-import-meta-resolve.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ assert.strictEqual(
3030
code: 'ERR_INVALID_ARG_TYPE',
3131
})
3232
);
33+
assert.equal(import.meta.resolve('http://some-absolute/url'), 'http://some-absolute/url')
34+
assert.equal(import.meta.resolve('some://weird/protocol'), 'some://weird/protocol')
3335
assert.strictEqual(import.meta.resolve('baz/', fixtures),
3436
fixtures + 'node_modules/baz/');
3537

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { spawnPromisified } from '../common/index.mjs';
2+
import * as fixtures from '../common/fixtures.mjs';
3+
import assert from 'node:assert';
4+
import { execPath } from 'node:process';
5+
import {describe, it} from 'node:test'
6+
7+
describe('default resolver', () => {
8+
it('should accept foreign schemas without exception (e.g. uyyt://something/or-other', async () => {
9+
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
10+
'--no-warnings',
11+
'--experimental-loader',
12+
fixtures.fileURL('/es-module-loaders/uyyt-dummy-loader.mjs'),
13+
fixtures.path('/es-module-loaders/uyyt-dummy-loader-main.mjs'),
14+
]);
15+
assert.strictEqual(code, 0);
16+
assert.strictEqual(stdout.trim(), 'index.mjs!');
17+
assert.strictEqual(stderr, '');
18+
})
19+
it('should resolve foreign schemas by doing regular url absolutization', async () => {
20+
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
21+
'--no-warnings',
22+
'--experimental-loader',
23+
fixtures.fileURL('/es-module-loaders/uyyt-dummy-loader.mjs'),
24+
fixtures.path('/es-module-loaders/uyyt-dummy-loader-main2.mjs'),
25+
]);
26+
assert.strictEqual(code, 0);
27+
assert.strictEqual(stdout.trim(), '42');
28+
assert.strictEqual(stderr, '');
29+
})
30+
})

test/fixtures/es-module-loaders/http-loader.mjs

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,5 @@
11
import { get } from 'http';
22

3-
export function resolve(specifier, context, nextResolve) {
4-
const { parentURL = null } = context;
5-
6-
if (specifier.startsWith('http://')) {
7-
return {
8-
shortCircuit: true,
9-
url: specifier,
10-
};
11-
} else if (parentURL?.startsWith('http://')) {
12-
return {
13-
shortCircuit: true,
14-
url: new URL(specifier, parentURL).href,
15-
};
16-
}
17-
18-
return nextResolve(specifier);
19-
}
20-
213
export function load(url, context, nextLoad) {
224
if (url.startsWith('http://')) {
235
return new Promise((resolve, reject) => {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import 'uyyt://1/index.mjs';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import 'uyyt://1/index2.mjs';
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export function load(url, context, nextLoad) {
2+
switch (url) {
3+
case 'uyyt://1/index.mjs':
4+
return {
5+
source: 'console.log("index.mjs!")',
6+
format: 'module',
7+
shortCircuit: true,
8+
};
9+
case 'uyyt://1/index2.mjs':
10+
return {
11+
source: 'import c from "./sub.mjs"; console.log(c);',
12+
format: 'module',
13+
shortCircuit: true,
14+
};
15+
case 'uyyt://1/sub.mjs':
16+
return {
17+
source: 'export default 42',
18+
format: 'module',
19+
shortCircuit: true,
20+
};
21+
default:
22+
return nextLoad(url, context);
23+
}
24+
}

0 commit comments

Comments
 (0)