Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ Power Assert follows the principle of "No API is the best API". You don't need t

### Using with Node.js Test Runner

For Node.js v22.14.0 or later:
For Node.js v22.15.0 or later:

```bash
npm install --save-dev @power-assert/node
Expand Down Expand Up @@ -244,7 +244,7 @@ This monorepo contains the following packages:

### Integration Packages

- [@power-assert/node](packages/node) - Custom hook for Node.js Test Runner (requires Node.js v22.14.0+)
- [@power-assert/node](packages/node) - Custom hook for Node.js Test Runner (requires Node.js v22.15.0+)
- [rollup-plugin-power-assert](packages/rollup-plugin-power-assert) - Plugin for Rollup and Vite (also supports Vitest)
- [swc-plugin-power-assert](packages/swc-plugin-power-assert) - High-performance SWC plugin written in Rust

Expand Down
166 changes: 166 additions & 0 deletions docs/adr-007-sync-register-hooks-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# ADR-007: Migration from async module.register to sync module.registerHooks

## Status

Accepted

## Context

The `@power-assert/node` package uses Node.js module hooks to intercept and transpile test files at load time. Previously, it relied on `module.register()` to register asynchronous customization hooks (`resolve` and `load`) that ran in a separate loader thread.

Node.js has deprecated `module.register()` in favor of `module.registerHooks()`, which runs hooks synchronously in the main thread. This is not merely a rename — it represents a fundamental shift from an asynchronous, off-thread model to a synchronous, in-process model.

### Key differences between the two APIs

| Aspect | `module.register()` (deprecated) | `module.registerHooks()` (new) |
|---|---|---|
| Execution model | Async hooks in a separate loader thread | Sync hooks in the main thread |
| Hook signatures | `async function resolve(...)`, `async function load(...)` | Sync function signatures (`ResolveHookSync`, `LoadHookSync`) |
| Registration | Indirect — passes a specifier URL to a hooks file | Direct — passes hook function references |
| I/O in hooks | `await readFile()`, `await transpile()` | `readFileSync()`, `transpileSync()` |
| Available since | Node.js 20.6.0 | Node.js 22.15.0 |

### Development progression

The migration was carried out incrementally:

1. **Sync transpiler foundation**: Added synchronous versions of transpile functions (`transpileWithInlineSourceMapSync`, `transpileWithSeparatedSourceMapSync`, `transpileSync`, `findIncomingSourceMapSync`) to `@power-assert/transpiler`, since the new sync hooks cannot call async functions.

2. **Parallel sync implementation**: Created a standalone `sync.mts` as an independent sync implementation alongside the existing async `hooks.mts`, exposed via a separate `@power-assert/node/sync` export. This allowed validation without disrupting the existing async path.

3. **Decision to consolidate**: After Node.js officially deprecated `module.register()`, the parallel-export strategy ("subject to change" as noted in the commit) was abandoned in favor of a breaking change that replaces the async implementation entirely.

## Decision

Replace the async `module.register()` hooks with sync `module.registerHooks()` as the sole implementation, accepting this as a breaking change.

### 1. Hook function signatures

**Before**: Async exported functions
```typescript
export async function resolve(specifier, context, nextResolve): Promise<ResolveFnOutput> { ... }
export async function load(url, context, nextLoad): Promise<LoadFnOutput> { ... }
```

**After**: Sync typed function expressions
```typescript
export const resolve: ResolveHookSync = function resolve(specifier, context, nextResolve): ResolveFnOutput { ... };
export const load: LoadHookSync = function load(url, context, nextLoad): LoadFnOutput { ... };
```

**Rationale**: `module.registerHooks()` requires hook functions to conform to `ResolveHookSync` and `LoadHookSync` type interfaces. Named function expressions are used (rather than arrow functions) to preserve function names in stack traces for debuggability while allowing explicit type annotation on the binding.

### 2. Registration mechanism

**Before**: Indirect registration via specifier URL using package self-reference
```typescript
import { register } from 'node:module';
register('@power-assert/node/hooks', import.meta.url);
```

**After**: Direct registration by passing function references
```typescript
import { registerHooks } from 'node:module';
import { resolve, load } from './hooks.mts';
registerHooks({ resolve, load });
```

**Rationale**: `module.registerHooks()` accepts hook functions directly as an options object, eliminating the need for the indirect specifier-based loading that `module.register()` required. This also removes the dependency on the package self-reference pattern (described in ADR-005) for hook registration, since hooks are now imported directly via relative path.

### 3. All I/O converted to synchronous

- `readFile` (from `node:fs/promises`) → `readFileSync` (from `node:fs`)
- `transpileWithInlineSourceMap` (async) → `transpileWithInlineSourceMapSync` (sync)
- `transpileWithSeparatedSourceMap` (async) → `transpileWithSeparatedSourceMapSync` (sync)
- `getPackageType` converted from async Promise-chain to sync try/catch
- All `await` expressions removed from hook functions

**Rationale**: Synchronous hooks cannot use async I/O. The sync versions perform the same operations and are acceptable here because module loading is inherently sequential — there is no concurrency benefit from async I/O in this context.

### 4. Defensive error handling in getPackageType

**Before**: Assert that `findPackageJSON()` always returns a value, use Promise chain with `.catch(() => undefined)`
```typescript
const pJson = findPackageJSON(url);
assert(pJson !== undefined, 'cannot find package.json');
return readFile(pJson, 'utf8').then(JSON.parse).then(json => json?.type).catch(() => undefined);
```

**After**: Guard against missing package.json, use try/catch
```typescript
const pJson = findPackageJSON(url);
if (!pJson) { return undefined; }
try {
const file = readFileSync(pJson, 'utf-8');
return JSON.parse(file)?.type;
} catch { return undefined; }
```

**Rationale**: The assertion was overly strict — `findPackageJSON()` can legitimately return `undefined` for files outside any package scope. The new implementation gracefully handles this case.

### 5. Consolidated entry point

- Removed the separate `./sync` export from `package.json`
- Deleted `src/sync.mts` (its logic was merged into `hooks.mts`)
- The single `@power-assert/node` import remains the only entry point

**Rationale**: With the async path removed, maintaining two entry points is unnecessary. A single entry point simplifies both the API surface and the implementation.

### 6. Node.js version requirement

**Before**: `>=22.14.0`
**After**: `>=22.15.0`

**Rationale**: `module.registerHooks()` is available from Node.js 22.15.0.

## Consequences

### Benefits

1. **Alignment with Node.js direction**: Uses the recommended API, avoiding deprecation warnings and future removal risk
2. **Simpler execution model**: Hooks run in the main thread, eliminating the complexity of cross-thread communication
3. **Simpler registration**: Direct function passing instead of indirect specifier-based loading
4. **Single code path**: No async/sync duplication to maintain
5. **Better debuggability**: Synchronous stack traces are easier to follow than async ones across thread boundaries

### Trade-offs

1. **Breaking change**: Users must update Node.js to >=22.15.0
2. **Synchronous I/O**: File reads in hooks block the event loop, though this is acceptable during module loading which is inherently sequential
3. **Loss of backward compatibility**: Users on older Node.js versions cannot use this version of `@power-assert/node`

### Risks and Mitigations

1. **Risk**: `module.registerHooks()` API changes in future Node.js versions
- **Mitigation**: Unlike the deprecated `module.register()`, `registerHooks()` is the actively maintained path. The explicit `ResolveHookSync`/`LoadHookSync` type annotations will catch any signature changes at compile time.

## References

### `module.registerHooks()` — introduction

- **Proposal issue**: [nodejs/node#52219](https://github.com/nodejs/node/issues/52219) — Synchronous hooks proposal by Joyee Cheung
- **Design proposal**: [nodejs/loaders#198](https://github.com/nodejs/loaders/pull/198) — Loaders team design document
- **Implementation PR**: [nodejs/node#55698](https://github.com/nodejs/node/pull/55698) — `implement module.registerHooks()` by Joyee Cheung, landed in v23.5.0
- **Tracking issue**: [nodejs/node#56241](https://github.com/nodejs/node/issues/56241) — `module.registerHooks()` tracking issue
- **Release note**: [Node.js v23.5.0](https://nodejs.org/en/blog/release/v23.5.0), backported to [v22.15.0 LTS](https://nodejs.org/en/blog/release/v22.15.0)

### `module.register()` — deprecation (DEP0205)

- **Doc-only deprecation PR**: [nodejs/node#62395](https://github.com/nodejs/node/pull/62395) — by Geoffrey Booth, landed in v25.9.0 / v24.15.0
- **Runtime deprecation PR**: [nodejs/node#62401](https://github.com/nodejs/node/pull/62401) — by Geoffrey Booth (semver-major), landed in [v26.0.0](https://nodejs.org/en/blog/release/v26.0.0)
- **Deprecation commit**: [nodejs/node@98907f560f](https://github.com/nodejs/node/commit/98907f560f)

### Official documentation

- **`module.registerHooks()` API**: https://nodejs.org/api/module.html#moduleregisterhooksoptions
- **Deprecation list (DEP0205)**: https://nodejs.org/api/deprecations.html#DEP0205

### Background — why async hooks were deprecated

The async/off-thread hook model had fundamental architectural issues that proved unresolvable ([nodejs/node#50948](https://github.com/nodejs/node/issues/50948), [nodejs/node#51668](https://github.com/nodejs/node/issues/51668)):
- Deadlocks from cross-thread synchronous communication
- Lost `console.log()` output in the hooks worker thread
- Inability to cover `createRequire()` from off-thread
- Inter-thread communication overhead

The stated goal is to remove `module.register()` entirely in Node.js v27 ([nodejs/node#62401 comment](https://github.com/nodejs/node/pull/62401)).
2 changes: 1 addition & 1 deletion packages/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ For lightweight TypeScript support, you can use the built-in support for [Type s
node --enable-source-maps --import @power-assert/node --test some.test.ts
```

