Skip to content

Commit 9df83a2

Browse files
update to support chaining & incorporate short-circuit as a return flag (#41)
Co-authored-by: Geoffrey Booth <[email protected]>
1 parent ce2c4e0 commit 9df83a2

File tree

3 files changed

+266
-131
lines changed

3 files changed

+266
-131
lines changed

doc/design/overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Custom loaders are intended to chain to support various concerns beyond the scop
1919

2020
### Proposals
2121

22-
* [Recursive chaining](./proposal-chaining-recursive.md)
22+
* [Chaining Hooks “Middleware” Design](./proposal-chaining-middleware.md)
2323

2424
## History
2525

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
# Chaining Hooks “Middleware” Design
2+
3+
## Chaining `resolve` hooks
4+
5+
Say you had a chain of three loaders:
6+
7+
1. `unpkg` resolves a specifier `foo` to an URL `http://unpkg.com/foo`.
8+
2. `http-to-https` rewrites that URL to `https://unpkg.com/foo`.
9+
3. `cache-buster` takes the URL and adds a timestamp to the end, like `https://unpkg.com/foo?ts=1234567890`.
10+
11+
The hook functions nest: each one must always return a plain object, and the chaining happens as a result of each function calling `next()`, which is a reference to the subsequent loader’s hook.
12+
13+
A hook that fails to return triggers an exception. A hook that returns without calling `next()`, and without returning `shortCircuit: true`, also triggers an exception. These errors are to help prevent unintentional breaks in the chain.
14+
15+
Following the pattern of `--require`:
16+
17+
```console
18+
node \
19+
--loader unpkg \
20+
--loader http-to-https \
21+
--loader cache-buster
22+
```
23+
24+
These would be called in the following sequence: `cache-buster` calls `http-to-https`, which calls `unpkg`. Or in JavaScript terms, `cacheBuster(httpToHttps(unpkg(input)))`.
25+
26+
Resolve hooks would have the following signature:
27+
28+
```ts
29+
export async function resolve(
30+
specifier: string, // The original specifier
31+
context: {
32+
conditions = string[], // Export conditions of the relevant `package.json`
33+
parentUrl = null, // The module importing this one, or null if
34+
// this is the Node entry point
35+
},
36+
next: function, // The subsequent `resolve` hook in the chain,
37+
// or Node’s default `resolve` hook after the
38+
// last user-supplied `resolve` hook
39+
): {
40+
format?: string, // A hint to the load hook (it might be ignored)
41+
shortCircuit?: true, // A signal that this hook intends to terminate
42+
// the chain of `resolve` hooks
43+
url: string, // The absolute URL that this input resolves to
44+
} {
45+
```
46+
47+
### `cache-buster` loader
48+
49+
<details>
50+
<summary>`cache-buster.mjs`</summary>
51+
52+
```js
53+
export async function resolve(
54+
specifier,
55+
context,
56+
next, // In this example, `next` is https’ resolve
57+
) {
58+
const result = await next(specifier, context);
59+
60+
const url = new URL(result.url);
61+
62+
if (url.protocol !== 'data:')) { // `data:` URLs don’t support query strings
63+
url.searchParams.set('ts', Date.now());
64+
}
65+
66+
return { url: url.href };
67+
}
68+
```
69+
</details>
70+
71+
### `http-to-https` loader
72+
73+
<details>
74+
<summary>`http-to-https.mjs`</summary>
75+
76+
```js
77+
export async function resolve(
78+
specifier,
79+
context,
80+
next, // In this example, `next` is unpkg’s resolve
81+
) {
82+
const result = await next(specifier, context);
83+
84+
const url = new URL(result.url);
85+
86+
if (url.protocol === 'http:') {
87+
url.protocol = 'https:';
88+
}
89+
90+
return { url: url.href };
91+
}
92+
```
93+
</details>
94+
95+
### `unpkg` loader
96+
97+
<details>
98+
<summary>`unpkg.mjs`</summary>
99+
100+
```js
101+
export async function resolve(
102+
specifier,
103+
context,
104+
next, // In this example, `next` is Node’s default `resolve`
105+
) {
106+
if (isBareSpecifier(specifier)) { // Implemented elsewhere
107+
return { url: `http://unpkg.com/${specifier}` };
108+
}
109+
110+
return next(specifier, context);
111+
}
112+
```
113+
</details>
114+
115+
## Chaining `load` hooks
116+
117+
Say you had a chain of three loaders:
118+
119+
* `babel` transforms modern JavaScript source into a specified target
120+
* `coffeescript` transforms CoffeeScript source into JavaScript source
121+
* `https` fetches `https:` URLs and returns their contents
122+
123+
Following the pattern of `--require`:
124+
125+
```console
126+
node \
127+
--loader babel \
128+
--loader coffeescript \
129+
--loader https
130+
```
131+
132+
These would be called in the following sequence: `babel` calls `coffeescript`, which calls `https`. Or in JavaScript terms, `babel(coffeescript(https(input)))`:
133+
134+
Load hooks would have the following signature:
135+
136+
```ts
137+
export async function load(
138+
resolvedUrl: string, // The URL returned by the last hook of the
139+
// `resolve` chain
140+
context: {
141+
conditions = string[], // Export conditions of the relevant `package.json`
142+
parentUrl = null, // The module importing this one, or null if
143+
// this is the Node entry point
144+
resolvedFormat?: string, // The format returned by the last hook of the
145+
// `resolve` chain
146+
},
147+
next: function, // The subsequent `load` hook in the chain,
148+
// or Node’s default `load` hook after the
149+
// last user-supplied `load` hook
150+
): {
151+
format: 'builtin' | 'commonjs' | 'module' | 'json' | 'wasm', // A format
152+
// that Node understands
153+
shortCircuit?: true, // A signal that this hook intends to terminate
154+
// the chain of `load` hooks
155+
source: string | ArrayBuffer | TypedArray, // The source for Node to evaluate
156+
} {
157+
```
158+
159+
### `babel` loader
160+
161+
<details>
162+
<summary>`babel.mjs`</summary>
163+
164+
```js
165+
const babelOutputToFormat = new Map([
166+
['cjs', 'commonjs'],
167+
['esm', 'module'],
168+
//
169+
]);
170+
171+
export async function load(
172+
url,
173+
context,
174+
next, // In this example, `next` is coffeescript’s hook
175+
) {
176+
const babelConfig = await getBabelConfig(url); // Implemented elsewhere
177+
178+
const format = babelOutputToFormat.get(babelConfig.output.format);
179+
180+
if (format === 'commonjs') {
181+
return { format, source: '' }; // Source is ignored for CommonJS
182+
}
183+
184+
const { source: transpiledSource } = await next(url, { ...context, format });
185+
const { code: transformedSource } = Babel.transformSync(transpiledSource.toString(), babelConfig);
186+
187+
return { format, source: transformedSource };
188+
}
189+
```
190+
</details>
191+
192+
### `coffeescript` loader
193+
194+
<details>
195+
<summary>`coffeescript.mjs`</summary>
196+
197+
```js
198+
// CoffeeScript files end in .coffee, .litcoffee or .coffee.md.
199+
const extensionsRegex = /\.coffee$|\.litcoffee$|\.coffee\.md$/;
200+
201+
export async function load(
202+
url,
203+
context,
204+
next, // In this example, `next` is https’ hook
205+
) {
206+
if (!extensionsRegex.test(url)) { // Skip this hook for non-CoffeeScript imports
207+
return next(url, context);
208+
}
209+
210+
const format = await getPackageType(url); // Implemented elsewhere
211+
212+
if (format === 'commonjs') {
213+
return { format, source: '' }; // Source is ignored for CommonJS
214+
}
215+
216+
const { source: rawSource } = await next(url, { ...context, format });
217+
const transformedSource = CoffeeScript.compile(rawSource.toString(), {
218+
bare: true,
219+
filename: url,
220+
});
221+
222+
return { format, source: transformedSource };
223+
}
224+
```
225+
</details>
226+
227+
### `https` loader
228+
229+
<details>
230+
<summary>`https.mjs`</summary>
231+
232+
```js
233+
import { get } from 'node:https';
234+
235+
const mimeTypeToFormat = new Map([
236+
['application/node', 'commonjs'],
237+
['application/javascript', 'module'],
238+
['text/javascript', 'module'],
239+
['application/json', 'json'],
240+
['application/wasm', 'wasm'],
241+
['text/coffeescript', 'coffeescript'],
242+
//
243+
]);
244+
245+
export async function load(
246+
url,
247+
context,
248+
next, // In this example, `next` is Node’s default `load`
249+
) {
250+
if (!url.startsWith('https://')) { // Skip this hook for non-https imports
251+
return next(url, context);
252+
}
253+
254+
return new Promise(function loadHttpsSource(resolve, reject) {
255+
get(url, function getHttpsSource(res) {
256+
const format = mimeTypeToFormat.get(res.headers['content-type']);
257+
let source = '';
258+
res.on('data', (chunk) => source += chunk);
259+
res.on('end', () => resolve({ format, source }));
260+
res.on('error', reject);
261+
}).on('error', (err) => reject(err));
262+
});
263+
}
264+
```
265+
</details>

0 commit comments

Comments
 (0)