diff --git a/doc/design/overview.md b/doc/design/overview.md index 4b5ad82..bb81b13 100644 --- a/doc/design/overview.md +++ b/doc/design/overview.md @@ -19,6 +19,7 @@ Custom loaders are intended to chain to support various concerns beyond the scop ### Proposals +* [Chaining Hooks “Iterative” Design](./proposal-chaining-iterative.md) * [Chaining Hooks “Middleware” Design](./proposal-chaining-middleware.md) ## History diff --git a/doc/design/proposal-chaining-iterative.md b/doc/design/proposal-chaining-iterative.md new file mode 100644 index 0000000..b3d75f3 --- /dev/null +++ b/doc/design/proposal-chaining-iterative.md @@ -0,0 +1,284 @@ +# Chaining Hooks “Iterative” Design + +## Chaining `resolve` hooks + +Say you had a chain of three loaders: + +1. `unpkg` resolves a specifier `foo` to an URL `http://unpkg.com/foo`. +2. `http-to-https` rewrites that URL to `https://unpkg.com/foo`. +3. `cache-buster` takes the URL and adds a timestamp to the end, like `https://unpkg.com/foo?ts=1234567890`. + +Following the pattern of `--require`: + +```console +node \ + --loader unpkg \ + --loader http-to-https \ + --loader cache-buster +``` + +These would be called in the following sequence: + +`unpkg` → `http-to-https` → `cache-buster` + +Resolve hooks would have the following signature: + +```ts +export async function resolve( + interimResult: { // results from the previous hook + format = '', + url = '', + }, + context: { + conditions = string[], // Export conditions of the relevant package.json + parentUrl = null, // The module importing this one, or null if + // this is the Node entry point + specifier: string, // The original value of the import specifier + }, + defaultResolve, // Node's default resolve hook +): { + format?: string, // A hint to the load hook (it might be ignored) + signals?: { // Signals from this hook to the ESMLoader + contextOverride?: object, // A new `context` argument for the next hook + interimIgnored?: true, // interimResult was intentionally ignored + shortCircuit?: true, // `resolve` chain should be terminated + }, + url: string, // The absolute URL that this input resolves to +} { +``` + +A hook including `shortCircuit: true` will cause the chain to short-circuit, immediately terminating the hook's chain (no subsequent `resolve` hooks are called). + +### `unpkg` loader + +
+`unpkg.mjs` + +```js +export async function resolve( + interimResult, + { originalSpecifier }, +) { + if (isBareSpecifier(originalSpecifier)) return `http://unpkg.com/${originalSpecifier}`; +} +``` +
+ +### `http-to-https` loader + +
+`http-to-https.mjs` + +```js +export async function resolve( + interimResult, + context, +) { + const url = new URL(interimResult.url); // this can throw, so handle appropriately + + if (url.protocol = 'http:') url.protocol = 'https:'; + + return { url: url.toString() }; +} +``` +
+ +### `cache-buster` resolver + +
+`cache-buster.mjs` + +```js +export async function resolve( + interimResult, +) { + const url = new URL(interimResult.url); // this can throw, so handle appropriately + + if (supportsQueryString(url.protocol)) { // exclude data: & friends + url.searchParams.set('t', Date.now()); + } + + return { url: url.toString() }; +} + +function supportsQueryString(/* … */) {/* … */} +``` +
+ + +## Chaining `load` hooks + +Say you had a chain of three loaders: + +* `babel` transforms modern JavaScript source into a specified target +* `coffeescript` transforms CoffeeScript source into JavaScript source +* `https` fetches `https:` URLs and returns their contents + +Following the pattern of `--require`: + +```console +node \ + --loader https \ + --loader babel \ + --loader coffeescript \ +``` + +These would be called in the following sequence: + +(`https` OR `defaultLoad`) → `coffeescript` → `babel` + +1. `defaultLoad` / `https` needs to be first to actually get the source, which is fed to the subsequent loader +1. `coffeescript` receives the raw source from the previous loader and transpiles coffeescript files to regular javascript +1. `babel` receives potentially bleeding-edge JavaScript and transforms it to some ancient JavaScript target + +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. + +Load hooks would have the following signature: + +```ts +export async function load( + interimResult: { // result from the previous hook + format = '', // the value if `resolve` settled with a `format` + // until a load hook provides a different value + source = '', + }, + context: { + conditions = string[], // Export conditions of the relevant package.json + parentUrl = null, // The module importing this one, or null if + // this is the Node entry point + resolvedUrl: string, // The URL returned by the last hook of the + // `resolve` chain + }, + defaultLoad: function, // Node's default load hook +): { + format: 'builtin' | 'commonjs' | 'module' | 'json' | 'wasm', // A format + // that Node understands + signals?: { // Signals from this hook to the ESMLoader + contextOverride?: object, // A new `context` argument for the next hook + interimIgnored?: true, // interimResult was intentionally ignored + shortCircuit?: true, // `resolve` chain should be terminated + }, + source: string | ArrayBuffer | TypedArray, // The source for Node to evaluate +} { +``` + +A hook including `shortCircuit: true` will cause the chain to short-circuit, immediately terminating the hook's chain (no subsequent `load` hooks are called). + +### `https` loader + +
+`https.mjs` + +```js +export async function load( + interimResult, + { resolvedUrl }, +) { + if (interimResult.source) return; // step aside (content already retrieved) + + if (!resolvedUrl.startsWith('https://')) return; // step aside + + return new Promise(function loadHttpsSource(resolve, reject) { + get(resolvedUrl, function getHttpsSource(rsp) { + const format = mimeTypeToFormat.get(rsp.headers['content-type']); + let source = ''; + + rsp.on('data', (chunk) => source += chunk); + rsp.on('end', () => resolve({ format, source })); + rsp.on('error', reject); + }); + }); +} + +const mimeTypeToFormat = new Map([ + ['application/node', 'commonjs'], + ['application/javascript', 'module'], + ['text/javascript', 'module'], + ['application/json', 'json'], + ['text/coffeescript', 'coffeescript'], + // … +]); +``` +
+ +### `coffeescript` loader + +
+`coffeescript.mjs` + +```js +export async function load( + interimResult, // possibly output of https-loader + context, + defaulLoad, +) { + const { resolvedUrl } = context; + if (!coffeescriptExtensionsRgx.test(resolvedUrl)) return; // step aside + + const format = interimResult.format || await getPackageType(resolvedUrl); + if (format === 'commonjs') return { format }; + + const rawSource = ( + interimResult.source + || await defaulLoad(resolvedUrl, { ...context, format }).source + ) + const transformedSource = CoffeeScript.compile(rawSource.toString(), { + bare: true, + filename: resolvedUrl, + }); + + return { + format, + source: transformedSource, + }; +} + +function getPackageType(url) {/* … */ } +const coffeescriptExtensionsRgs = /* … */ +``` +
+ +### `babel` loader + +
+`babel.mjs` + +```js +export async function load( + interimResult, // possibly output of coffeescript-loader + context, + defaulLoad, +) { + const { resolvedUrl } = context; + const babelConfig = await getBabelConfig(resolvedUrl); + + const format = ( + interimResult.format + || babelOutputToFormat.get(babelConfig.output.format) + ); + + if (format === 'commonjs') return { format }; + + const sourceToTranspile = ( + interimResult.source + || await defaulLoad(resolvedUrl, { ...context, format }).source + ); + const transformedSource = Babel.transformSync( + sourceToTranspile.toString(), + babelConfig, + ).code; + + return { + format, + source: transformedSource, + }; +} + +function getBabelConfig(url) {/* … */ } +const babelOutputToFormat = new Map([ + ['cjs', 'commonjs'], + ['esm', 'module'], + // … +]); +``` +