feat(web): grow real node halves in connection and hmr
connection binds the web transport: it injects httpServer + apiProxy and registers toFetchHandler(ctx.apiProxy) under the /api prefix (the node:http to fetch bridge moves in from the webserver, keeping the res-close disconnect detection and drain/close backpressure waits). hmr owns dev reload: a stat-poll watch per graph row driven by clientModuleHost.onGraphChanged, rebuilt(id) on content change, and the /plugins/events SSE route (GET/HEAD guarded); frame types are single-sourced in events.ts shared by both halves.
This commit is contained in:
@@ -3,8 +3,7 @@
|
||||
* @module @deepseek-ai/dsh-client-hmr/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
|
||||
@@ -14,14 +13,43 @@ export const name = 'client-hmr-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Live fs.watchFile pollers (this package is the composition's only stat-poll user). */
|
||||
function statWatchers(): number {
|
||||
return process.getActiveResourcesInfo().filter(kind => kind === 'StatWatcher').length
|
||||
}
|
||||
|
||||
/**
|
||||
* No runtime invariant: a dev-only reload driver — it consumes the loader
|
||||
* entry tree and module cache but owns no events and no cross-plugin mutable
|
||||
* state; reload correctness (dispose → style removal → re-execute ordering)
|
||||
* is observable only through the assembled browser runtime, not a host-side
|
||||
* event relation.
|
||||
* Owned relation: every bundle stat watcher the node half starts must die
|
||||
* with its fiber — a surviving poller would keep re-hashing bundles for a
|
||||
* torn-down dev chain forever. Checked as a baseline delta: the StatWatcher
|
||||
* count observed at fiber creation must be restored once disposal has drained
|
||||
* the fiber's effects (`internal/plugin` fires at dispose start; the microtask
|
||||
* hop lets the disposer queue its unload before `fiber.await()` joins it).
|
||||
* SSE-connection and listener teardown live inside the same ctx.effect
|
||||
* disposers, so the watcher count is the relation's observable proxy.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
const baselines = new WeakMap<Fiber, number>()
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'client-hmr') return
|
||||
if (fiber.uid !== null) {
|
||||
baselines.set(fiber, statWatchers())
|
||||
return
|
||||
}
|
||||
const baseline = baselines.get(fiber)
|
||||
if (baseline === undefined) return
|
||||
// Returned to the emitter: emitPluginDisposed awaits-and-logs async
|
||||
// listener failures, so a violation surfaces loudly instead of unhandled.
|
||||
return (async () => {
|
||||
await Promise.resolve()
|
||||
await fiber.await()
|
||||
const remaining = statWatchers()
|
||||
if (remaining > baseline) {
|
||||
fail(`client-hmr fiber disposed but ${remaining - baseline} bundle stat watcher(s) survived teardown`)
|
||||
}
|
||||
})()
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
@@ -30,4 +58,3 @@ const install: InvariantInstaller = () => {}
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
Reference in New Issue
Block a user