For Node version `>=22.6.0 <23.6.0` requires `--experimental-strip-types` flag explicitly.
For Node version `>=22.6.0 <22.18.0` or `>=23.0.0 <23.6.0` require `--experimental-strip-types` flag explicitly.

```
node --experimental-strip-types --enable-source-maps --import @power-assert/node --test some.test.ts
Expand Down
2 changes: 1 addition & 1 deletion packages/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
},
"license": "MIT",
"engines": {
"node": ">=22.14.0"
"node": ">=22.15.0"
},
"type": "module",
"main": "dist/register.mjs",
Expand Down
75 changes: 41 additions & 34 deletions packages/node/src/hooks.mts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { strict as assert } from 'node:assert';
import { transpileWithInlineSourceMap } from '@power-assert/transpiler';
import { readFile } from 'node:fs/promises';
import { transpileWithInlineSourceMapSync } from '@power-assert/transpiler';
import { readFileSync } from 'node:fs';
import { extname } from 'node:path';
import { findPackageJSON, stripTypeScriptTypes } from 'node:module';
import type { LoadFnOutput, LoadHookContext, ResolveFnOutput, ResolveHookContext } from 'node:module';
import type { LoadHookSync, LoadFnOutput, LoadHookContext, ResolveHookSync, ResolveFnOutput, ResolveHookContext } from 'node:module';

type NextResolveFn = (specifier: string, context?: ResolveHookContext) => ResolveFnOutput | Promise<ResolveFnOutput>;
type NextLoadFn = (url: string, context?: LoadHookContext) => LoadFnOutput | Promise<LoadFnOutput>;
type NextResolveFnSync = (specifier: string, context?: ResolveHookContext) => ResolveFnOutput;
type NextLoadFnSync = (url: string, context?: LoadHookContext) => LoadFnOutput;

const supportedExts = new Set([
'.js',
Expand All @@ -16,17 +16,19 @@ const supportedExts = new Set([
]);

/**
* The `resolve` hook chain is responsible for resolving file URL for a given module specifier and parent URL, and optionally its format (such as `'module'`) as a hint to the `load` hook.
* If a format is specified, the load hook is ultimately responsible for providing the final `format` value (and it is free to ignore the hint provided by `resolve`);
* if `resolve` provides a format, a custom `load` hook is required even if only to pass the value to the Node.js default `load` hook.
* The resolve hook chain is responsible for telling Node.js where to find and how to cache a given import statement or expression, or require call.
* It can optionally return a format (such as 'module') as a hint to the load hook.
* If a format is specified, the load hook is ultimately responsible for providing the final format value (and it is free to ignore the hint provided by resolve);
* if resolve provides a format, a custom load hook is required even if only to pass the value to the Node.js default load hook.
*
* @param specifier The specified URL path of the module to be resolved
* @param context
* @param nextResolve The subsequent `resolve` hook in the chain, or the Node.js default `resolve` hook after the last user-supplied resolve hook
* @param context The context in which the module is being resolved
* @param nextResolve The subsequent resolve hook in the chain, or the Node.js default resolve hook after the last user-supplied resolve hook
* @returns The result of the resolution, including the resolved URL and optional format
*/
export async function resolve (specifier: string, context: ResolveHookContext, nextResolve: NextResolveFn): Promise<ResolveFnOutput> {
const nextResolveWithShortCircuitFalse = async (specifier: string, context: ResolveHookContext): Promise<ResolveFnOutput> => {
const resolved = await nextResolve(specifier, context);
export const resolve: ResolveHookSync = function resolve (specifier: string, context: ResolveHookContext, nextResolve: NextResolveFnSync): ResolveFnOutput {
const nextResolveWithShortCircuitFalse = (specifier: string, context: ResolveHookContext): ResolveFnOutput => {
const resolved = nextResolve(specifier, context);
return { ...resolved, shortCircuit: false };
};

Expand All @@ -44,14 +46,14 @@ export async function resolve (specifier: string, context: ResolveHookContext, n
return nextResolveWithShortCircuitFalse(specifier, context);
}

const { url: nextUrl, format } = await nextResolveWithShortCircuitFalse(specifier, context);
const { url: nextUrl, format } = nextResolveWithShortCircuitFalse(specifier, context);
const url = nextUrl ?? new URL(specifier, context.parentURL).href;
assert(url !== null, 'url should not be null');
assert.equal(typeof url, 'string', 'url should be a string');

// url ends with .mjs or .mts => module
// url ends with .js or .ts => detect format from package.json
const isModule = ext.startsWith('.m') || (await getPackageType(url)) === 'module';
const isModule = ext.startsWith('.m') || getPackageType(url) === 'module';
if (!isModule) {
return nextResolveWithShortCircuitFalse(specifier, context);
}
Expand All @@ -67,19 +69,21 @@ export async function resolve (specifier: string, context: ResolveHookContext, n
url
};
return resolved;
}
};

/**
* The `load` hook provides a way to define a custom method of determining how a URL should be interpreted, retrieved, and parsed.
* It is also in charge of validating the import assertion.
* The `load` hook provides a way to define a custom method for retrieving the source code of a resolved URL.
* This would allow a loader to potentially avoid reading files from disk.
* It could also be used to map an unrecognized format to a supported one, for example yaml to module.
*
* @param url The URL/path of the module to be loaded
* @param context Metadata about the module
* @param nextLoad The subsequent `load` hook in the chain, or the Node.js default `load` hook after the last user-supplied `load` hook
* @param url The URL returned by the resolve chain
* @param context Metadata about the module being loaded, including the format and import attributes
* @param nextLoad The subsequent load hook in the chain, or the Node.js default load hook after the last user-supplied load hook
* @returns The result of the load, including the source code and format
*/
export async function load (url: string, context: LoadHookContext, nextLoad: NextLoadFn): Promise<LoadFnOutput> {
const nextLoadWithShortCircuitFalse = async (url: string, context: LoadHookContext): Promise<LoadFnOutput> => {
const loaded = await nextLoad(url, context);
export const load: LoadHookSync = function load (url: string, context: LoadHookContext, nextLoad: NextLoadFnSync): LoadFnOutput {
const nextLoadWithShortCircuitFalse = (url: string, context: LoadHookContext): LoadFnOutput => {
const loaded = nextLoad(url, context);
return { ...loaded, shortCircuit: false };
};
const { importAttributes, format } = context;
Expand All @@ -88,22 +92,22 @@ export async function load (url: string, context: LoadHookContext, nextLoad: Nex
}
delete importAttributes.powerAssert;

const { source: rawSource } = await nextLoadWithShortCircuitFalse(url, context);
const { source: rawSource } = nextLoadWithShortCircuitFalse(url, context);
assert(rawSource !== undefined, 'rawSource should not be undefined');
let incomingCode = rawSource.toString();
if (format === 'module-typescript') {
incomingCode = stripTypeScriptTypes(incomingCode);
}
const transpiled = await transpileWithInlineSourceMap(incomingCode, { file: url });
const transpiled = transpileWithInlineSourceMapSync(incomingCode, { file: url });
return {
format,
shortCircuit: false,
source: transpiled.code
};
}
};

// start borrowing from https://nodejs.org/api/module.html#transpilation
async function getPackageType (url: string): Promise<string | false> {
function getPackageType (url: string): string | undefined {
// `url` is only a file path during the first iteration when passed the
// resolved url from the load() hook
// an actual file path from load() will contain a file extension as it's
Expand All @@ -112,11 +116,14 @@ async function getPackageType (url: string): Promise<string | false> {
// work for most projects but does not cover some edge-cases (such as
// extensionless files or a url ending in a trailing space)
const pJson = findPackageJSON(url);
assert(pJson !== undefined, 'cannot find package.json');

return readFile(pJson, 'utf8')
.then(JSON.parse)
.then((json) => json?.type)
.catch(() => undefined);
if (!pJson) {
return undefined;
}
try {
const file = readFileSync(pJson, 'utf-8');
return JSON.parse(file)?.type;
} catch {
return undefined;
}
}
// end borrowing from https://nodejs.org/api/module.html#transpilation
12 changes: 9 additions & 3 deletions packages/node/src/register.mts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import { register } from 'node:module';
// use package self-reference to use conditional exports with type-stripping
register('@power-assert/node/hooks', import.meta.url);
import { registerHooks } from 'node:module';
import { resolve, load } from './hooks.mts';
import type { RegisterHooksOptions } from 'node:module';

const hooksOptions: RegisterHooksOptions = {
resolve,
load
};
registerHooks(hooksOptions);
Loading