diff --git a/README.md b/README.md index 63c0956..7691f4a 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ In the case of an HTTP server, you would typically dynamic import your controlle Also note that you must use `import.meta.hot?.boundary` when importing the module. This is a special [import](https://nodejs.org/api/esm.html#import-attributes) attributes that allows to create what we call an [HMR boundary](#boundary). +> [!TIP] +> If using `import.meta.hot?.boundary` is not of your taste, you can also hardcode the list of files that you want to be hot reloadable in the `hot.init` options using glob patterns in the `boundaries` option. + Example : ```ts @@ -88,6 +91,18 @@ await hot.init({ An array of glob patterns that specifies which files should not be considered by Hot Hook. That means they won't be reloaded when modified. By default, it's `['node_modules/**']`. +### `boundaries` + +An array of glob patterns that specifies which files should be considered as HMR boundaries. This is useful when you don't want to use `import.meta.hot?.boundary` in your code. + +```ts +await hot.init({ + boundaries: [ + './app/**/controllers/*.ts' + ] +}) +``` + ## API ### import.meta.hot diff --git a/packages/hot_hook/src/dependency_tree.ts b/packages/hot_hook/src/dependency_tree.ts index e32a0a1..f14d59d 100644 --- a/packages/hot_hook/src/dependency_tree.ts +++ b/packages/hot_hook/src/dependency_tree.ts @@ -37,17 +37,17 @@ export default class DependencyTree { #tree!: FileNode #pathMap: Map = new Map() - add(path: string): void { + constructor(options: { root: string }) { this.#tree = { - path, version: 0, parents: null, reloadable: false, + path: options.root, dependents: new Set(), dependencies: new Set(), } - this.#pathMap.set(path, this.#tree) + this.#pathMap.set(this.#tree.path, this.#tree) } /** diff --git a/packages/hot_hook/src/hot.ts b/packages/hot_hook/src/hot.ts index c0247ce..2a1c486 100644 --- a/packages/hot_hook/src/hot.ts +++ b/packages/hot_hook/src/hot.ts @@ -57,7 +57,7 @@ class Hot { messagePort: port2, root: this.#options.root, ignore: this.#options.ignore, - reload: this.#options.reload, + boundaries: this.#options.boundaries, } satisfies InitializeHookOptions, }) diff --git a/packages/hot_hook/src/loader.ts b/packages/hot_hook/src/loader.ts index 070a31c..5b321b0 100644 --- a/packages/hot_hook/src/loader.ts +++ b/packages/hot_hook/src/loader.ts @@ -1,6 +1,4 @@ -import fg from 'fast-glob' import chokidar from 'chokidar' -import picomatch from 'picomatch' import { realpath } from 'node:fs/promises' import { MessagePort } from 'node:worker_threads' import { fileURLToPath } from 'node:url' @@ -8,26 +6,29 @@ 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 { InitializeHookOptions } from './types.js' -import { Matcher } from './matcher.js' export class HotHookLoader { #projectRoot: string #messagePort?: MessagePort #watcher: chokidar.FSWatcher #pathIgnoredMatcher: Matcher - #dependencyTree = new DependencyTree() + #dependencyTree: DependencyTree + #hardcodedBoundaryMatcher: Matcher constructor(options: InitializeHookOptions) { this.#projectRoot = dirname(options.root) this.#messagePort = options.messagePort - this.#watcher = this.#createWatcher(options.reload) + this.#watcher = this.#createWatcher().add(options.root) this.#pathIgnoredMatcher = new Matcher(this.#projectRoot, options.ignore) + this.#hardcodedBoundaryMatcher = new Matcher(this.#projectRoot, options.boundaries) - this.#watcher.add(options.root) - this.#dependencyTree.add(options.root) + this.#dependencyTree = new DependencyTree({ + root: options.root, + }) } /** @@ -57,11 +58,8 @@ export class HotHookLoader { /** * Create the chokidar watcher instance. */ - #createWatcher(initialPaths: picomatch.Glob = []) { - const arrayPaths = Array.isArray(initialPaths) ? initialPaths : [initialPaths] - const entries = fg.sync(arrayPaths, { cwd: this.#projectRoot, absolute: true }) - - const watcher = chokidar.watch(entries) + #createWatcher() { + const watcher = chokidar.watch([]) watcher.on('change', this.#onFileChange.bind(this)) watcher.on('unlink', (relativeFilePath) => { @@ -148,7 +146,9 @@ export class HotHookLoader { const resultPath = fileURLToPath(resultUrl) const parentPath = fileURLToPath(parentUrl) - const reloadable = context.importAttributes.hot === 'true' ? true : false + const isHardcodedBoundary = this.#hardcodedBoundaryMatcher.match(resultPath) + const reloadable = context.importAttributes.hot === 'true' ? true : isHardcodedBoundary + this.#dependencyTree.addDependency(parentPath, { path: resultPath, reloadable }) this.#dependencyTree.addDependent(resultPath, parentPath) diff --git a/packages/hot_hook/src/types.ts b/packages/hot_hook/src/types.ts index b82222b..1e1b713 100644 --- a/packages/hot_hook/src/types.ts +++ b/packages/hot_hook/src/types.ts @@ -1,4 +1,3 @@ -import picomatch from 'picomatch' import { MessagePort } from 'node:worker_threads' export type MessageChannelMessage = @@ -6,11 +5,6 @@ export type MessageChannelMessage = | { type: 'hot-hook:invalidated'; paths: string[] } export interface InitOptions { - /** - * An array of globs that will trigger a full server reload when changed. - */ - reload?: picomatch.Glob - /** * onFullReloadAsked is called when a full server reload is requested * by the hook. You should use this to kill the current process and @@ -22,15 +16,21 @@ export interface InitOptions { * Paths that will not be watched by the hook. * @default ['/node_modules/'] */ - ignore?: picomatch.Glob + ignore?: string[] /** * Path to the root file of the application. */ root: string + + /** + * Files that will create an HMR boundary. This is equivalent of importing + * the module with `import.meta.hot.boundary` in the module. + */ + boundaries?: string[] } -export type InitializeHookOptions = Pick & { +export type InitializeHookOptions = Pick & { /** * The message port to communicate with the parent thread. */ diff --git a/packages/hot_hook/tests/dependency_tree.spec.ts b/packages/hot_hook/tests/dependency_tree.spec.ts index 1681666..3046773 100644 --- a/packages/hot_hook/tests/dependency_tree.spec.ts +++ b/packages/hot_hook/tests/dependency_tree.spec.ts @@ -3,9 +3,8 @@ import DependencyTree from '../src/dependency_tree.js' test.group('Dependency tree', () => { test('basic scenario', ({ assert }) => { - const tree = new DependencyTree() + const tree = new DependencyTree({ root: 'app.ts' }) - tree.add('app.ts') tree.addDependency('app.ts', { path: 'start/index.ts' }) tree.addDependency('app.ts', { path: 'providers/database_provider.ts' }) tree.addDependency('start/index.ts', { diff --git a/packages/hot_hook/tests/loader.spec.ts b/packages/hot_hook/tests/loader.spec.ts index ba33cd2..374211b 100644 --- a/packages/hot_hook/tests/loader.spec.ts +++ b/packages/hot_hook/tests/loader.spec.ts @@ -228,4 +228,45 @@ test.group('Loader', () => { assert.isDefined(result) }) + + test('Can define hardcoded boundaries', async ({ fs }) => { + await fakeInstall(fs.basePath) + + await fs.createJson('package.json', { type: 'module' }) + await fs.create( + 'server.js', + `import * as http from 'http' + import { hot } from 'hot-hook' + import { join } from 'node:path' + + await hot.init({ + root: import.meta.filename, + boundaries: ['./app.js'] + }) + + const server = http.createServer(async (request, response) => { + const app = await import('./app.js') + await app.default(request, response) + }) + + server.listen(3333, () => { + console.log('Server is running') + })` + ) + + await createHandlerFile({ path: 'app.js', response: 'Hello World!' }) + + const server = runProcess('server.js', { cwd: fs.basePath, env: { NODE_DEBUG: 'hot-hook' } }) + await server.waitForOutput('Server is running') + + await supertest('http://localhost:3333').get('/').expect(200).expect('Hello World!') + + await setTimeout(100) + await createHandlerFile({ path: 'app.js', response: 'Hello World! Updated' }) + await supertest('http://localhost:3333').get('/').expect(200).expect('Hello World! Updated') + + await setTimeout(100) + await createHandlerFile({ path: 'app.js', response: 'Hello World! Updated new' }) + await supertest('http://localhost:3333').get('/').expect(200).expect('Hello World! Updated new') + }) })