perf(hmr): poll client bundles only

This commit is contained in:
imccyu
2026-08-26 15:09:48 +08:00
parent 8f88a6f207
commit 1429737a52
5 changed files with 25 additions and 58 deletions
+15 -25
View File
@@ -1,7 +1,7 @@
/**
* HMR plugin, node half: the host end of the dev reload chain. One interval
* stat-polls every graph row's client bundle and optional source map (polling
* by design: network mounts deliver no inotify events), reports changes through
* stat-polls every graph row's client bundle (polling by design: network mounts
* deliver no inotify events), reports changes through
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
* broadcasting graph/rebuilt frames to the browser half (src/client/).
* The web bundle mounts this row unconditionally: without a rebuild
@@ -42,30 +42,22 @@ function sseData(frame: PluginsEventFrame): string {
return `data: ${JSON.stringify(frame)}\n\n`
}
type WatchedArtifactStat = Omit<ClientArtifactBaseline, 'path'>
type WatchedBundleStat = Omit<ClientArtifactBaseline, 'path'>
type WatchedBundle = {
-readonly [K in keyof ClientArtifactBaseline]: ClientArtifactBaseline[K]
} & { dirty: boolean }
/** Snapshot the bundle plus its optional development source map. */
function artifactStat(path: string): WatchedArtifactStat {
/** Snapshot the executable bundle metadata that drives reloads. */
function bundleStat(path: string): WatchedBundleStat {
const bundle = statSync(path)
try {
const map = statSync(`${path}.map`)
return { mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: map.mtimeMs, mapSize: map.size }
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return { mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null }
}
return { mtimeMs: bundle.mtimeMs, size: bundle.size }
}
/** Whether neither served artifact changed since the last successful re-hash. */
function sameArtifactStat(left: WatchedArtifactStat, right: WatchedArtifactStat): boolean {
/** Whether the executable bundle is unchanged since the last successful re-hash. */
function sameBundleStat(left: WatchedBundleStat, right: WatchedBundleStat): boolean {
return left.mtimeMs === right.mtimeMs
&& left.size === right.size
&& left.mapMtimeMs === right.mapMtimeMs
&& left.mapSize === right.mapSize
}
/**
@@ -80,7 +72,7 @@ export function apply(ctx: Context, config: Config): void {
// --- bundle watch: one HMR-owned stat poll ------------------------------
const watched = new Map<string, WatchedBundle>()
const rehash = (id: string, watch: WatchedBundle, current: WatchedArtifactStat): void => {
const rehash = (id: string, watch: WatchedBundle, current: WatchedBundleStat): void => {
try {
// rebuilt() replaces the opaque startup rev on its first call; later
// calls stay silent when the content hash is unchanged.
@@ -95,17 +87,15 @@ export function apply(ctx: Context, config: Config): void {
}
watch.mtimeMs = current.mtimeMs
watch.size = current.size
watch.mapMtimeMs = current.mapMtimeMs
watch.mapSize = current.mapSize
watch.dirty = false
}
const watchRow = (id: string, baseline: ClientArtifactBaseline): void => {
const watch: WatchedBundle = { ...baseline, dirty: false }
watched.set(id, watch)
let current: WatchedArtifactStat
let current: WatchedBundleStat
try {
current = artifactStat(baseline.path)
current = bundleStat(baseline.path)
} catch (error) {
watch.dirty = true
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
@@ -113,20 +103,20 @@ export function apply(ctx: Context, config: Config): void {
}
// The module host captured its baseline before reading the bytes in the
// startup batch. Only a mismatch crosses into the content-hash path.
if (!sameArtifactStat(current, watch)) rehash(id, watch, current)
if (!sameBundleStat(current, watch)) rehash(id, watch, current)
}
const pollWatches = (): void => {
for (const [id, watch] of watched) {
let current: WatchedArtifactStat
let current: WatchedBundleStat
try {
current = artifactStat(watch.path)
current = bundleStat(watch.path)
} catch (error) {
watch.dirty = true
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
continue
}
if (!watch.dirty && sameArtifactStat(current, watch)) continue
if (!watch.dirty && sameBundleStat(current, watch)) continue
// Stat-before-hash preserves a detectable older baseline for writes that
// land during hashing. Repeated stat changes heal a torn read.
rehash(id, watch, current)
@@ -31,19 +31,7 @@ interface FakeHostOptions {
function artifactBaseline(path: string): ClientArtifactBaseline {
const bundle = statSync(path)
try {
const sourceMap = statSync(`${path}.map`)
return {
path,
mtimeMs: bundle.mtimeMs,
size: bundle.size,
mapMtimeMs: sourceMap.mtimeMs,
mapSize: sourceMap.size,
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return { path, mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null }
}
return { path, mtimeMs: bundle.mtimeMs, size: bundle.size }
}
function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
@@ -111,7 +99,7 @@ async function mount(clientModuleHost: FakeHost, webServer: WebServer) {
}
describe('hmr node half', () => {
it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => {
it('watches graph bundles, ignores map-only changes, and unwatches on dispose', async () => {
const bundle = join(dir, 'a.js')
writeFileSync(bundle, 'v1')
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
@@ -130,13 +118,17 @@ describe('hmr node half', () => {
clientModuleHost.rebuiltCalls.length = 0
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(`${bundle}.map`, '{"version":3}')
await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
expect(clientModuleHost.rebuiltCalls).toEqual([])
writeFileSync(bundle, 'v3-even-longer')
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
await fiber.dispose()
expect(routes).toHaveLength(0)
// Watcher gone: further file changes report nothing.
clientModuleHost.rebuiltCalls.length = 0
writeFileSync(bundle, 'v3-even-longer')
writeFileSync(bundle, 'v4-after-dispose')
await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
})
+2 -15
View File
@@ -23,7 +23,7 @@
*/
import { createHash, randomBytes } from 'node:crypto'
import { readFileSync, statSync, type Stats } from 'node:fs'
import { readFileSync, statSync } from 'node:fs'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
@@ -78,10 +78,6 @@ export interface ClientArtifactBaseline {
readonly mtimeMs: number
/** Bundle size in bytes. */
readonly size: number
/** Source-map modification time, or null when no map was observable. */
readonly mapMtimeMs: number | null
/** Source-map size in bytes, or null when no map was observable. */
readonly mapSize: number | null
}
/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
@@ -756,22 +752,13 @@ export class ClientModuleRegistry extends Service {
return meta
}
/** Capture the bundle and optional-map stats before reading their bytes. */
/** Capture the bundle stats before reading its bytes. */
private captureArtifactBaseline(clientPath: string): ClientArtifactBaseline {
const bundle = statSync(clientPath)
let sourceMap: Stats | undefined
try {
sourceMap = statSync(`${clientPath}.map`)
} catch {
// Optional map metadata only seeds HMR; the following map read reports
// malformed or inaccessible bytes and a later stat change self-heals.
}
return {
path: clientPath,
mtimeMs: bundle.mtimeMs,
size: bundle.size,
mapMtimeMs: sourceMap?.mtimeMs ?? null,
mapSize: sourceMap?.size ?? null,
}
}
@@ -378,8 +378,6 @@ describe('client bundle activation', () => {
path: firstPath,
mtimeMs: firstStat.mtimeMs,
size: firstStat.size,
mapMtimeMs: null,
mapSize: null,
})
expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
})
@@ -3434,7 +3434,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ClientArtifactBaseline',
declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n readonly mapMtimeMs: number | null;\n readonly mapSize: number | null;\n}',
declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n}',
},
{
name: 'CodeBindingErrorClass',