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
24 changes: 24 additions & 0 deletions .changeset/pretty-cycles-show.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'hot-hook': patch
---

## Global watching and file-changed events

Add support for "global watching" and "file-changed" events

Before this commit, Hot-hook only watched files after they were imported at runtime. Now, it directly watches all project files and sends a "file-changed" event whenever a file is modified, deleted, or added. This allows "runners" to react in various ways to file changes based on their needs.

The option to configure which files to watch is `include`, and by default is set to `["**/*"]`, meaning all files from the `rootDirectory`.

The behavior will be as follows:
- If the file has not yet been imported at runtime, and therefore is not part of the import graph, and it is modified, then a `hot-hook:file-changed` event will be emitted with the payload `{ path: string, action: "change" | "add" | "unlink"}`
- If the file has already been imported at runtime, and it is hot reloadable, then a `hot-hook:invalidated` event will be emitted with the payload `{ path: string }`. As before.
- If the file has already been imported at runtime and it is not hot reloadable, then `hot-hook:full-reload` will be emitted with the payload `{ path: string }`. As before.

## Environment variables configuration

You can now configure certain environment variables for hot-hook, which are:
- `HOT_HOOK_IGNORE`: for the `ignore` option. Example: `HOT_HOOK_IGNORE=**/node_modules/**,**/dist/**`
- `HOT_HOOK_INCLUDE`: for the `include` option. Example: `HOT_HOOK_INCLUDE=**/*`
- `HOT_HOOK_BOUNDARIES`: for the `boundaries` option. Example: `HOT_HOOK_BOUNDARIES=./controllers/**/*.ts`
- `HOT_HOOK_RESTART`: for the `restart` option. Example: `HOT_HOOK_RESTART=.env,./config/**/*.ts`
2 changes: 1 addition & 1 deletion examples/hono/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev:tsnode": "hot-runner --node-args=--import=./tsnode.esm.js --node-args=--import=hot-hook/register src/index.tsx",
"dev:tsx": "hot-runner --node-args=--import=tsx --node-args=--import=hot-hook/register src/index.tsx"
"dev:tsx": "hot-runner --node-args=--import=tsx --node-args=--import=hot-hook/register --no-clear-screen src/index.tsx"
},
"dependencies": {
"@hono/node-server": "^1.14.3",
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"keywords": [],
"scripts": {
"typecheck": "pnpm run -r --parallel typecheck",
"lint": "eslint . --ext=.ts",
"lint": "eslint .",
"test": "pnpm run -r --parallel test",
"build": "pnpm run -r build",
"release": "pnpm run build && changeset publish"
Expand Down Expand Up @@ -37,6 +37,7 @@
"prettier": "^3.5.3",
"supertest": "^7.1.1",
"ts-node": "^10.9.2",
"tsdown": "^0.12.5",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
},
Expand Down
7 changes: 7 additions & 0 deletions packages/hot_hook/src/dependency_tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ export default class DependencyTree {
this.#pathMap.set(this.#tree.path, this.#tree)
}

/**
* Check if a path is inside the dependency tree
*/
isInside(path: string): boolean {
return this.#pathMap.has(path)
}

/**
* Get the version of a file
*/
Expand Down
18 changes: 16 additions & 2 deletions packages/hot_hook/src/hot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,36 @@ class Hot {
this.#options.onFullReloadAsked?.()
return
}

process.send?.({ type: 'hot-hook:invalidated', paths: message.paths })

for (const url of message.paths) {
const callback = this.#disposeCallbacks.get(url)
callback?.()
}
}

if (message.type === 'hot-hook:file-changed') {
process.send?.(message)
}
}

