Skip to content

Commit

Permalink
doc: fix transpiler loader hooks documentation
Browse files Browse the repository at this point in the history
The loader hooks examples have been broken for a while:

1. The nextLoad() hook cannot be used on a .coffee file that ends
   up going to the default load step without an explict format,
   which would cause a ERR_UNKNOWN_FILE_EXTENSION. Mention
   adding a package.json with a type field to work around it
   in the example.
2. Pass the context parameter to the nextLoad() invocation and
   document that context.format is mandatory when module type
   is not explicitly inferrable from the module.
3. Correct the getPackageType() implementation which returns
   false instead of undefined in the absence of an explict format,
   which is not a valid type for format.

PR-URL: #57037
Refs: #57030
Reviewed-By: Jacob Smith <[email protected]>
Reviewed-By: Antoine du Hamel <[email protected]>
  • Loading branch information
joyeecheung authored and targos committed Feb 17, 2025
1 parent 286bb84 commit c02494f
Showing 1 changed file with 48 additions and 54 deletions.
102 changes: 48 additions & 54 deletions doc/api/module.md
Original file line number Diff line number Diff line change
Expand Up @@ -1156,7 +1156,11 @@ changes:
Node.js default `load` hook after the last user-supplied `load` hook
* `url` {string}
* `context` {Object|undefined} When omitted, defaults are provided. When provided, defaults are
merged in with preference to the provided properties.
merged in with preference to the provided properties. In the default `nextLoad`, if
the module pointed to by `url` does not have explicit module type information,
`context.format` is mandatory.
<!-- TODO(joyeecheung): make it at least optionally non-mandatory by allowing
JS-style/TS-style module detection when the format is simply unknown -->
* Returns: {Object|Promise} The asynchronous version takes either an object containing the
following properties, or a `Promise` that will resolve to such an object. The
synchronous version only accepts an object returned synchronously.
Expand Down Expand Up @@ -1354,36 +1358,32 @@ transpiler hooks should only be used for development and testing purposes.
```mjs
// coffeescript-hooks.mjs
import { readFile } from 'node:fs/promises';
import { dirname, extname, resolve as resolvePath } from 'node:path';
import { cwd } from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { findPackageJSON } from 'node:module';
import coffeescript from 'coffeescript';

const extensionsRegex = /\.(coffee|litcoffee|coffee\.md)$/;

export async function load(url, context, nextLoad) {
if (extensionsRegex.test(url)) {
// CoffeeScript files can be either CommonJS or ES modules, so we want any
// CoffeeScript file to be treated by Node.js the same as a .js file at the
// same location. To determine how Node.js would interpret an arbitrary .js
// file, search up the file system for the nearest parent package.json file
// and read its "type" field.
const format = await getPackageType(url);

const { source: rawSource } = await nextLoad(url, { ...context, format });
// CoffeeScript files can be either CommonJS or ES modules. Use a custom format
// to tell Node.js not to detect its module type.
const { source: rawSource } = await nextLoad(url, { ...context, format: 'coffee' });
// This hook converts CoffeeScript source code into JavaScript source code
// for all imported CoffeeScript files.
const transformedSource = coffeescript.compile(rawSource.toString(), url);

// To determine how Node.js would interpret the transpilation result,
// search up the file system for the nearest parent package.json file
// and read its "type" field.
return {
format,
format: await getPackageType(url),
shortCircuit: true,
source: transformedSource,
};
}

// Let Node.js handle all other URLs.
return nextLoad(url);
return nextLoad(url, context);
}

async function getPackageType(url) {
Expand All @@ -1394,72 +1394,51 @@ async function getPackageType(url) {
// this simple truthy check for whether `url` contains a file extension will
// work for most projects but does not cover some edge-cases (such as
// extensionless files or a url ending in a trailing space)
const isFilePath = !!extname(url);
// If it is a file path, get the directory it's in
const dir = isFilePath ?
dirname(fileURLToPath(url)) :
url;
// Compose a file path to a package.json in the same directory,
// which may or may not exist
const packagePath = resolvePath(dir, 'package.json');
// Try to read the possibly nonexistent package.json
const type = await readFile(packagePath, { encoding: 'utf8' })
.then((filestring) => JSON.parse(filestring).type)
.catch((err) => {
if (err?.code !== 'ENOENT') console.error(err);
});
// If package.json existed and contained a `type` field with a value, voilà
if (type) return type;
// Otherwise, (if not at the root) continue checking the next directory up
// If at the root, stop and return false
return dir.length > 1 && getPackageType(resolvePath(dir, '..'));
const pJson = findPackageJSON(url);

return readFile(pJson, 'utf8')
.then(JSON.parse)
.then((json) => json?.type)
.catch(() => undefined);
}
```
##### Synchronous version
```mjs
// coffeescript-sync-hooks.mjs
import { readFileSync } from 'node:fs/promises';
import { registerHooks } from 'node:module';
import { dirname, extname, resolve as resolvePath } from 'node:path';
import { cwd } from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { readFileSync } from 'node:fs';
import { registerHooks, findPackageJSON } from 'node:module';
import coffeescript from 'coffeescript';

const extensionsRegex = /\.(coffee|litcoffee|coffee\.md)$/;

function load(url, context, nextLoad) {
if (extensionsRegex.test(url)) {
const format = getPackageType(url);

const { source: rawSource } = nextLoad(url, { ...context, format });
const { source: rawSource } = nextLoad(url, { ...context, format: 'coffee' });
const transformedSource = coffeescript.compile(rawSource.toString(), url);

return {
format,
format: getPackageType(url),
shortCircuit: true,
source: transformedSource,
};
}

return nextLoad(url);
return nextLoad(url, context);
}

function getPackageType(url) {
const isFilePath = !!extname(url);
const dir = isFilePath ? dirname(fileURLToPath(url)) : url;
const packagePath = resolvePath(dir, 'package.json');

let type;
const pJson = findPackageJSON(url);
if (!pJson) {
return undefined;
}
try {
const filestring = readFileSync(packagePath, { encoding: 'utf8' });
type = JSON.parse(filestring).type;
} catch (err) {
if (err?.code !== 'ENOENT') console.error(err);
const file = readFileSync(pJson, 'utf-8');
return JSON.parse(file)?.type;
} catch {
return undefined;
}
if (type) return type;
return dir.length > 1 && getPackageType(resolvePath(dir, '..'));
}

registerHooks({ load });
Expand All @@ -1481,6 +1460,21 @@ console.log "Brought to you by Node.js version #{version}"
export scream = (str) -> str.toUpperCase()
```
For the sake of running the example, add a `package.json` file containing the
module type of the CoffeeScript files.
```json
{
"type": "module"
}
```
This is only for running the example. In real world loaders, `getPackageType()` must be
able to return an `format` known to Node.js even in the absence of an explicit type in a
`package.json`, or otherwise the `nextLoad` call would throw `ERR_UNKNOWN_FILE_EXTENSION`
(if undefined) or `ERR_UNKNOWN_MODULE_FORMAT` (if it's not a known format listed in
the [load hook][] documentation).
With the preceding hooks modules, running
`node --import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register(pathToFileURL("./coffeescript-hooks.mjs"));' ./main.coffee`
or `node --import ./coffeescript-sync-hooks.mjs ./main.coffee`
Expand Down

0 comments on commit c02494f

Please sign in to comment.