Skip to content

Commit 3a9e140

Browse files
Docs: Add Iterative chaining design (#32)
* update design docs to support multiple concurrent proposals * update proposals to incorporate short-circuit as a returned flag * add return `signals` * align to updates to "middleware" design doc
1 parent 9df83a2 commit 3a9e140

File tree

2 files changed

+285
-0
lines changed

2 files changed

+285
-0
lines changed

doc/design/overview.md

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

2020
### Proposals
2121

22+
* [Chaining Hooks “Iterative” Design](./proposal-chaining-iterative.md)
2223
* [Chaining Hooks “Middleware” Design](./proposal-chaining-middleware.md)
2324

2425
## History
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
# Chaining Hooks “Iterative” 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+
Following the pattern of `--require`:
12+
13+
```console
14+
node \
15+
--loader unpkg \
16+
--loader http-to-https \
17+
--loader cache-buster
18+
```
19+
20+
These would be called in the following sequence:
21+
22+
`unpkg``http-to-https``cache-buster`
23+
24+
Resolve hooks would have the following signature:
25+
26+
```ts
27+
export async function resolve(
28+
interimResult: { // results from the previous hook
29+
format = '',
30+
url = '',
31+
},
32+
context: {
33+
conditions = string[], // Export conditions of the relevant package.json
34+
parentUrl = null, // The module importing this one, or null if
35+
// this is the Node entry point
36+
specifier: string, // The original value of the import specifier
37+
},
38+
defaultResolve, // Node's default resolve hook
39+
): {
40+
format?: string, // A hint to the load hook (it might be ignored)
41+
signals?: { // Signals from this hook to the ESMLoader
42+
contextOverride?: object, // A new `context` argument for the next hook
43+
interimIgnored?: true, // interimResult was intentionally ignored
44+
shortCircuit?: true, // `resolve` chain should be terminated
45+
},
46+
url: string, // The absolute URL that this input resolves to
47+
} {
48+
```
49+
50+
A hook including `shortCircuit: true` will cause the chain to short-circuit, immediately terminating the hook's chain (no subsequent `resolve` hooks are called).
51+
52+
### `unpkg` loader
53+
54+
<details>
55+
<summary>`unpkg.mjs`</summary>
56+
57+
```js
58+
export async function resolve(
59+
interimResult,
60+
{ originalSpecifier },
61+
) {
62+
if (isBareSpecifier(originalSpecifier)) return `http://unpkg.com/${originalSpecifier}`;
63+
}
64+
```
65+
</details>
66+
67+
### `http-to-https` loader
68+
69+
<details>
70+
<summary>`http-to-https.mjs`</summary>
71+
72+
```js
73+
export async function resolve(
74+
interimResult,
75+
context,
76+
) {
77+
const url = new URL(interimResult.url); // this can throw, so handle appropriately
78+
79+
if (url.protocol = 'http:') url.protocol = 'https:';
80+
81+
return { url: url.toString() };
82+
}
83+
```
84+
</details>
85+
86+
### `cache-buster` resolver
87+
88+
<details>
89+
<summary>`cache-buster.mjs`</summary>
90+
91+
```js
92+
export async function resolve(
93+
interimResult,
94+
) {
95+
const url = new URL(interimResult.url); // this can throw, so handle appropriately
96+
97+
if (supportsQueryString(url.protocol)) { // exclude data: & friends
98+
url.searchParams.set('t', Date.now());
99+
}
100+
101+
return { url: url.toString() };
102+
}
103+
104+
function supportsQueryString(/**/) {/**/}
105+
```
106+
</details>
107+
108+
109+
## Chaining `load` hooks
110+
111+
Say you had a chain of three loaders:
112+
113+
* `babel` transforms modern JavaScript source into a specified target
114+
* `coffeescript` transforms CoffeeScript source into JavaScript source
115+
* `https` fetches `https:` URLs and returns their contents
116+
117+
Following the pattern of `--require`:
118+
119+
```console
120+
node \
121+
--loader https \
122+
--loader babel \
123+
--loader coffeescript \
124+
```
125+
126+
These would be called in the following sequence:
127+
128+
(`https` OR `defaultLoad`) → `coffeescript``babel`
129+
130+
1. `defaultLoad` / `https` needs to be first to actually get the source, which is fed to the subsequent loader
131+
1. `coffeescript` receives the raw source from the previous loader and transpiles coffeescript files to regular javascript
132+
1. `babel` receives potentially bleeding-edge JavaScript and transforms it to some ancient JavaScript target
133+
134+
The below examples are not exhaustive and provide only the gist of what each loader needs to do and how it interacts with the others.
135+
136+
Load hooks would have the following signature:
137+
138+
```ts
139+
export async function load(
140+
interimResult: { // result from the previous hook
141+
format = '', // the value if `resolve` settled with a `format`
142+
// until a load hook provides a different value
143+
source = '',
144+
},
145+
context: {
146+
conditions = string[], // Export conditions of the relevant package.json
147+
parentUrl = null, // The module importing this one, or null if
148+
// this is the Node entry point
149+
resolvedUrl: string, // The URL returned by the last hook of the
150+
// `resolve` chain
151+
},
152+
defaultLoad: function, // Node's default load hook
153+
): {
154+
format: 'builtin' | 'commonjs' | 'module' | 'json' | 'wasm', // A format
155+
// that Node understands
156+
signals?: { // Signals from this hook to the ESMLoader
157+
contextOverride?: object, // A new `context` argument for the next hook
158+
interimIgnored?: true, // interimResult was intentionally ignored
159+
shortCircuit?: true, // `resolve` chain should be terminated
160+
},
161+
source: string | ArrayBuffer | TypedArray, // The source for Node to evaluate
162+
} {
163+
```
164+
165+
A hook including `shortCircuit: true` will cause the chain to short-circuit, immediately terminating the hook's chain (no subsequent `load` hooks are called).
166+
167+
### `https` loader
168+
169+
<details>
170+
<summary>`https.mjs`</summary>
171+
172+
```js
173+
export async function load(
174+
interimResult,
175+
{ resolvedUrl },
176+
) {
177+
if (interimResult.source) return; // step aside (content already retrieved)
178+
179+
if (!resolvedUrl.startsWith('https://')) return; // step aside
180+
181+
return new Promise(function loadHttpsSource(resolve, reject) {
182+
get(resolvedUrl, function getHttpsSource(rsp) {
183+
const format = mimeTypeToFormat.get(rsp.headers['content-type']);
184+
let source = '';
185+
186+
rsp.on('data', (chunk) => source += chunk);
187+
rsp.on('end', () => resolve({ format, source }));
188+
rsp.on('error', reject);
189+
});
190+
});
191+
}
192+
193+
const mimeTypeToFormat = new Map([
194+
['application/node', 'commonjs'],
195+
['application/javascript', 'module'],
196+
['text/javascript', 'module'],
197+
['application/json', 'json'],
198+
['text/coffeescript', 'coffeescript'],
199+
//
200+
]);
201+
```
202+
</details>
203+
204+
### `coffeescript` loader
205+
206+
<details>
207+
<summary>`coffeescript.mjs`</summary>
208+
209+
```js
210+
export async function load(
211+
interimResult, // possibly output of https-loader
212+
context,
213+
defaulLoad,
214+
) {
215+
const { resolvedUrl } = context;
216+
if (!coffeescriptExtensionsRgx.test(resolvedUrl)) return; // step aside
217+
218+
const format = interimResult.format || await getPackageType(resolvedUrl);
219+
if (format === 'commonjs') return { format };
220+
221+
const rawSource = (
222+
interimResult.source
223+
|| await defaulLoad(resolvedUrl, { ...context, format }).source
224+
)
225+
const transformedSource = CoffeeScript.compile(rawSource.toString(), {
226+
bare: true,
227+
filename: resolvedUrl,
228+
});
229+
230+
return {
231+
format,
232+
source: transformedSource,
233+
};
234+
}
235+
236+
function getPackageType(url) {/**/ }
237+
const coffeescriptExtensionsRgs = /**/
238+
```
239+
</details>
240+
241+
### `babel` loader
242+
243+
<details>
244+
<summary>`babel.mjs`</summary>
245+
246+
```js
247+
export async function load(
248+
interimResult, // possibly output of coffeescript-loader
249+
context,
250+
defaulLoad,
251+
) {
252+
const { resolvedUrl } = context;
253+
const babelConfig = await getBabelConfig(resolvedUrl);
254+
255+
const format = (
256+
interimResult.format
257+
|| babelOutputToFormat.get(babelConfig.output.format)
258+
);
259+
260+
if (format === 'commonjs') return { format };
261+
262+
const sourceToTranspile = (
263+
interimResult.source
264+
|| await defaulLoad(resolvedUrl, { ...context, format }).source
265+
);
266+
const transformedSource = Babel.transformSync(
267+
sourceToTranspile.toString(),
268+
babelConfig,
269+
).code;
270+
271+
return {
272+
format,
273+
source: transformedSource,
274+
};
275+
}
276+
277+
function getBabelConfig(url) {/**/ }
278+
const babelOutputToFormat = new Map([
279+
['cjs', 'commonjs'],
280+
['esm', 'module'],
281+
//
282+
]);
283+
```
284+
</details>

0 commit comments

Comments
 (0)