Skip to content

Commit

Permalink
refactor: refactor loaders and move to picomatch
Browse files Browse the repository at this point in the history
  • Loading branch information
Julien-R44 committed Mar 31, 2024
1 parent b3f9c56 commit 1b4a4a2
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 111 deletions.
5 changes: 4 additions & 1 deletion packages/hot_hook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@
"version": "npm run build",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"@types/picomatch": "^2.3.3"
},
"dependencies": {
"chokidar": "^3.6.0",
"minimatch": "^9.0.4"
"picomatch": "^4.0.2"
},
"author": "Julien Ripouteau <[email protected]>",
"license": "ISC",
Expand Down
219 changes: 124 additions & 95 deletions packages/hot_hook/src/loader.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,95 @@
import path from 'node:path'
import { URL } from 'node:url'
import chokidar from 'chokidar'
import { minimatch } from 'minimatch'
import picomatch from 'picomatch'
import { realpath } from 'node:fs/promises'
import { relative, resolve as pathResolve } from 'node:path'
import { MessagePort } from 'node:worker_threads'
import type { InitializeHook, LoadHook, ResolveHook } from 'node:module'

import debug from './debug.js'
import DependencyTree from './dependency_tree.js'
import type { InitializeHookOptions } from './types.js'

const dependencyTree = new DependencyTree()

let options: InitializeHookOptions

/**
* Check if a path should be ignored and not watched.
*/
function isPathIgnored(filePath: string) {
const relativePath = path.relative(options.projectRoot, filePath)

const match = options.ignore?.some((pattern) => minimatch(relativePath, pattern, { dot: true }))
return match
}
import { InitializeHookOptions } from './types.js'

export class HotHookLoader {
#projectRoot: string
#messagePort?: MessagePort
#watcher: chokidar.FSWatcher
#dependencyTree = new DependencyTree()
#isReloadPathMatcher: picomatch.Matcher
#isPathIgnoredMatcher: picomatch.Matcher

constructor(options: InitializeHookOptions) {
this.#projectRoot = options.projectRoot
this.#messagePort = options.messagePort

this.#watcher = this.#createWatcher()
this.#isReloadPathMatcher = picomatch(options.reload || [])
this.#isPathIgnoredMatcher = picomatch(options.ignore || [])
}

/**
* Check if a path should trigger a full reload.
*/
function isReloadPath(filePath: string) {
const relativePath = path.relative(options.projectRoot, filePath)
#buildRelativePath(filePath: string) {
return relative(this.#projectRoot, filePath)
}

if (typeof options.reload === 'function') {
return options.reload(filePath)
/**
* Check if a path should be ignored and not watched.
*/
#isPathIgnored(filePath: string) {
return this.#isPathIgnoredMatcher(this.#buildRelativePath(filePath))
}

return options.reload?.some((pattern) => minimatch(relativePath, pattern, { dot: true }))
}
/**
* Check if a path should trigger a full reload.
*/
#isReloadPath(filePath: string) {
return this.#isReloadPathMatcher(this.#buildRelativePath(filePath))
}

