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
7 changes: 7 additions & 0 deletions .changeset/sync-module-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'hot-hook': minor
---

Use synchronous `module.registerHooks()` on Node.js >= 22.22.3, 24.11.1, 25.1.0 and 26 instead of the deprecated `module.register()` (DEP0205). Older Node.js versions fall back to `module.register()`, since their `registerHooks()` breaks CommonJS loading when combined with `module.register()` hooks such as ts-node's.

With in-thread hooks, `require()` calls also go through hot-hook.
3 changes: 2 additions & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [22, 24]
# 22.15 uses the `module.register()` fallback, see `canUseSyncHooks()`
node: ['22.15', 22, 24, 26]
runs-on: ${{ matrix.os }}
timeout-minutes: 10

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ await hot.init({
})
```

The `hot.init` function internally call [`register`](https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options) from `node:module` to hook into the module loading process. This is why you need to call it as early as possible, otherwise modules imported before the call to `hot.init` will not benefit from hot module replacement.
The `hot.init` function internally calls [`registerHooks`](https://nodejs.org/api/module.html#moduleregisterhooksoptions) from `node:module` to hook into the module loading process. On Node.js versions older than 22.22.3, 24.11.1 or 25.1.0, it falls back to [`register`](https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options). This is why you need to call it as early as possible, otherwise modules imported before the call to `hot.init` will not benefit from hot module replacement.

Next, you need to include the types for `import.meta.hot` in your project. To do this, add the following code in a `.d.ts` file or in the `types` property of your `tsconfig.json`.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.19.2",
"@types/supertest": "^6.0.3",
"c8": "^10.1.3",
"c8": "^12.0.0",
"del-cli": "^6.0.0",
"desm": "^1.3.1",
"eslint": "^9.39.2",
Expand Down
16 changes: 11 additions & 5 deletions packages/hot_hook/src/dynamic_import_checker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { readFile } from 'node:fs/promises'
import { parseImports } from 'parse-imports'
import { readFileSync } from 'node:fs'
import { parseImportsSync, wasmLoadPromise } from 'parse-imports'

/**
* `parseImportsSync` needs the lexer WASM to be loaded. The check runs inside
* module hooks, which are synchronous with `module.registerHooks()`.
*/
await wasmLoadPromise

/**
* This class is responsible for checking if a given specifier
Expand All @@ -13,14 +19,14 @@ import { parseImports } from 'parse-imports'
export class DynamicImportChecker {
private cache: Map<string, Map<string, boolean>> = new Map()

async ensureFileIsImportedDynamicallyFromParent(parentPath: string, specifier: string) {
ensureFileIsImportedDynamicallyFromParent(parentPath: string, specifier: string) {
const cacheKey = parentPath
if (this.cache.has(cacheKey) && this.cache.get(cacheKey)!.has(specifier)) {
return this.cache.get(cacheKey)!.get(specifier)!
}

const parentCode = await readFile(parentPath, 'utf-8')
const imports = [...(await parseImports(parentCode))]
const parentCode = readFileSync(parentPath, 'utf-8')
const imports = [...parseImportsSync(parentCode)]

const matchingImport = imports.find((importStatement) => {
return importStatement.moduleSpecifier.value === specifier
Expand Down
77 changes: 59 additions & 18 deletions packages/hot_hook/src/hot.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
import { register } from 'node:module'
import module from 'node:module'
import { MessageChannel } from 'node:worker_threads'
import type { LoadHookSync, ResolveHookSync } from 'node:module'

import debug from './debug.js'
import type { InitOptions, InitializeHookOptions, MessageChannelMessage } from './types.js'

/**
* Whether this Node.js version can run hot-hook with `module.registerHooks()`.
*
* `registerHooks()` exists since 22.15, but until 22.22.3, 24.11.1 and 25.1.0,
* a synchronous `load` hook fails on CommonJS modules when hooks from
* `module.register()` are also registered, like ts-node's or older tsx's.
*/
function canUseSyncHooks() {
const [major, minor, patch] = process.versions.node.split('.').map(Number)
if (major >= 26) return true

const minimum = ({ 22: [22, 3], 24: [11, 1], 25: [1, 0] } as Record<number, number[]>)[major]
if (!minimum) return false
return minor > minimum[0] || (minor === minimum[0] && patch >= minimum[1])
}

class Hot {
#options!: InitOptions
#messageChannel!: MessageChannel
Expand Down Expand Up @@ -72,26 +89,50 @@ class Hot {

/**
* First, we setup a message channel to be able to communicate
* between the hook and the application process since hooks
* are running in a worker thread
* between the hook and the application process, since with the
* `module.register()` fallback hooks are running in a worker thread
*/
this.#messageChannel = new MessageChannel()

register('hot-hook/loader', {
parentURL: import.meta.url,
transferList: [this.#messageChannel.port2],
data: {
root: this.#options.root,
watch: this.#options.watch,
ignore: this.#options.ignore,
restart: this.#options.restart,
boundaries: this.#options.boundaries,
messagePort: this.#messageChannel.port2,
rootDirectory: this.#options.rootDirectory,
throwWhenBoundariesAreNotDynamicallyImported:
this.#options.throwWhenBoundariesAreNotDynamicallyImported,
} satisfies InitializeHookOptions,
})
const data: InitializeHookOptions = {
root: this.#options.root,
watch: this.#options.watch,
ignore: this.#options.ignore,
restart: this.#options.restart,
boundaries: this.#options.boundaries,
messagePort: this.#messageChannel.port2,
rootDirectory: this.#options.rootDirectory,
throwWhenBoundariesAreNotDynamicallyImported:
this.#options.throwWhenBoundariesAreNotDynamicallyImported,
}

/**
* Prefer synchronous in-thread hooks since `module.register()` is
* deprecated (DEP0205). The loader then runs on this thread but keeps
* using the message channel, like the off-thread fallback.
*/
if (canUseSyncHooks()) {
const { HotHookLoader } = await import('./loader.js')
const loader = new HotHookLoader(data)

/**
* Safe casts: hooks return synchronously when `nextResolve`/`nextLoad`
* do, which is always the case with `registerHooks()`
*/
module.registerHooks({
resolve: loader.resolve as ResolveHookSync,
load: loader.load as LoadHookSync,
})

this.#messageChannel.port2.unref()
await loader.whenWatcherReady()
} else {
module.register('hot-hook/loader', {
parentURL: import.meta.url,
transferList: [this.#messageChannel.port2],
data,
})
}

this.#messageChannel.port1.on('message', this.#onMessage.bind(this))
this.#messageChannel.port1.unref()
Expand Down
Loading
Loading