|
|
@@ -22,8 +22,7 @@
|
|
|
*/
|
|
|
|
|
|
import { createHash } from 'node:crypto'
|
|
|
-import { readFileSync, unwatchFile, watchFile } from 'node:fs'
|
|
|
-import type { Stats } from 'node:fs'
|
|
|
+import { readFileSync, statSync, type Stats } from 'node:fs'
|
|
|
import { dirname, join } from 'node:path'
|
|
|
import type { Context } from 'cordis'
|
|
|
|
|
|
@@ -107,9 +106,9 @@ export interface WebPluginRegistryDeps {
|
|
|
onError: (err: Error) => void
|
|
|
/**
|
|
|
* Dev-mode bundle watching: stat-poll every scanned row's client bundle
|
|
|
- * (fs.watchFile — polling by design: network mounts deliver no inotify
|
|
|
- * events) and re-hash + notify onRebuilt subscribers on change. Absent =
|
|
|
- * no watching (prod composition).
|
|
|
+ * with an explicit stat baseline (polling by design: network mounts deliver
|
|
|
+ * no inotify events) and re-hash + notify onRebuilt subscribers on change.
|
|
|
+ * Absent = no watching (prod composition).
|
|
|
*/
|
|
|
watch?: {
|
|
|
/** Stat-poll interval in milliseconds; default 500 (the build-side watcher's polling default). */
|
|
|
@@ -130,6 +129,13 @@ interface WebPluginRecord {
|
|
|
clientPath: string
|
|
|
}
|
|
|
|
|
|
+interface WatchedBundle {
|
|
|
+ path: string
|
|
|
+ mtimeMs: number
|
|
|
+ size: number
|
|
|
+ dirty: boolean
|
|
|
+}
|
|
|
+
|
|
|
/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
|
|
|
function parseDshClient(name: string, value: unknown): DshClientDeclaration | undefined {
|
|
|
if (value === undefined) return undefined
|
|
|
@@ -204,8 +210,32 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe
|
|
|
throw new Error(`web-plugins: watch.intervalMs must be a positive integer (got ${String(deps.watch?.intervalMs)})`)
|
|
|
}
|
|
|
|
|
|
+ const stageWatches = (
|
|
|
+ candidateTable: Map<string, WebPluginRecord>,
|
|
|
+ currentWatches: Map<string, WatchedBundle>,
|
|
|
+ ): Map<string, WatchedBundle> => {
|
|
|
+ const candidateWatches = new Map<string, WatchedBundle>()
|
|
|
+ if (watchInterval === undefined) return candidateWatches
|
|
|
+ for (const [id, record] of candidateTable) {
|
|
|
+ const current = currentWatches.get(id)
|
|
|
+ if (current?.path === record.clientPath) {
|
|
|
+ candidateWatches.set(id, { ...current })
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ const baseline = statSync(record.clientPath)
|
|
|
+ candidateWatches.set(id, {
|
|
|
+ path: record.clientPath,
|
|
|
+ mtimeMs: baseline.mtimeMs,
|
|
|
+ size: baseline.size,
|
|
|
+ dirty: false,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return candidateWatches
|
|
|
+ }
|
|
|
+
|
|
|
let table = scan(deps)
|
|
|
let graph = composeGraph(table)
|
|
|
+ let watched = stageWatches(table, new Map())
|
|
|
const rebuildListeners = new Set<(id: string, rev: string) => void>()
|
|
|
|
|
|
const rebuilt = (id: string): string | undefined => {
|
|
|
@@ -217,51 +247,57 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe
|
|
|
return rev
|
|
|
}
|
|
|
|
|
|
- // Dev bundle watch: one fs.watchFile stat poll per table row. A torn read
|
|
|
- // of a half-written bundle self-heals — the ongoing write keeps changing
|
|
|
- // the stats, so the next poll tick re-hashes the completed file.
|
|
|
- const watched = new Map<string, { path: string; listener: (curr: Stats, prev: Stats) => void }>()
|
|
|
- const syncWatches = (): void => {
|
|
|
- if (watchInterval === undefined) return
|
|
|
+ // Dev bundle watch: capture every row's baseline synchronously before the
|
|
|
+ // registry is returned, then poll those baselines. fs.watchFile establishes
|
|
|
+ // its first baseline asynchronously, so an immediate rebuild can otherwise
|
|
|
+ // become the baseline and disappear without an observed delta.
|
|
|
+ const pollWatches = (): void => {
|
|
|
for (const [id, watch] of watched) {
|
|
|
- if (table.get(id)?.clientPath === watch.path) continue
|
|
|
- unwatchFile(watch.path, watch.listener)
|
|
|
- watched.delete(id)
|
|
|
- }
|
|
|
- for (const [id, record] of table) {
|
|
|
- if (watched.has(id)) continue
|
|
|
- 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
|
|
|
- const before = table.get(id)?.entry.rev
|
|
|
- let rev: string | undefined
|
|
|
+ let current: Stats
|
|
|
+ try {
|
|
|
+ current = statSync(watch.path)
|
|
|
+ } catch (error) {
|
|
|
+ const code = (error as NodeJS.ErrnoException).code
|
|
|
+ if (code === 'ENOENT') {
|
|
|
+ watch.dirty = true
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ deps.onError(error instanceof Error ? error : new Error(String(error)))
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (!watch.dirty && current.mtimeMs === watch.mtimeMs && current.size === watch.size) continue
|
|
|
+ const before = table.get(id)?.entry.rev
|
|
|
+ let rev: string | undefined
|
|
|
+ try {
|
|
|
+ rev = rebuilt(id)
|
|
|
+ } catch (error) {
|
|
|
+ const code = (error as NodeJS.ErrnoException).code
|
|
|
+ if (code === 'ENOENT') {
|
|
|
+ watch.dirty = true
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ watch.mtimeMs = current.mtimeMs
|
|
|
+ watch.size = current.size
|
|
|
+ deps.onError(error instanceof Error ? error : new Error(String(error)))
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ watch.mtimeMs = current.mtimeMs
|
|
|
+ watch.size = current.size
|
|
|
+ watch.dirty = false
|
|
|
+ if (rev === undefined || rev === before) continue
|
|
|
+ for (const notify of rebuildListeners) {
|
|
|
+ // A throwing subscriber must not skip later subscribers or escape the
|
|
|
+ // polling callback into the process event loop.
|
|
|
try {
|
|
|
- rev = rebuilt(id)
|
|
|
+ notify(id, rev)
|
|
|
} catch (error) {
|
|
|
- const code = (error as NodeJS.ErrnoException).code
|
|
|
- if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick
|
|
|
deps.onError(error instanceof Error ? error : new Error(String(error)))
|
|
|
- return
|
|
|
- }
|
|
|
- if (rev === undefined || rev === before) return
|
|
|
- for (const notify of rebuildListeners) {
|
|
|
- // A throwing subscriber must not escape the fs.watchFile callback
|
|
|
- // (that would skip later subscribers and can kill the process).
|
|
|
- try {
|
|
|
- notify(id, rev)
|
|
|
- } catch (error) {
|
|
|
- deps.onError(error instanceof Error ? error : new Error(String(error)))
|
|
|
- }
|
|
|
}
|
|
|
}
|
|
|
- watchFile(record.clientPath, { interval: watchInterval, persistent: false }, listener)
|
|
|
- watched.set(id, { path: record.clientPath, listener })
|
|
|
}
|
|
|
}
|
|
|
- syncWatches()
|
|
|
+ const watchTimer = watchInterval === undefined ? undefined : setInterval(pollWatches, watchInterval)
|
|
|
+ watchTimer?.unref()
|
|
|
|
|
|
let pending = false
|
|
|
const unsubscribe = deps.ctx.on('internal/plugin', () => {
|
|
|
@@ -270,9 +306,12 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe
|
|
|
queueMicrotask(() => {
|
|
|
pending = false
|
|
|
try {
|
|
|
- table = scan(deps)
|
|
|
- graph = composeGraph(table)
|
|
|
- syncWatches()
|
|
|
+ const candidateTable = scan(deps)
|
|
|
+ const candidateGraph = composeGraph(candidateTable)
|
|
|
+ const candidateWatches = stageWatches(candidateTable, watched)
|
|
|
+ table = candidateTable
|
|
|
+ graph = candidateGraph
|
|
|
+ watched = candidateWatches
|
|
|
} catch (error) {
|
|
|
// Keep serving the previous graph: a mid-flight rescan failure must not
|
|
|
// take down the boot manifest for plugins that were fine.
|
|
|
@@ -291,7 +330,7 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe
|
|
|
},
|
|
|
dispose: () => {
|
|
|
unsubscribe()
|
|
|
- for (const { path, listener } of watched.values()) unwatchFile(path, listener)
|
|
|
+ if (watchTimer !== undefined) clearInterval(watchTimer)
|
|
|
watched.clear()
|
|
|
rebuildListeners.clear()
|
|
|
},
|