Skip to content

Commit

Permalink
feat: add hardcoded boundaries options
Browse files Browse the repository at this point in the history
  • Loading branch information
Julien-R44 committed Apr 4, 2024
1 parent bb4062e commit 9f6331c
Show file tree
Hide file tree
Showing 7 changed files with 82 additions and 27 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/hot_hook/src/dependency_tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,17 @@ export default class DependencyTree {
#tree!: FileNode
#pathMap: Map<string, FileNode> = 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)
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/hot_hook/src/hot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})

Expand Down
26 changes: 13 additions & 13 deletions packages/hot_hook/src/loader.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
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'
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,
})
}

/**
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)

Expand Down
16 changes: 8 additions & 8 deletions packages/hot_hook/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import picomatch from 'picomatch'
import { MessagePort } from 'node:worker_threads'

export type MessageChannelMessage =
| { type: 'hot-hook:full-reload'; path: string }
| { 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
Expand All @@ -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<InitOptions, 'ignore' | 'root' | 'reload'> & {
export type InitializeHookOptions = Pick<InitOptions, 'ignore' | 'root' | 'boundaries'> & {
/**
* The message port to communicate with the parent thread.
*/
Expand Down
3 changes: 1 addition & 2 deletions packages/hot_hook/tests/dependency_tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
41 changes: 41 additions & 0 deletions packages/hot_hook/tests/loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})

0 comments on commit 9f6331c

Please sign in to comment.