Merge origin/master into codex/trim-redundant-comments
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
|
||||
|
||||
The plugin subscribes to the webserver's system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. Rebuild detection lives on the webserver: in dev mode it stat-polls each plugin's built `lib/client.js` (`fs.watchFile`) and broadcasts the `rebuilt` frame when the bundle's rev changes, so any tsdown watch process producing the bundle triggers HMR with no builder→host channel.
|
||||
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
/**
|
||||
* HMR plugin, node half: the host end of the dev reload chain. Stat-polls
|
||||
* every graph row's client bundle (fs.watchFile — polling by design: network
|
||||
* HMR plugin, node half: the host end of the dev reload chain. One interval
|
||||
* stat-polls every graph row's client bundle (polling by design: network
|
||||
* mounts deliver no inotify events), reports content changes through
|
||||
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
|
||||
* broadcasting graph/rebuilt frames to the browser half (src/client/).
|
||||
* Dev-only row: prod compositions never mount this plugin.
|
||||
*/
|
||||
import type { Stats } from 'node:fs'
|
||||
import { unwatchFile, watchFile } from 'node:fs'
|
||||
import { statSync } from 'node:fs'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
@@ -41,6 +40,13 @@ function sseData(frame: PluginsEventFrame): string {
|
||||
return `data: ${JSON.stringify(frame)}\n\n`
|
||||
}
|
||||
|
||||
interface WatchedBundle {
|
||||
path: string
|
||||
mtimeMs: number
|
||||
size: number
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel.
|
||||
* @param ctx - host plugin context carrying clientModuleHost and httpServer.
|
||||
@@ -50,29 +56,59 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery's .default() guarantees the field is set after validation.
|
||||
const pollIntervalMs = config.pollIntervalMs as number
|
||||
|
||||
// --- bundle watch: one fs.watchFile stat poll per graph row -------------
|
||||
const watched = new Map<string, { path: string; listener: (curr: Stats, prev: Stats) => void }>()
|
||||
// --- bundle watch: one HMR-owned stat poll ------------------------------
|
||||
const watched = new Map<string, WatchedBundle>()
|
||||
|
||||
const rehash = (id: string, watch: WatchedBundle, current: { mtimeMs: number; size: number }): void => {
|
||||
try {
|
||||
// rebuilt() re-hashes; an unchanged hash stays silent (clientModuleHost
|
||||
// fires onRebuilt only on a real rev change).
|
||||
ctx.clientModuleHost.rebuilt(id)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT') {
|
||||
watch.dirty = true
|
||||
return
|
||||
}
|
||||
ctx.logger.warn(error)
|
||||
}
|
||||
watch.mtimeMs = current.mtimeMs
|
||||
watch.size = current.size
|
||||
watch.dirty = false
|
||||
}
|
||||
|
||||
const watchRow = (id: string, path: string): void => {
|
||||
const listener = (curr: Stats, prev: Stats): void => {
|
||||
// fs.watchFile fires on any stat delta (atime included); only content
|
||||
// signals count. An all-zero curr means the file vanished mid-rebuild
|
||||
// — the completing write fires the next tick, so skipping is safe.
|
||||
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return
|
||||
if (curr.mtimeMs === 0) return
|
||||
try {
|
||||
// rebuilt() re-hashes; an unchanged hash stays silent (clientModuleHost
|
||||
// fires onRebuilt only on a real rev change). A torn read of a
|
||||
// half-written bundle self-heals on the next poll tick.
|
||||
ctx.clientModuleHost.rebuilt(id)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick
|
||||
ctx.logger.warn(error)
|
||||
}
|
||||
let baseline: { mtimeMs: number; size: number }
|
||||
try {
|
||||
baseline = statSync(path)
|
||||
} catch (error) {
|
||||
watched.set(id, { path, mtimeMs: 0, size: 0, dirty: true })
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
|
||||
return
|
||||
}
|
||||
const watch = { path, mtimeMs: baseline.mtimeMs, size: baseline.size, dirty: false }
|
||||
watched.set(id, watch)
|
||||
// The module host hashed before publishing the graph. Re-hash immediately
|
||||
// after capturing this baseline so a write in between cannot become an
|
||||
// already-current baseline paired with a stale graph rev.
|
||||
rehash(id, watch, baseline)
|
||||
}
|
||||
|
||||
const pollWatches = (): void => {
|
||||
for (const [id, watch] of watched) {
|
||||
let current: { mtimeMs: number; size: number }
|
||||
try {
|
||||
current = statSync(watch.path)
|
||||
} catch (error) {
|
||||
watch.dirty = true
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
|
||||
continue
|
||||
}
|
||||
if (!watch.dirty && current.mtimeMs === watch.mtimeMs && current.size === watch.size) 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)
|
||||
}
|
||||
watchFile(path, { interval: pollIntervalMs, persistent: false }, listener)
|
||||
watched.set(id, { path, listener })
|
||||
}
|
||||
|
||||
// Diff the watch set against the current graph: drop watches for removed
|
||||
@@ -85,7 +121,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
for (const [id, watch] of watched) {
|
||||
if (rows.get(id) === watch.path) continue
|
||||
unwatchFile(watch.path, watch.listener)
|
||||
watched.delete(id)
|
||||
}
|
||||
for (const [id, path] of rows) {
|
||||
@@ -99,9 +134,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// own row — no self-exemption, a modules/hmr rebuild rides the same chain).
|
||||
syncWatches()
|
||||
const unsubscribe = ctx.clientModuleHost.onGraphChanged(syncWatches)
|
||||
const timer = setInterval(pollWatches, pollIntervalMs)
|
||||
timer.unref()
|
||||
return () => {
|
||||
unsubscribe()
|
||||
for (const { path, listener } of watched.values()) unwatchFile(path, listener)
|
||||
clearInterval(timer)
|
||||
watched.clear()
|
||||
}
|
||||
}, 'client-hmr: bundle watches')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Node half of the HMR plugin: bundle watches follow the graph, stat changes
|
||||
* report through clientModuleHost.rebuilt, and everything dies with the fiber.
|
||||
*/
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
@@ -24,18 +24,29 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
||||
* the service class carries private scan state a literal need not reproduce.
|
||||
*/
|
||||
type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void }
|
||||
function fakeClientModuleHost(rows: Map<string, string>): FakeHost {
|
||||
interface FakeHostOptions {
|
||||
beforeGraphRead?: () => void
|
||||
rebuilt?: (id: string) => string | undefined
|
||||
}
|
||||
|
||||
function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
|
||||
const graphListeners = new Set<() => void>()
|
||||
const rebuiltCalls: string[] = []
|
||||
const fake: Pick<FakeHost, 'graph' | 'clientPath' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
|
||||
rebuiltCalls,
|
||||
fireGraphChanged: () => { for (const l of graphListeners) l() },
|
||||
graph: (): WebBootGraph => ({
|
||||
rev: 'r',
|
||||
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
|
||||
}),
|
||||
graph: (): WebBootGraph => {
|
||||
options.beforeGraphRead?.()
|
||||
return {
|
||||
rev: 'r',
|
||||
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
|
||||
}
|
||||
},
|
||||
clientPath: id => rows.get(id),
|
||||
rebuilt: (id) => { rebuiltCalls.push(id); return 'r2' },
|
||||
rebuilt: (id) => {
|
||||
rebuiltCalls.push(id)
|
||||
return options.rebuilt?.(id) ?? 'r2'
|
||||
},
|
||||
onRebuilt: () => () => {},
|
||||
onGraphChanged: (listener) => {
|
||||
graphListeners.add(listener)
|
||||
@@ -81,6 +92,8 @@ describe('hmr node half', () => {
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
|
||||
expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
|
||||
// Nudge mtime past stat granularity so the poller sees a content signal.
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
||||
@@ -103,14 +116,89 @@ describe('hmr node half', () => {
|
||||
const rows = new Map([['pkg-early', early]])
|
||||
const clientModuleHost = fakeClientModuleHost(rows)
|
||||
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
|
||||
writeFileSync(late, 'v1')
|
||||
rows.set('pkg-late', late)
|
||||
clientModuleHost.fireGraphChanged()
|
||||
expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-late'])
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
||||
writeFileSync(late, 'v2-longer')
|
||||
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 })
|
||||
|
||||
rows.delete('pkg-late')
|
||||
clientModuleHost.fireGraphChanged()
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
writeFileSync(late, 'v3-even-longer')
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
|
||||
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('rehashes after baseline capture so a construction-window write cannot become the baseline', async () => {
|
||||
const bundle = join(dir, 'construction.js')
|
||||
writeFileSync(bundle, 'v1')
|
||||
let rewrite = true
|
||||
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
|
||||
beforeGraphRead: () => {
|
||||
if (!rewrite) return
|
||||
rewrite = false
|
||||
// The graph carries the hash from before this write. The old
|
||||
// fs.watchFile registration asynchronously captured the new file as
|
||||
// its first baseline and never requested a re-hash.
|
||||
writeFileSync(bundle, 'v2-written-during-watch-construction')
|
||||
},
|
||||
})
|
||||
|
||||
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
||||
|
||||
expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
|
||||
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('marks a vanished bundle dirty so identical metadata still re-hashes after it reappears', async () => {
|
||||
const bundle = join(dir, 'replace.js')
|
||||
writeFileSync(bundle, 'seed')
|
||||
const fixedTime = new Date(1_600_000_000_000)
|
||||
utimesSync(bundle, fixedTime, fixedTime)
|
||||
const baseline = statSync(bundle)
|
||||
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
|
||||
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
|
||||
unlinkSync(bundle)
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
||||
writeFileSync(bundle, 'x'.repeat(baseline.size))
|
||||
utimesSync(bundle, fixedTime, fixedTime)
|
||||
const restored = statSync(bundle)
|
||||
expect({ mtimeMs: restored.mtimeMs, size: restored.size }).toEqual({
|
||||
mtimeMs: baseline.mtimeMs,
|
||||
size: baseline.size,
|
||||
})
|
||||
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a']) }, { timeout: 3_000 })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('retains a dirty baseline when the immediate re-hash races a rename', async () => {
|
||||
const bundle = join(dir, 'rename.js')
|
||||
writeFileSync(bundle, 'v1')
|
||||
let first = true
|
||||
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
|
||||
rebuilt: () => {
|
||||
if (!first) return 'r2'
|
||||
first = false
|
||||
throw Object.assign(new Error('bundle renamed'), { code: 'ENOENT' })
|
||||
},
|
||||
})
|
||||
|
||||
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
||||
|
||||
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a', 'pkg-a']) }, { timeout: 3_000 })
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user