/**
* Register the hot reload hooks
*/
async init(options: InitOptions) {
const envIgnore = process.env.HOT_HOOK_IGNORE?.split(',').map((p) => p.trim())
const envRestart = process.env.HOT_HOOK_RESTART?.split(',').map((p) => p.trim())
const envBoundaries = process.env.HOT_HOOK_BOUNDARIES?.split(',').map((p) => p.trim())
const envInclude = process.env.HOT_HOOK_INCLUDE?.split(',').map((p) => p.trim())

this.#options = Object.assign(
{
ignore: [
include: envInclude || ['**/*'],
boundaries: envBoundaries || [],
restart: envRestart || ['.env'],
throwWhenBoundariesAreNotDynamicallyImported: false,
ignore: envIgnore || [
'**/node_modules/**',
/**
* Vite has a bug where it create multiple files with a
Expand All @@ -59,7 +73,6 @@ class Hot {
'**/vite.config.js.timestamp*',
'**/vite.config.ts.timestamp*',
],
restart: ['.env'],
},
options,
)
Expand All @@ -79,6 +92,7 @@ class Hot {
data: {
root: this.#options.root,
ignore: this.#options.ignore,
include: this.#options.include,
restart: this.#options.restart,
boundaries: this.#options.boundaries,
messagePort: this.#messageChannel.port2,
Expand Down
95 changes: 69 additions & 26 deletions packages/hot_hook/src/loader.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { fileURLToPath } from 'node:url'
import chokidar, { type FSWatcher } from 'chokidar'
import { access, realpath } from 'node:fs/promises'
import chokidar, { type FSWatcher } from 'chokidar'
import type { MessagePort } from 'node:worker_threads'
import { resolve as pathResolve, dirname } from 'node:path'
import type { InitializeHook, LoadHook, ResolveHook } from 'node:module'

import debug from './debug.js'
import { Matcher } from './matcher.js'
import DependencyTree from './dependency_tree.js'
import type { InitializeHookOptions } from './types.js'
import { DynamicImportChecker } from './dynamic_import_checker.js'
import { FileNotImportedDynamicallyException } from './errors/file_not_imported_dynamically_exception.js'
import type {
FileChangeAction,
InitializeHookOptions,
MessageChannelMessage,
MessageChannelPerType,
} from './types.js'

export class HotHookLoader {
#options: InitializeHookOptions
Expand All @@ -19,6 +24,7 @@ export class HotHookLoader {
#messagePort?: MessagePort
#watcher!: FSWatcher
#pathIgnoredMatcher!: Matcher
#pathIncludedMatcher!: Matcher
#dependencyTree: DependencyTree
#hardcodedBoundaryMatcher!: Matcher
#dynamicImportChecker!: DynamicImportChecker
Expand All @@ -39,11 +45,13 @@ export class HotHookLoader {
* Initialize the class with the provided root path.
*/
#initialize(root: string) {
this.#watcher = this.#createWatcher().add([root, ...(this.#options.restart || [])])
this.#projectRoot = this.#projectRoot ?? dirname(root)
this.#reloadMatcher = new Matcher(this.#projectRoot, this.#options.restart || [])
this.#pathIgnoredMatcher = new Matcher(this.#projectRoot, this.#options.ignore)
this.#pathIncludedMatcher = new Matcher(this.#projectRoot, this.#options.include || [])
this.#hardcodedBoundaryMatcher = new Matcher(this.#projectRoot, this.#options.boundaries)

this.#watcher = this.#createWatcher()
}

/**
Expand All @@ -58,33 +66,46 @@ export class HotHookLoader {
}
}

#postMessage<T extends MessageChannelMessage['type']>(type: T, data: MessageChannelPerType[T]) {
this.#messagePort?.postMessage({ type, ...data })
}

/**
* When a message is received from the main thread
*/
#onMessage(message: any) {
if (message.type !== 'hot-hook:dump') return

const dump = this.#dependencyTree.dump()
this.#messagePort?.postMessage({ type: 'hot-hook:dump', dump })
this.#messagePort?.postMessage({ type: 'hot-hook:dump', dump: this.#dependencyTree.dump() })
}

/**
* When a file changes, invalidate it and its dependents.
*/
async #onFileChange(relativeFilePath: string) {
debug('File change %s', relativeFilePath)

async #onFileChange(relativeFilePath: string, action: FileChangeAction) {
debug('File change %s', { relativeFilePath, action })
const filePath = pathResolve(relativeFilePath)
const realFilePath = await realpath(filePath)

/**
* First check if file still exists. If not, we must remove it from the
* dependency tree
* If the file is removed, we must remove it from the dependency tree
* and stop watching it.
*/
const isFileExist = await this.#checkIfFileExists(realFilePath)
if (!isFileExist) {
debug('File does not exist anymore %s', realFilePath)
return this.#dependencyTree.remove(realFilePath)
if (action === 'unlink') {
debug('File removed %s', filePath)
this.#watcher.unwatch(filePath)
this.#postMessage('hot-hook:file-changed', { path: filePath, action: 'unlink' })

return this.#dependencyTree.remove(filePath)
}

/**
* Defensive check to ensure the file still exists.
* If it doesn't, we just return and do nothing.
*/
const fileExists = await this.#checkIfFileExists(filePath)
if (!fileExists) {
debug('File does not exist anymore %s', filePath)
this.#watcher.unwatch(filePath)
return this.#dependencyTree.remove(filePath)
}

/**
Expand All @@ -96,9 +117,19 @@ export class HotHookLoader {
/**
* If the file is an hardcoded reload file, we trigger a full reload.
*/
const realFilePath = await realpath(filePath)
if (this.#reloadMatcher.match(realFilePath)) {
debug('Full reload (hardcoded `restart` file) %s', realFilePath)
return this.#messagePort?.postMessage({ type: 'hot-hook:full-reload', path: realFilePath })
return this.#postMessage('hot-hook:full-reload', { path: realFilePath })
}

/**
* Check if the file exist in the dependency tree. If not, means it was still
* not loaded, so we just send a "file-changed" message
*/
if (!this.#dependencyTree.isInside(realFilePath)) {
debug('File not in dependency tree, sending file-changed message %s', realFilePath)
return this.#postMessage('hot-hook:file-changed', { path: realFilePath, action })
}

/**
Expand All @@ -108,29 +139,40 @@ export class HotHookLoader {
const { reloadable, shouldBeReloadable } = this.#dependencyTree.isReloadable(realFilePath)
if (!reloadable) {
debug('Full reload (not-reloadable file) %s', realFilePath)
return this.#messagePort?.postMessage({
type: 'hot-hook:full-reload',
path: realFilePath,
shouldBeReloadable,
})
return this.#postMessage('hot-hook:full-reload', { path: realFilePath, shouldBeReloadable })
}

/**
* Otherwise, we invalidate the file and its dependents
*/
const invalidatedFiles = this.#dependencyTree.invalidateFileAndDependents(realFilePath)
debug('Invalidating %s', Array.from(invalidatedFiles).join(', '))
this.#messagePort?.postMessage({ type: 'hot-hook:invalidated', paths: [...invalidatedFiles] })
this.#postMessage('hot-hook:invalidated', { paths: [...invalidatedFiles] })
}

/**
* Create the chokidar watcher instance.
*/
#createWatcher() {
const watcher = chokidar.watch([])
const watcher = chokidar.watch('.', {
ignoreInitial: true,
cwd: this.#projectRoot,
ignored: (file, stats) => {
if (file === this.#projectRoot) return false
if (!stats) return false

if (this.#pathIgnoredMatcher.match(file)) return true
if (this.#reloadMatcher.match(file)) return false

if (stats.isDirectory()) return false

watcher.on('change', this.#onFileChange.bind(this))
watcher.on('unlink', this.#onFileChange.bind(this))
return !this.#pathIncludedMatcher.match(file)
},
})

watcher.on('change', (path) => this.#onFileChange(path, 'change'))
watcher.on('unlink', (path) => this.#onFileChange(path, 'unlink'))
watcher.on('add', (path) => this.#onFileChange(path, 'add'))

return watcher
}
Expand Down Expand Up @@ -215,6 +257,7 @@ export class HotHookLoader {
this.#initialize(resultPath)
return result
}

/**
* Sometimes we receive a parentUrl that is just `data:`. I didn't really understand
* why yet, for now we just ignore these cases.
Expand Down
6 changes: 1 addition & 5 deletions packages/hot_hook/src/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,13 @@ import { readPackageUp } from 'read-package-up'
import { hot } from './hot.js'

const pkgJson = await readPackageUp()
if (!pkgJson) {
throw new Error('Could not find package.json')
}
if (!pkgJson) throw new Error('Could not find package.json')

const { packageJson, path: packageJsonPath } = pkgJson
const hotHookConfig = packageJson.hotHook

await hot.init({
...(hotHookConfig || {}),
rootDirectory: dirname(packageJsonPath),
throwWhenBoundariesAreNotDynamicallyImported:
hotHookConfig?.throwWhenBoundariesAreNotDynamicallyImported ?? false,
root: hotHookConfig?.root ? resolve(packageJsonPath, hotHookConfig.root) : undefined,
})
32 changes: 24 additions & 8 deletions packages/hot_hook/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import type { MessagePort } from 'node:worker_threads'

export type FileChangeAction = 'change' | 'add' | 'unlink'
export type MessageChannelMessage =
| { type: 'hot-hook:full-reload'; path: string; shouldBeReloadable?: boolean }
| { type: 'hot-hook:invalidated'; paths: string[] }
| { type: 'hot-hook:file-changed'; path: string; action: FileChangeAction }

export type MessageChannelPerType = {
[K in MessageChannelMessage['type']]: Omit<Extract<MessageChannelMessage, { type: K }>, 'type'>
}

type PathOrGlobPattern = string

export interface InitOptions {
/**
Expand All @@ -12,12 +20,6 @@ export interface InitOptions {
*/
onFullReloadAsked?: () => void

/**
* Paths that will not be watched by the hook.
* @default ['/node_modules/']
*/
ignore?: string[]

/**
* Path to the root file of the application.
*/
Expand All @@ -29,11 +31,24 @@ export interface InitOptions {
*/
rootDirectory?: string

/**
* Paths/glob patterns that will be watched by the hook.
*
* @default ['**\/*']
*/
include?: PathOrGlobPattern[]

/**
* Paths/glob patterns that will not be watched by the hook.
* @default ['/node_modules/']
*/
ignore?: PathOrGlobPattern[]

/**
* Files that will create an HMR boundary. This is equivalent of importing
* the module with `import.meta.hot.boundary` in the module.
*/
boundaries?: string[]
boundaries?: PathOrGlobPattern[]

/**
* List of files that should trigger a full reload when change.
Expand All @@ -47,7 +62,7 @@ export interface InitOptions {
*
* @default ['.env']
*/
restart?: string[]
restart?: PathOrGlobPattern[]

/**
* If true, the hook will throw an error if a boundary is not dynamically
Expand All @@ -62,6 +77,7 @@ export type InitializeHookOptions = Pick<
| 'root'
| 'rootDirectory'
| 'boundaries'
| 'include'
| 'restart'
| 'throwWhenBoundariesAreNotDynamicallyImported'
> & {
Expand Down
Loading
Loading