feat(gui): dsh-client-hmr reload driver and the dshClient-discovered watch-build script
client-hmr is a normal plugin package composed into dev graphs only. It listens on /plugins/events and reloads one plugin per rebuilt frame, serialized: invalidate, prefetch (fresh factory registers while the old fiber still serves), registry.delete before touching the fiber, drain disposers, drop owned style tags, entry.refresh(), fiber.await() loud. Dependency cascade costs zero client code — fiber activation epochs re-load dependents through cordis itself. Reload is coarse by design; no rollback in v1; self-reload works with a frame gap the next rebuild heals. scripts/dev-web.ts (pnpm run dev:web) is the convenience watch-build: it discovers its package list by scanning packages/*/*/package.json for dshClient platform "web" at startup — no hardcoded roster — and talks no protocol to the host. Gate bookkeeping rides along: knip entries for the new packages, README model-experience allowlist rows.
This commit is contained in:
19
packages/client/hmr/README.md
Normal file
19
packages/client/hmr/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# @deepseek-ai/dsh-client-hmr
|
||||
|
||||
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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the reload driver is browser-side machinery; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
|
||||
- **No failure rollback** — a reload that fails leaves the entry FAILED and loud in the loader status projection; restoring the previous bundle automatically is deferred until a real need shows.
|
||||
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless (the bundle endpoint serves no-cache); rev refresh lands with the reconnect-handshake mechanism.
|
||||
51
packages/client/hmr/package.json
Normal file
51
packages/client/hmr/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-hmr",
|
||||
"description": "Dev-only hot-reload driver for fetch-arrival client entries: SSE rebuilt frames → prefetch/invalidate → fiber swap through the vendored Loader entry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-client-modules": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
191
packages/client/hmr/src/client/index.ts
Normal file
191
packages/client/hmr/src/client/index.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* client-hmr, browser half: hot-reload driver for client plugin entries.
|
||||
*
|
||||
* Listens on the host's system SSE channel (`GET /plugins/events`); on a
|
||||
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
|
||||
* fiber in place. Every graph entry is a plugin bundle under the web2 model
|
||||
* — `immediately` rows differ only in stage-one prefetch (a boot
|
||||
* optimization), so all nine plugin packages share these reload semantics;
|
||||
* normal packages (react family, cordis, shell, pure libs) are not entries
|
||||
* and shell changes still mean a page reload. Cascade is zero-touch:
|
||||
* downstream fibers key their activation epoch on provider fiber uids
|
||||
* (vendor/cordis/src/fiber.ts `_refresh`), so replacing a provider fiber
|
||||
* re-cascades natively — reloading a data-layer plugin (connection/runtime)
|
||||
* cascades into its UI dependents with no HMR-side bookkeeping.
|
||||
*
|
||||
* Reload order (lazy CJS table): invalidate (drop the stale factory and
|
||||
* materialized record) → prefetch (fetch + execute + register the fresh
|
||||
* factory) → registry-first teardown → drain old fiber unload → remove
|
||||
* owned `<style data-plugin>` tags → `entry.refresh()` materializes the new
|
||||
* factory. Invalidate MUST precede prefetch: a live factory makes prefetch
|
||||
* a no-op, and re-executing a bundle over an undeleted registration is a
|
||||
* loud duplicate. The swap is safe because execution is pure registration
|
||||
* under the lazy model — every module side effect (CSS injection included)
|
||||
* lives in the factory closure and runs at materialization, inside
|
||||
* refresh(). That also keeps the CSS ordering guarantee: owned styles are
|
||||
* removed after the old fiber's disposers drained (SlotCore one-owner
|
||||
* unregister) and before materialization re-injects tags under the same
|
||||
* stable tag ids.
|
||||
*
|
||||
* Failure window: if prefetch rejects after invalidate, the module is left
|
||||
* unregistered while the OLD fiber keeps running untouched (teardown never
|
||||
* started) — degraded but recoverable, the next rebuilt frame retries from
|
||||
* scratch. Consistent with the v1 no-rollback policy below. Known dev-only
|
||||
* race: a rebuilt frame overlapping a still-in-flight boot arrival shares
|
||||
* that arrival's task and may materialize the pre-rebuild bytes; the next
|
||||
* rebuilt frame self-heals.
|
||||
*
|
||||
* Why not the naive `entry.fiber.dispose()` → `entry.refresh()` path —
|
||||
* confirmed against vendor sources:
|
||||
* 1. `Entry.fiber` is never cleared on dispose (vendor/loader/src/config/
|
||||
* entry.ts assigns it only in `_init`), so `refresh()` hits its
|
||||
* `if (this.fiber) return` guard and no-ops.
|
||||
* 2. A bare `fiber.dispose()` lands in Loader's self-dispose branch
|
||||
* (vendor/loader/src/index.ts `internal/plugin` case 4: the registry
|
||||
* still holds the runtime at emit time), which flags the entry
|
||||
* `disabled: true` — permanently.
|
||||
* vendor/hmr's reload skeleton documents the fix: delete the runtime record
|
||||
* FIRST (`registry.delete` → case 4 returns early, the entry stays enabled),
|
||||
* then rebuild. We additionally clear `entry.fiber` ourselves so
|
||||
* `entry.refresh()` re-imports and re-plugins through the Loader's own
|
||||
* `_init` (entry-resolved config, automatic `fiber.entry` rebinding) instead
|
||||
* of hand-rolling `registry.plugin`. Client entries have exactly one fiber
|
||||
* per runtime, so `registry.delete` never collaterally disposes siblings.
|
||||
*
|
||||
* Self-reload: this plugin is itself a graph entry, so a rebuilt frame may
|
||||
* name it. The in-flight reload keeps running in the old bundle's closure
|
||||
* (its EventSource closes with the old fiber's effects); the new bundle's
|
||||
* apply opens a fresh channel. Frames arriving during the gap are lost —
|
||||
* acceptable for the dev channel, the next rebuild renotifies.
|
||||
*
|
||||
* Failure policy (v1): no rollback. An import failure leaves the entry
|
||||
* fiberless (the next rebuilt frame retries from scratch); an apply failure
|
||||
* leaves a FAILED fiber for the shell's status projection. Both log loudly.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { Entry, Loader } from '@cordisjs/plugin-loader'
|
||||
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
/**
|
||||
* Frames on the `GET /plugins/events` system SSE channel (owned host-side by
|
||||
* dsh-host-webserver's PluginEventFrame). Mirrored here because this is a
|
||||
* wire boundary: frames arrive as JSON text and are validated at the parse
|
||||
* point, not shared as a same-process typed seam.
|
||||
*/
|
||||
export type PluginsEventFrame =
|
||||
| { type: 'graph'; graph: WebBootGraph }
|
||||
| { type: 'rebuilt'; id: string; rev: string }
|
||||
|
||||
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
|
||||
export const EVENTS_ENDPOINT = '/plugins/events'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'client-hmr'
|
||||
|
||||
/** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */
|
||||
export const inject = ['loader', 'modules']
|
||||
|
||||
/** Find the loader entry whose module specifier is `id` (entry tree ids are random; the package name lives in `options.name`). */
|
||||
function findEntry(loader: Loader, id: string): Entry | undefined {
|
||||
for (const entry of loader.entries()) {
|
||||
if (entry.options.name === id) return entry
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Remove every `<style data-plugin>` tag owned by `id` (attribute compared verbatim — no CSS-selector escaping pitfalls). */
|
||||
function removeOwnedStyles(id: string): void {
|
||||
for (const el of document.querySelectorAll('style[data-plugin]')) {
|
||||
if (el.getAttribute('data-plugin') === id) el.remove()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the HMR driver: subscribe to the system SSE channel and hot-swap
|
||||
* rebuilt entries.
|
||||
* @param ctx - plugin context with `loader` and `modules` available.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// Both are declared injections (typed Context merges: `modules` from the
|
||||
// client module loader package, `loader` from the vendored Loader).
|
||||
const modLoader = ctx.modules
|
||||
const loader: Loader = ctx.loader
|
||||
|
||||
async function reload(id: string): Promise<void> {
|
||||
const entry = findEntry(loader, id)
|
||||
if (entry === undefined) {
|
||||
ctx.logger.warn(`client-hmr: rebuilt frame for unknown entry "${id}" (not in the loader tree)`)
|
||||
return
|
||||
}
|
||||
// Invalidate first (drop stale factory + record — a live factory makes
|
||||
// prefetch a no-op and re-registration a loud duplicate), then run the
|
||||
// async half while the old fiber still serves: fetch + execute registers
|
||||
// the fresh factory with zero side effects (lazy CJS — module bodies run
|
||||
// at materialization, not execution).
|
||||
modLoader.invalidate(id)
|
||||
await modLoader.prefetch(id)
|
||||
|
||||
const oldFiber = entry.fiber
|
||||
if (oldFiber !== undefined) {
|
||||
// Registry-first teardown (see module comment): the runtime record must
|
||||
// be gone before the fiber's disposer emits internal/plugin, or the
|
||||
// Loader flags the entry disabled.
|
||||
const runtime = oldFiber.runtime
|
||||
if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
|
||||
// Drain the unload: effect disposers (slots, subscriptions) must finish
|
||||
// before the new bundle executes and the new apply re-registers.
|
||||
while (oldFiber.inertia !== undefined) await oldFiber.inertia
|
||||
delete entry.fiber
|
||||
}
|
||||
// Old owned styles go before materialization re-injects them (the CSS
|
||||
// idempotency guard keys on stable tag ids).
|
||||
removeOwnedStyles(id)
|
||||
// Re-init through the entry: fiber cleared above, so refresh() re-imports
|
||||
// — materializing the prefetched factory (CSS injects here) — and
|
||||
// re-plugins under the entry context. Import failures are logged by
|
||||
// Entry._init and leave the entry fiberless (retryable).
|
||||
await entry.refresh()
|
||||
// Surface apply failures loudly (v1: no rollback, FAILED state stays).
|
||||
await entry.fiber?.await()
|
||||
}
|
||||
|
||||
// Serialize reloads: frames can arrive faster than a swap completes, and
|
||||
// interleaved dispose/execute chains would corrupt the single-slot handoff.
|
||||
let queue: Promise<void> = Promise.resolve()
|
||||
const handle = (frame: PluginsEventFrame): void => {
|
||||
switch (frame.type) {
|
||||
case 'rebuilt':
|
||||
queue = queue.then(() => reload(frame.id)).catch((error: unknown) => {
|
||||
ctx.logger.error(`client-hmr: reload of "${frame.id}" failed`)
|
||||
ctx.logger.error(error)
|
||||
})
|
||||
break
|
||||
case 'graph':
|
||||
// Connect-time snapshot, unused in v1. The loader's cached graph rev
|
||||
// goes stale after rebuilds — harmless, since prefetch hits the
|
||||
// network anyway (host serves bundles no-cache); graph rev refresh
|
||||
// lands with the reconnect-handshake mechanism.
|
||||
break
|
||||
default:
|
||||
// Merge-extensible frame union: unknown frame types from newer hosts
|
||||
// are ignored by design.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ctx.effect(() => {
|
||||
const source = new EventSource(EVENTS_ENDPOINT)
|
||||
source.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
let frame: PluginsEventFrame
|
||||
try {
|
||||
frame = JSON.parse(event.data) as PluginsEventFrame
|
||||
} catch {
|
||||
// Wire boundary: a malformed dev-channel frame is dropped loudly.
|
||||
ctx.logger.warn(`client-hmr: unparseable event frame: ${event.data}`)
|
||||
return
|
||||
}
|
||||
handle(frame)
|
||||
})
|
||||
return () => { source.close() }
|
||||
}, 'client-hmr: event source')
|
||||
}
|
||||
9
packages/client/hmr/src/index.ts
Normal file
9
packages/client/hmr/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* HMR plugin, node half. The package IS a dshClient plugin (dev-only row in
|
||||
* the host graph): the reload driver lives in its client half in full
|
||||
* (src/client/); the empty apply exists so the plugin appears in the host
|
||||
* Loader (lifecycle governance + dshClient discovery).
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the HMR plugin. */
|
||||
export function apply(): void {}
|
||||
33
packages/client/hmr/src/invariant.ts
Normal file
33
packages/client/hmr/src/invariant.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-hmr`.
|
||||
* @module @deepseek-ai/dsh-client-hmr/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-hmr-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
30
packages/client/hmr/tsconfig.json
Normal file
30
packages/client/hmr/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../modules"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/hmr/tsdown.config.ts
Normal file
3
packages/client/hmr/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-hmr', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user