const watcher = chokidar
.watch([])
.on('change', async (relativeFilePath) => {
const filePath = path.resolve(relativeFilePath)
/**
* When a file changes, invalidate it and its dependents.
*/
async #onFileChange(relativeFilePath: string) {
const filePath = pathResolve(relativeFilePath)
const realFilePath = await realpath(filePath)

/**
* If the file is in the reload list, we send a full reload message
* to the main thread.
*/
debug('Changed %s', realFilePath)
if (isReloadPath(realFilePath)) {
options.messagePort?.postMessage({ type: 'hot-hook:full-reload', path: realFilePath })
return
if (this.#isReloadPath(realFilePath)) {
return this.#messagePort?.postMessage({ type: 'hot-hook:full-reload', path: realFilePath })
}

const invalidatedFiles = dependencyTree.invalidateFileAndDependents(realFilePath)
/**
* Otherwise, we invalidate the file and its dependents
*/
const invalidatedFiles = this.#dependencyTree.invalidateFileAndDependents(realFilePath)
debug('Invalidating %s', Array.from(invalidatedFiles).join(', '))
options.messagePort?.postMessage({
type: 'hot-hook:invalidated',
paths: Array.from(invalidatedFiles),
})
})
.on('unlink', (relativeFilePath) => {
const filePath = path.resolve(relativeFilePath)
debug('Deleted %s', filePath)
dependencyTree.remove(filePath)
})

/**
* Load hook
*/
export const load: LoadHook = async (url, context, nextLoad) => {
const parsedUrl = new URL(url)
if (parsedUrl.searchParams.has('hot-hook')) {
parsedUrl.searchParams.delete('hot-hook')
url = parsedUrl.href
this.#messagePort?.postMessage({ type: 'hot-hook:invalidated', paths: [...invalidatedFiles] })
}

if (parsedUrl.protocol === 'file:') {
debug('Importing %s', parsedUrl.pathname)
}
/**
* Create the chokidar watcher instance.
*/
#createWatcher() {
const watcher = chokidar.watch([])

watcher.on('change', this.#onFileChange.bind(this))
watcher.on('unlink', (relativeFilePath) => {
const filePath = pathResolve(relativeFilePath)
debug('Deleted %s', filePath)
this.#dependencyTree.remove(filePath)
})

const result = await nextLoad(url, context)
return watcher
}

if (result.format === 'module') {
/**
* Returns the code source for the import.meta.hot object.
* We need to add this to every module since `import.meta.hot` is
* scoped to each module.
*/
#getImportMetaHotSource() {
let hotFns = `
import.meta.hot = {};
import.meta.hot.dispose = async (callback) => {
Expand All @@ -88,59 +100,76 @@ export const load: LoadHook = async (url, context, nextLoad) => {
import.meta.hot.decline = async () => {
const { hot } = await import('hot-hook');
hot.decline(import.meta.url);
};
`
};`

/**
* By minifying the hot functions we can avoid adding a new line to the source
* By minifying the code we can avoid adding a new line to the source
* and so we can avoid totally breaking the source maps.
*
* This simple trick seems to do the job for now, but we should probably
* find a better way to handle this in the future.
*/
const minified = hotFns.replace(/\n/g, '').replace(/\s{2,}/g, ' ')
result.source = minified + result.source
return hotFns.replace(/\n/g, '').replace(/\s{2,}/g, ' ')
}

return result
}

/**
* Resolve hook
*/
export const resolve: ResolveHook = async (specifier, context, nextResolve) => {
const parentUrl = (context.parentURL && new URL(context.parentURL)) as URL
if (parentUrl?.searchParams.has('hot-hook')) {
parentUrl.searchParams.delete('hot-hook')
context = { ...context, parentURL: parentUrl.href }
}
/**
* The load hook.
* We use it mainly for adding the import.meta.hot object to the module.
*/
load: LoadHook = async (url, context, nextLoad) => {
const parsedUrl = new URL(url)
if (parsedUrl.searchParams.has('hot-hook')) {
parsedUrl.searchParams.delete('hot-hook')
url = parsedUrl.href
}

const result = await nextResolve(specifier, context)
const result = await nextLoad(url, context)
if (result.format !== 'module') return result

const resultUrl = new URL(result.url)
const resultPath = resultUrl.pathname
if (resultUrl.protocol !== 'file:' || isPathIgnored(resultPath)) {
result.source = this.#getImportMetaHotSource() + result.source
return result
}

if (!dependencyTree.has(resultPath)) {
debug('Watching %s', resultPath)
dependencyTree.add(resultPath)
watcher.add(resultPath)
}
/**
* The resolve hook
* We use it for :
* - Adding the hot-hook query parameter to the URL ( to getting a fresh version )
* - And adding files to the watcher
*/
resolve: ResolveHook = async (specifier, context, nextResolve) => {
const parentUrl = (context.parentURL && new URL(context.parentURL)) as URL
if (parentUrl?.searchParams.has('hot-hook')) {
parentUrl.searchParams.delete('hot-hook')
context = { ...context, parentURL: parentUrl.href }
}

const parentPath = parentUrl?.pathname
if (parentPath) {
dependencyTree.addDependent(resultPath, parentPath)
}
const result = await nextResolve(specifier, context)

const resultUrl = new URL(result.url)
const resultPath = resultUrl.pathname
if (resultUrl.protocol !== 'file:' || this.#isPathIgnored(resultPath)) {
return result
}

if (!this.#dependencyTree.has(resultPath)) {
debug('Watching %s', resultPath)
this.#dependencyTree.add(resultPath)
this.#watcher.add(resultPath)
}

const parentPath = parentUrl?.pathname
if (parentPath) {
this.#dependencyTree.addDependent(resultPath, parentPath)
}

resultUrl.searchParams.set('hot-hook', dependencyTree.getVersion(resultPath)!.toString())
return { ...result, url: resultUrl.href }
resultUrl.searchParams.set('hot-hook', this.#dependencyTree.getVersion(resultPath)!.toString())
return { ...result, url: resultUrl.href }
}
}

/**
* Initialize hook
*/
let loader!: HotHookLoader
export const initialize: InitializeHook = (data: InitializeHookOptions) => {
options = data
loader = new HotHookLoader(data)
}
export const load: LoadHook = (...args) => loader?.load(...args)
export const resolve: ResolveHook = (...args) => loader?.resolve(...args)
8 changes: 3 additions & 5 deletions packages/hot_hook/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import picomatch from 'picomatch'
import { MessagePort } from 'node:worker_threads'

export type MessageChannelMessage =
Expand All @@ -7,11 +8,8 @@ export type MessageChannelMessage =
export interface InitOptions {
/**
* An array of globs that will trigger a full server reload when changed.
*
* You can also pass a function that will receive the changed file path
* and return a boolean to decide if the server should reload or not.
*/
reload?: string[] | ((path: string) => boolean)
reload?: picomatch.Glob

/**
* onFullReloadAsked is called when a full server reload is requested
Expand All @@ -24,7 +22,7 @@ export interface InitOptions {
* Paths that will not be watched by the hook.
* @default ['/node_modules/']
*/
ignore?: string[]
ignore?: picomatch.Glob

/**
* The project root directory.
Expand Down
28 changes: 18 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 1b4a4a2

Please sign in to comment.