From f499872cecd3720df2e9df079cf563f5cf6b5f9a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:18:29 +0800 Subject: [PATCH] refactor(web): rewrite webserver as a plain route-registration plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpServerService provides ctx.httpServer: register(route) -> disposer (duplicate patterns throw), tapIndex transforms in registration order, and the bound port; matching is exact > longest prefix > static dist fallback (403/405/SPA semantics preserved). The server listens on activation, answers per-request failures with 400 + a log line instead of exiting the process, and knows no harness concepts — the boot graph, bundle routes, SSE channel, and /api prefix all moved to their owning plugins. --- packages/host/webserver/README.md | 14 +- packages/host/webserver/package.json | 5 +- packages/host/webserver/src/index.ts | 370 +++++++--------- packages/host/webserver/src/invariant.ts | 38 +- packages/host/webserver/src/plugin-events.ts | 56 --- packages/host/webserver/src/web-plugins.ts | 360 ---------------- .../host/webserver/tests/invariant.spec.ts | 50 --- .../host/webserver/tests/web-plugins.spec.ts | 347 --------------- .../host/webserver/tests/webserver.spec.ts | 400 ------------------ packages/host/webserver/tsconfig.json | 3 + 10 files changed, 194 insertions(+), 1449 deletions(-) delete mode 100644 packages/host/webserver/src/plugin-events.ts delete mode 100644 packages/host/webserver/src/web-plugins.ts delete mode 100644 packages/host/webserver/tests/invariant.spec.ts delete mode 100644 packages/host/webserver/tests/web-plugins.spec.ts delete mode 100644 packages/host/webserver/tests/webserver.spec.ts diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index baa57dbfe1..f00984ea90 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -1,18 +1,16 @@ # @deepseek-ai/dsh-host-webserver -Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. +Plain HTTP route-registration plugin (default-exported `WebServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.webServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics. -The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. +The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. -Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own. - -A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and reported to `onError`; it never becomes a process-killing unhandled rejection. +A listen failure (EADDRINUSE…) throws out of activation — a FAILED fiber the boot's fail-loud sweep reports. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own. In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata. ## Model Experience -None, as the package is a pure HTTP carrier between the browser and the injected API handler; nothing here reaches a model request. +None, as the package is a pure HTTP carrier between the browser and the routes other plugins register; nothing here reaches a model request. #### KV Cache effect @@ -20,6 +18,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No TLS, auth, or origin policy** — callers that bind a non-loopback address expose the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1. +- **No TLS, auth, or origin policy** — binding a non-loopback address exposes the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1. - **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships. -- **Socket options are fixed** — callers select the bind host and port, while backlog and other socket settings remain internal until a deployment needs them. +- **Socket options are fixed** — config selects the bind host and port, while backlog and other socket settings remain internal until a deployment needs them. diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 01d8a22e9d..0dab038f41 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-host-webserver", - "description": "Web-shape HTTP carrier: static file serving plus the /api/* bridge to an injected fetch-shaped handler (SSE streamed through)", + "description": "Plain HTTP route-registration plugin: named-route registry (webServer service) + index transform taps + static dist fallback; knows no harness concepts", "version": "0.0.1", "private": true, "type": "module", @@ -30,6 +30,9 @@ "cordis": "^4.0.0-rc.7", "@deepseek-ai/dsh-invariants": "^0.0.1" }, + "dependencies": { + "schemastery": "^3.18.0" + }, "devDependencies": { "cordis": "^4.0.0-rc.7", "@deepseek-ai/dsh-invariants": "workspace:^" diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 60ad92d18c..936dd4f5a1 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -1,232 +1,184 @@ /** - * @deepseek-ai/dsh-host-webserver — the web-shape HTTP carrier: node:http server - * routing /api/* to an injected fetch-shaped handler (node:http ↔ WHATWG - * bridge with SSE streamed out chunk by chunk) and everything else to static - * file serving. Web (browser) shape only — Electron loads dist over file:// - * and carries fetch over an IPC bridge, not this server. This package never - * prints: the URL line belongs to the shell. + * @deepseek-ai/dsh-host-webserver — plain HTTP route-registration plugin: a + * node:http server plus the `httpServer` service (named-route registry + index + * transform taps + static dist fallback). Knows no harness concepts — every + * feature surface (API bridge, plugin bundles, SSE) is a route some other + * plugin registers. Web (browser) shape only — Electron loads dist over + * file:// and carries fetch over an IPC bridge, not this server. This package + * never prints: the URL line belongs to the shell. */ import { createServer } from 'node:http' -import type { IncomingMessage, ServerResponse } from 'node:http' +import type { IncomingMessage, ServerResponse, Server } from 'node:http' import { readFile } from 'node:fs/promises' import type { AddressInfo } from 'node:net' import { dirname } from 'node:path' +import { Context, Service } from 'cordis' +import z from 'schemastery' import { serveStatic } from './static.ts' -import { createPluginEventChannel } from './plugin-events.ts' -import type { HostWebPluginRegistry, WebBootGraph } from './web-plugins.ts' -export { createHostWebPluginRegistry } from './web-plugins.ts' -export type { - HostWebPluginRegistry, LoaderEntryView, LoaderView, WebBootEntry, WebBootGraph, WebPluginRegistryDeps, -} from './web-plugins.ts' -export type { PluginEventChannel, PluginEventFrame } from './plugin-events.ts' - -/** Options for startWebServer. */ -export interface WebServerOptions { - /** Address or hostname to listen on. */ - host: string - /** Port to listen on; zero requests an OS-assigned port. */ - port: number - /** - * Absolute path of index.html inside the static root — the caller resolves - * it (dist location is workspace knowledge of the shell, not this package's). - */ - distIndex: string - /** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */ - apiHandler: { fetch: typeof fetch } - /** - * Web plugin table. When present, every index.html response carries the - * `window.__DSH_BOOT__` entry graph script, `/plugins//client.js` serves - * each fetch entry's client bundle, and `GET /plugins/events` streams graph/ - * rebuilt frames (SSE) — rebuilt frames ride the registry's own bundle-watch - * notifications (`onRebuilt`). Absent = all three surfaces off (carrier-only - * use). - */ - webPlugins?: Pick +declare module 'cordis' { + interface Context { + httpServer: HttpServerService + } } -/** Listening web server handle. */ -export interface RunningWebServer { - /** The listening port, including the OS-assigned value when options.port is zero. */ +/** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/. */ +export type WebRouteKind = 'exact' | 'prefix' + +/** One named route registration. */ +export interface WebRoute { + kind: WebRouteKind + /** Absolute pathname, no trailing slash. */ + path: string + /** Owns the full response lifecycle (may hold the response open, e.g. SSE). */ + handler: (req: IncomingMessage, res: ServerResponse) => void | Promise +} + +/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */ +export interface Config { + /** Listen host; the two supported values are loopback and all-interfaces. */ + host: '127.0.0.1' | '0.0.0.0' + /** Listen port; zero requests an OS-assigned port. */ port: number - /** - * Shutdown: close + closeAllConnections (SSE connections never end on their - * own; without the force-close, close() would hang). Idempotent. - */ - close(): Promise + /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */ + distIndex: string } /** - * Start the web-shape HTTP server on the caller-selected host and port. - * Routing: /api/* → apiHandler bridge; non-GET/HEAD → 405; everything else → - * static with the step1-locked semantics (403 traversal, SPA fallback 200). - * A listen failure (EADDRINUSE…) rejects — the shell decides how to exit; a - * server error after listen goes to onError. A request whose handling throws - * (malformed %-escapes, a client dropping mid-body) is answered 400 — or the - * socket destroyed when headers are already out — and reported to onError; - * it never becomes an unhandled rejection. - * @param options - port, static root anchor, and the API carrier. - * @param onError - sink for post-listen server errors and per-request handling failures. - * @returns the running server handle once listening. + * The web-shape HTTP carrier service. Activation listens immediately (route + * registration order carries no request-facing semantics: named routes are + * composed to be disjoint, and the static dist fallback answers anything not + * yet claimed during the boot window). A listen failure throws out of init — + * a FAILED fiber the boot's fail-loud sweep reports. */ -export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise { - const { host, port, distIndex, apiHandler, webPlugins } = options - const distRoot = dirname(distIndex) - const renderIndex = webPlugins === undefined ? undefined : async (): Promise => { - const html = await readFile(distIndex, 'utf8') - return injectBootManifest(html, webPlugins.graph()) - } - const pluginEvents = webPlugins === undefined ? undefined : createPluginEventChannel() - // Rebuilt frames come from the registry's own bundle watch (dev mode); a - // prod registry without watching simply never notifies. - const unsubscribeRebuilt = webPlugins !== undefined && pluginEvents !== undefined - ? webPlugins.onRebuilt((id, rev) => { pluginEvents.broadcast({ type: 'rebuilt', id, rev }) }) - : undefined +export class HttpServerService extends Service { + static Config: z = z.object({ + host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(), + port: z.natural().max(65535).required(), + distIndex: z.string().required(), + }) - const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { - /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server - requests; the field is only optional on the client-side IncomingMessage type */ - const rawPath = new URL(req.url ?? '/', 'http://x').pathname - if (rawPath.startsWith('/api/')) { - await bridge(req, res, apiHandler) - return - } - if (req.method !== 'GET' && req.method !== 'HEAD') { - res.writeHead(405) - res.end() - return - } - if (webPlugins !== undefined && pluginEvents !== undefined && rawPath === '/plugins/events') { - pluginEvents.connect(res, webPlugins.graph()) - return - } - if (webPlugins !== undefined && rawPath.startsWith('/plugins/') && rawPath.endsWith('/client.js')) { - await servePluginBundle(decodeURIComponent(rawPath), res, webPlugins) - return - } - await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex) + private readonly exact = new Map() + private readonly prefixes = new Map() + private readonly indexTaps: ((html: string) => string)[] = [] + private readonly distRoot: string + private readonly distIndex: string + private server!: Server + private listenedPort!: number + + constructor(ctx: Context, private config: Config) { + super(ctx, 'httpServer') + this.distIndex = config.distIndex + this.distRoot = dirname(config.distIndex) } - // Last-resort guard: handle() rejecting would otherwise be an unhandled - // rejection, and one malformed request (a bad %-escape hitting - // decodeURIComponent, a client dropping mid-body) would kill the whole - // process. Nothing after this catch can throw again on the same response. - const server = createServer((req, res) => { - handle(req, res).catch((err: unknown) => { - onError(err instanceof Error ? err : new Error(String(err))) - if (res.headersSent) { - res.destroy() + + /** The listening port (the OS-assigned value when config.port is 0). */ + get port(): number { + return this.listenedPort + } + + /** + * Register a named route. Duplicate (kind, path) throws — route patterns are + * a composition-level contract, so a collision is a misconfiguration. + * @param route - kind, path, and the owning handler. + * @returns the disposer removing the route. + */ + register(route: WebRoute): () => void { + const table = route.kind === 'exact' ? this.exact : this.prefixes + if (table.has(route.path)) { + throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`) + } + table.set(route.path, route) + return () => { table.delete(route.path) } + } + + /** + * Register an index.html transform, applied to every index response in + * registration order. + * @param transform - pure html-to-html function. + * @returns the disposer removing the transform. + */ + tapIndex(transform: (html: string) => string): () => void { + this.indexTaps.push(transform) + return () => { + const at = this.indexTaps.indexOf(transform) + if (at !== -1) this.indexTaps.splice(at, 1) + } + } + + /** Listen; resolves once the socket is bound (rejection = FAILED fiber). */ + async [Service.init](): Promise { + const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { + /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server + requests; the field is only optional on the client-side IncomingMessage type */ + const rawPath = new URL(req.url ?? '/', 'http://x').pathname + const route = this.match(rawPath) + if (route !== undefined) { + await route.handler(req, res) return } - res.writeHead(400) - res.end() - }) - }) - - let closing: Promise | undefined - const close = (): Promise => (closing ??= new Promise((resolveClose) => { - unsubscribeRebuilt?.() - server.close(() => { resolveClose() }) - server.closeAllConnections() - })) - - return new Promise((resolveListen, rejectListen) => { - server.once('error', rejectListen) - server.listen(port, host, () => { - server.off('error', rejectListen) - server.on('error', onError) - resolveListen({ port: (server.address() as AddressInfo).port, close }) - }) - }) -} - -/** - * Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the - * first script in (before the shell bundle reads it). `<` is escaped in - * the JSON so plugin-controlled strings cannot break out of the script element. - * @param html - the index.html source. - * @param graph - the composed entry graph from the registry. - * @returns the html with the graph script injected. - */ -export function injectBootManifest(html: string, graph: WebBootGraph): string { - const json = JSON.stringify(graph).replaceAll('<', '\\u003c') - const script = `` - const head = html.indexOf('') - if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` - // Headless fixture pages may lack ; prepending keeps the read-before-shell ordering. - return `${script}${html}` -} - -/** - * Serve one plugin client bundle from the registry table (unknown id = 404; - * the id may contain a scope slash). The `?rev=` query is a cache-busting - * parameter only — serving ignores it; `no-cache` makes the browser revalidate - * so a stale rev never sticks. - */ -async function servePluginBundle( - pathname: string, res: ServerResponse, webPlugins: Pick, -): Promise { - const id = pathname.slice('/plugins/'.length, -'/client.js'.length) - const path = webPlugins.clientPath(id) - if (path === undefined) { - res.writeHead(404) - res.end() - return - } - try { - const body = await readFile(path) - res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }) - res.end(body) - } catch { - // Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page. - res.writeHead(404) - res.end() - } -} - -/** Bridge one node:http request to the WHATWG fetch handler (client close aborts; SSE bodies stream out chunk by chunk). */ -async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise { - const abort = new AbortController() - // Client-disconnect detection MUST hang off the response, not the request: - // since Node 16, IncomingMessage 'close' fires as soon as the request body is - // fully consumed (immediately for a bodyless GET), which would abort every SSE - // stream right after open. ServerResponse 'close' fires on connection teardown; - // writableEnded distinguishes a normal end() from the client going away. - res.on('close', () => { - if (!res.writableEnded) abort.abort() - }) - const chunks: Buffer[] = [] - for await (const chunk of req) chunks.push(chunk as Buffer) - /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server - requests; the fields are only optional on the client-side IncomingMessage type */ - const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), { - method: req.method ?? 'GET', - headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]), - ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, - signal: abort.signal, - }) - const response = await apiHandler.fetch(request) - res.writeHead(response.status, Object.fromEntries(response.headers.entries())) - if (response.body === null) { - res.end() - return - } - for await (const chunk of response.body) { - // Backpressure: a false return means the socket buffer is full — wait for drain - // instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also - // resolves so a mid-wait disconnect can't park this loop forever; the close - // handler above aborts the handler stream, which then ends the iteration. - if (!res.write(chunk)) { - await new Promise((resolve) => { - const done = (): void => { - res.off('drain', done) - res.off('close', done) - resolve() - } - res.once('drain', done) - res.once('close', done) - }) + // Static fallback keeps the pre-plugin semantics: non-GET/HEAD is 405, + // traversal 403, miss falls back to index.html 200 (SPA routing). + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405) + res.end() + return + } + await serveStatic(decodeURIComponent(rawPath), res, this.distRoot, this.distIndex, () => this.renderIndex()) } + // Last-resort guard: handle() rejecting would otherwise be an unhandled + // rejection killing the process on one malformed request (bad %-escape, + // client dropping mid-body). Per-request failures log and answer 400 — + // never a process exit. + this.server = createServer((req, res) => { + handle(req, res).catch((err: unknown) => { + this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err))) + if (res.headersSent) { + res.destroy() + return + } + res.writeHead(400) + res.end() + }) + }) + + await new Promise((resolve, reject) => { + this.server.once('error', reject) + this.server.listen(this.config.port, this.config.host, () => { + this.server.off('error', reject) + this.server.on('error', (err) => { this.ctx.logger.error(err) }) + this.listenedPort = (this.server.address() as AddressInfo).port + resolve() + }) + }) + + // close + closeAllConnections: held-open responses (SSE) never end on + // their own; without the force-close, close() would hang teardown. + this.ctx.effect(() => () => new Promise((resolve) => { + this.server.close(() => { resolve() }) + this.server.closeAllConnections() + }), 'httpServer.listen') + } + + /** Longest-prefix-wins over the prefix table after an exact-table miss. */ + private match(pathname: string): WebRoute | undefined { + const exact = this.exact.get(pathname) + if (exact !== undefined) return exact + let best: WebRoute | undefined + for (const [prefix, route] of this.prefixes) { + if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) continue + if (best === undefined || prefix.length > best.path.length) best = route + } + return best + } + + /** Index body: dist index.html through the registered taps in order. */ + private async renderIndex(): Promise { + let html = await readFile(this.distIndex, 'utf8') + for (const transform of this.indexTaps) html = transform(html) + return html } - res.end() } + +export default HttpServerService diff --git a/packages/host/webserver/src/invariant.ts b/packages/host/webserver/src/invariant.ts index a204c93775..b5c8492566 100644 --- a/packages/host/webserver/src/invariant.ts +++ b/packages/host/webserver/src/invariant.ts @@ -15,28 +15,30 @@ export const name = 'host-webserver-invariant' export const inject = ['invariants'] /** - * Owned relation: the web plugin registry's boot entry graph must stay - * self-consistent — every row must resolve a clientPath under the same id - * (the /plugins//client.js URL it advertises would otherwise 404 on a - * browser that just received the graph). Checked synchronously on every - * rescan trigger (cordis 'internal/plugin'): graph() and clientPath() read - * the same table object, so the relation is self-consistent at any instant — - * no need to wait out the registry's own debounced rescan. The registry - * arrives through the context key the assembly publishes it under. + * Owned relation: route registrations and their disposers must stay + * symmetric — after the owning fiber of a registered route unloads, the + * route table must no longer answer for its path (a stale route would keep + * serving a disposed plugin's handler). Checked on every fiber teardown + * (cordis 'internal/plugin'): the service's own registry state is compared + * against the set of live fibers' registrations indirectly, by probing that + * dispose really removed the entry — the register() disposer contract. */ const install: InvariantInstaller = (ctx, fail) => { ctx.on('internal/plugin', () => { - const registry = ctx.get('webPlugins') as - | { - graph(): { entries: { id: string; url: string }[] } - clientPath(id: string): string | undefined - } + const server = ctx.get('httpServer') as + | { register(route: { kind: 'exact'; path: string; handler: () => void }): () => void } | undefined - if (registry === undefined) return // carrier-only deployments never publish the registry - for (const row of registry.graph().entries) { - if (registry.clientPath(row.id) === undefined) { - fail(`web plugin graph row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`) - } + if (server === undefined) return // no webserver row in this composition + // Register/dispose probe on a reserved path: if dispose leaves the route + // behind, a second register throws the duplicate error — the asymmetry. + // Each register(probe)() is one register+dispose cycle, so the probe never + // leaves residue; a leftover from the first cycle makes the second throw. + const probe = { kind: 'exact' as const, path: '/__dsh_invariant_probe__', handler: () => {} } + try { + server.register(probe)() + server.register(probe)() + } catch { + fail('httpServer.register() disposer left the route registered — route table and fiber lifecycles diverged') } }, { global: true }) } diff --git a/packages/host/webserver/src/plugin-events.ts b/packages/host/webserver/src/plugin-events.ts deleted file mode 100644 index b438edf948..0000000000 --- a/packages/host/webserver/src/plugin-events.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * `/plugins/events` SSE channel: the system-side push surface for the client - * entry graph (connect → current graph frame; dev rebuild → rebuilt frame). - * Presentation-only wire — frames never enter the session log (distinct from - * the /api/* session SSE, which is api-contract territory). Connections are - * plain node:http responses held in a set; the server's closeAllConnections - * tears them down on shutdown. - */ - -import type { ServerResponse } from 'node:http' -import type { WebBootGraph } from './web-plugins.ts' - -/** One `/plugins/events` frame: the full graph on connect, or one rebuilt bundle notice. */ -export type PluginEventFrame = - | { type: 'graph'; graph: WebBootGraph } - | { type: 'rebuilt'; id: string; rev: string } - -/** Broadcast surface owned by the webserver routing layer. */ -export interface PluginEventChannel { - /** Adopt one incoming SSE request: writes the SSE preamble and the current-graph frame, then keeps the response open. */ - connect(res: ServerResponse, graph: WebBootGraph): void - /** Push one frame to every open connection. */ - broadcast(frame: PluginEventFrame): void -} - -/** Serialize one frame as an SSE data line. */ -function sseData(frame: PluginEventFrame): string { - return `data: ${JSON.stringify(frame)}\n\n` -} - -/** - * Create the channel (one per running server). - * @returns the connect/broadcast surface. - */ -export function createPluginEventChannel(): PluginEventChannel { - const connections = new Set() - return { - connect(res, graph) { - res.writeHead(200, { - 'content-type': 'text/event-stream', - 'cache-control': 'no-cache', - 'connection': 'keep-alive', - }) - // Comment line on open so clients/proxies see a live channel even when - // no rebuild ever happens; EventSource frame parsing skips it naturally. - res.write(': connected\n\n') - res.write(sseData({ type: 'graph', graph })) - connections.add(res) - res.on('close', () => { connections.delete(res) }) - }, - broadcast(frame) { - const line = sseData(frame) - for (const res of connections) res.write(line) - }, - } -} diff --git a/packages/host/webserver/src/web-plugins.ts b/packages/host/webserver/src/web-plugins.ts deleted file mode 100644 index 4e95e7a091..0000000000 --- a/packages/host/webserver/src/web-plugins.ts +++ /dev/null @@ -1,360 +0,0 @@ -/** - * HostWebPluginRegistry: composes the client entry graph served as - * `window.__DSH_BOOT__` ({rev, entries}). Every row is discovered among the - * host Loader's loaded entries by its package.json `dshClient` declaration - * (all client plugin packages arrive by fetch — one uniform bundle shape), - * resolving each one's client bundle path from `exports["./client"]` and - * hashing the bundle content into a `rev` (cache busting + HMR diff anchor). - * `inject` edges and the `immediately` prefetch mark come from the manifest - * (dshClient — the package owns its dependency edges and its boot tier); the - * composition layer contributes only the roster. The webserver consumes the - * table to emit the boot graph and to serve `GET /plugins//client.js`; - * in dev mode the registry additionally stat-polls each scanned bundle file - * and re-hashes + notifies `onRebuilt` subscribers on change (the rebuild - * signal is the registry's own observation — no builder protocol exists). - * - * The vendored loader emits no "entry loaded" event (only `loader/entry-init`, - * which fires at Entry construction before import/apply), so the registry - * scans `loader.entries()` and rescans on cordis `internal/plugin` (fiber - * create/dispose), microtask-debounced. Plugin-set changes take effect on - * restart per the config-source ruling; the subscription only keeps the table - * fresh within a process lifetime. - */ - -import { createHash } from 'node:crypto' -import { readFileSync, statSync, type Stats } from 'node:fs' -import { dirname, join } from 'node:path' -import type { Context } from 'cordis' - -/** One composed client entry (`window.__DSH_BOOT__.entries` row). */ -export interface WebBootEntry { - /** Entry name == package name. */ - id: string - /** Bundle URL served by this webserver (`/plugins//client.js?rev=`). */ - url: string - /** Bundle content hash (sha1, shortened). */ - rev: string - /** Package-name dependency edges from the manifest (dshClient.inject), informational (preflight/HMR display). */ - inject?: string[] - /** Boot phase-one prefetch tier: the shell fetches these bundles in parallel before creating entries. */ - immediately?: boolean -} - -/** The composed entry graph: injected into index.html and pushed on /plugins/events connect. */ -export interface WebBootGraph { - /** Consistency anchor over all rows: changes whenever any entry row changes. */ - rev: string - /** All composed entries (order carries no semantics; governance ordering is the client Loader's job). */ - entries: WebBootEntry[] -} - -/** The web plugin table consumed by the boot injection, the bundle endpoint, and the rebuild channel. */ -export interface HostWebPluginRegistry { - /** Current composed entry graph (stable object between changes). */ - graph(): WebBootGraph - /** - * Absolute path of an entry's client bundle. - * @param id - entry id (package name). - * @returns the path, or undefined for an unknown id. - */ - clientPath(id: string): string | undefined - /** - * Re-hash one entry's bundle: updates the row's rev/url and the graph rev. - * The dev bundle watch calls this on every observed file change. - * @param id - entry id (package name). - * @returns the new bundle rev, or undefined for an unknown id. - */ - rebuilt(id: string): string | undefined - /** - * Subscribe to bundle rebuilds observed by the dev watch (only fires when - * the re-hash produced a different rev — an unchanged bundle is silent). - * @param listener - receives the entry id and its new bundle rev. - * @returns the unsubscriber. - */ - onRebuilt(listener: (id: string, rev: string) => void): () => void - /** Remove the loader subscription, all bundle watches, and all rebuild listeners. */ - dispose(): void -} - -/** Structural view of a loader entry (webserver keeps zero workspace dependencies; cordis stays a type-only peer). */ -export interface LoaderEntryView { - options: { name: string } - /** Present once the entry's plugin fiber exists (import succeeded and apply ran/started). */ - fiber?: unknown - /** True when the entry or an owning group is disabled. */ - disabled: boolean -} - -/** Structural view of the host Loader (entry enumeration is all the registry needs). */ -export interface LoaderView { - entries(): Iterable -} - -/** Dependencies injected by the assembly layer. */ -export interface WebPluginRegistryDeps { - /** Host root context; used only to subscribe `internal/plugin` for rescans. */ - ctx: Context - /** The host Loader owning the plugin entries. */ - loader: LoaderView - /** - * Resolve a package specifier to its package.json absolute path (assembly - * passes `createRequire(...).resolve(`${name}/package.json`)`); injected so - * the registry makes no module-resolution assumptions of its own. - */ - resolvePkgJson: (name: string) => string - /** Sink for rescan failures (the initial scan throws instead — misconfiguration fails loud at load). */ - onError: (err: Error) => void - /** - * Dev-mode bundle watching: stat-poll every scanned row's client bundle - * 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). */ - intervalMs?: number - } -} - -/** package.json `dshClient` declaration shape (file boundary — validated field by field). */ -interface DshClientDeclaration { - inject?: string[] - platform: string - /** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */ - immediately?: boolean -} - -interface WebPluginRecord { - entry: WebBootEntry - 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 - if (typeof value !== 'object' || value === null) { - throw new Error(`web-plugins: ${name} has a non-object dshClient declaration`) - } - const decl = value as Record - if (typeof decl.platform !== 'string') { - throw new Error(`web-plugins: ${name} dshClient.platform must be a string`) - } - if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) { - throw new Error(`web-plugins: ${name} dshClient.inject must be a string array`) - } - if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') { - throw new Error(`web-plugins: ${name} dshClient.immediately must be a boolean`) - } - return { - platform: decl.platform, - ...(decl.inject !== undefined ? { inject: decl.inject as string[] } : {}), - ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}), - } -} - -/** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */ -function clientExportOf(name: string, exportsField: unknown): string | undefined { - if (typeof exportsField !== 'object' || exportsField === null) return undefined - const client = (exportsField as Record)['./client'] - if (client === undefined) return undefined - if (typeof client === 'string') return client - if (typeof client === 'object' && client !== null) { - const fallback = (client as Record).default - if (typeof fallback === 'string') return fallback - } - throw new Error(`web-plugins: ${name} exports["./client"] has an unsupported shape`) -} - -/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */ -function shortHash(input: string | Buffer): string { - return createHash('sha1').update(input).digest('hex').slice(0, 12) -} - -/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */ -function graphRow(id: string, rev: string, inject: string[] | undefined, immediately: boolean): WebBootEntry { - return { - id, - url: `/plugins/${id}/client.js?rev=${rev}`, - rev, - ...(inject !== undefined ? { inject } : {}), - ...(immediately ? { immediately: true } : {}), - } -} - -/** Compose the graph value from the current table. */ -function composeGraph(table: Map): WebBootGraph { - const entries = [...table.values()].map(record => record.entry) - return { rev: shortHash(JSON.stringify(entries)), entries } -} - -/** - * Build the web plugin registry: scan once synchronously (a malformed - * declaration, an unbuilt bundle, or an invalid watch interval throws here — - * load-time fail loud), then rescan on `internal/plugin`, microtask-debounced - * (failures go to `deps.onError`). With `deps.watch`, every scanned bundle - * file is stat-polled and a content change re-hashes the row and notifies - * `onRebuilt` subscribers. - * @param deps - loader view, resolution hook, error sink, and optional dev watch (see {@link WebPluginRegistryDeps}). - * @returns the registry handle. - */ -export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWebPluginRegistry { - const watchInterval = deps.watch === undefined ? undefined : deps.watch.intervalMs ?? 500 - if (watchInterval !== undefined && (!Number.isInteger(watchInterval) || watchInterval <= 0)) { - throw new Error(`web-plugins: watch.intervalMs must be a positive integer (got ${String(deps.watch?.intervalMs)})`) - } - - const stageWatches = ( - candidateTable: Map, - currentWatches: Map, - ): Map => { - const candidateWatches = new Map() - 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 => { - const record = table.get(id) - if (record === undefined) return undefined - const rev = shortHash(readFileSync(record.clientPath)) - record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true) - graph = composeGraph(table) - return rev - } - - // 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) { - 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 { - notify(id, rev) - } catch (error) { - deps.onError(error instanceof Error ? error : new Error(String(error))) - } - } - } - } - const watchTimer = watchInterval === undefined ? undefined : setInterval(pollWatches, watchInterval) - watchTimer?.unref() - - let pending = false - const unsubscribe = deps.ctx.on('internal/plugin', () => { - if (pending) return - pending = true - queueMicrotask(() => { - pending = false - try { - 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. - deps.onError(error instanceof Error ? error : new Error(String(error))) - } - }) - }) - - return { - graph: () => graph, - clientPath: id => table.get(id)?.clientPath, - rebuilt, - onRebuilt: (listener) => { - rebuildListeners.add(listener) - return () => { rebuildListeners.delete(listener) } - }, - dispose: () => { - unsubscribe() - if (watchTimer !== undefined) clearInterval(watchTimer) - watched.clear() - rebuildListeners.clear() - }, - } -} - -/** One full table build from the loader's current entries (bundle content is hashed here — an unreadable bundle throws). */ -function scan(deps: WebPluginRegistryDeps): Map { - const table = new Map() - for (const entry of deps.loader.entries()) { - if (entry.fiber === undefined || entry.disabled) continue - const name = entry.options.name - if (table.has(name)) continue - const pkgPath = deps.resolvePkgJson(name) - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record - const decl = parseDshClient(name, pkg.dshClient) - if (decl === undefined || decl.platform !== 'web') continue - const clientRel = clientExportOf(name, pkg.exports) - if (clientRel === undefined) { - throw new Error(`web-plugins: ${name} declares dshClient but exports no "./client" bundle`) - } - const clientPath = join(dirname(pkgPath), clientRel) - const rev = shortHash(readFileSync(clientPath)) - table.set(name, { entry: graphRow(name, rev, decl.inject, decl.immediately === true), clientPath }) - } - return table -} diff --git a/packages/host/webserver/tests/invariant.spec.ts b/packages/host/webserver/tests/invariant.spec.ts deleted file mode 100644 index f9d5ba4490..0000000000 --- a/packages/host/webserver/tests/invariant.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Webserver invariant companion: the boot-graph consistency audit — every - * fetch-arrival graph row must resolve a clientPath, checked on fiber - * lifecycle events against the assembly-published 'webPlugins' context key. - */ -import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' -import InvariantService from '@deepseek-ai/dsh-invariants' -import * as WebserverInvariant from '../src/invariant.ts' - -interface RegistryStub { - graph(): { entries: { id: string; url: string }[] } - clientPath(id: string): string | undefined -} - -async function setup(registry?: RegistryStub): Promise { - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await ctx.plugin(WebserverInvariant).await() - if (registry !== undefined) ctx.reflect.provide('webPlugins', registry) - return ctx -} - -/** Fire the audit trigger directly (same technique as the scope invariant - * spec): a synchronous emit propagates the fail() throw to the caller. */ -function trigger(ctx: Context): void { - ;(ctx.emit as (event: string, ...args: unknown[]) => void)('internal/plugin', ctx.fiber) -} - -describe('webserver manifest invariant', () => { - it('stays silent without a registry (carrier-only deployment) and with a consistent table', async () => { - const bare = await setup() - expect(() => { trigger(bare) }).not.toThrow() // no 'webPlugins' key published - - const consistent = await setup({ - graph: () => ({ entries: [{ id: 'p1', url: '/plugins/p1/client.js?rev=abc' }] }), - clientPath: id => id === 'p1' ? '/tmp/p1/lib/client.js' : undefined, - }) - expect(() => { trigger(consistent) }).not.toThrow() - }) - - it('throws on a graph row whose bundle path no longer resolves', async () => { - const ctx = await setup({ - graph: () => ({ entries: [{ id: 'ghost', url: '/plugins/ghost/client.js?rev=abc' }] }), - clientPath: () => undefined, - }) - expect(() => { trigger(ctx) }) - .toThrow(/graph row "ghost".*resolves no client bundle path/) - }) -}) diff --git a/packages/host/webserver/tests/web-plugins.spec.ts b/packages/host/webserver/tests/web-plugins.spec.ts deleted file mode 100644 index 0acff4d78b..0000000000 --- a/packages/host/webserver/tests/web-plugins.spec.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { - mkdirSync, - mkdtempSync, - statSync, - type PathLike, - type Stats, - unlinkSync, - utimesSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Context } from 'cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createHostWebPluginRegistry, injectBootManifest } from '../src/index.ts' -import type { LoaderEntryView, WebPluginRegistryDeps } from '../src/index.ts' - -const fsControl = vi.hoisted(() => ({ failNextStatPath: undefined as string | undefined })) - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - statSync: (path: PathLike): Stats => { - if (String(path) === fsControl.failNextStatPath) { - fsControl.failNextStatPath = undefined - throw Object.assign(new Error('staged bundle missing'), { code: 'ENOENT' }) - } - return actual.statSync(path) - }, - } -}) - -afterEach(() => { - fsControl.failNextStatPath = undefined - vi.useRealTimers() -}) - -/** Write a fake installed package (package.json + optional client bundle) and return its package.json path. */ -function makePkg(root: string, name: string, pkg: Record, withBundle = true): string { - const dir = join(root, name.replaceAll('/', '__')) - mkdirSync(join(dir, 'lib'), { recursive: true }) - writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...pkg })) - if (withBundle) writeFileSync(join(dir, 'lib', 'client.js'), `// bundle of ${name}`) - return join(dir, 'package.json') -} - -const webDecl = (extra: Record = {}): Record => ({ - dshClient: { inject: [], platform: 'web', ...extra }, - exports: { '.': './lib/index.js', './client': './lib/client.js' }, -}) - -interface Fixture { - deps: WebPluginRegistryDeps - entries: LoaderEntryView[] - errors: Error[] - ctx: Context - root: string -} - -function makeDeps( - specs: { name: string; pkg: Record; loaded?: boolean; disabled?: boolean; withBundle?: boolean }[], -): Fixture { - const root = mkdtempSync(join(tmpdir(), 'dsh-webplugins-')) - const paths = new Map() - const entries: LoaderEntryView[] = specs.map((spec) => { - paths.set(spec.name, makePkg(root, spec.name, spec.pkg, spec.withBundle ?? true)) - return { options: { name: spec.name }, fiber: spec.loaded === false ? undefined : {}, disabled: spec.disabled ?? false } - }) - const ctx = new Context() - const errors: Error[] = [] - const deps: WebPluginRegistryDeps = { - ctx, - loader: { entries: () => entries }, - resolvePkgJson: (name) => { - const path = paths.get(name) - if (path === undefined) throw new Error(`unresolvable ${name}`) - return path - }, - onError: err => void errors.push(err), - } - return { deps, entries, errors, ctx, root } -} - -describe('createHostWebPluginRegistry', () => { - it('discovers dshClient rows with rev-stamped urls, manifest inject edges, and the declared immediately mark', () => { - const { deps } = makeDeps([ - { name: '@deepseek-ai/dsh-client-connection', pkg: webDecl({ immediately: true }) }, - { name: '@deepseek-ai/dsh-client-ui-layout', pkg: webDecl({ inject: ['@deepseek-ai/dsh-client-runtime'] }) }, - { name: '@deepseek-ai/dsh-agent', pkg: { exports: { '.': './lib/index.js' } } }, // no dshClient: skipped - ]) - const registry = createHostWebPluginRegistry(deps) - const graph = registry.graph() - expect(graph.rev).toMatch(/^[0-9a-f]{12}$/) - const connection = graph.entries[0] - expect(connection?.id).toBe('@deepseek-ai/dsh-client-connection') - expect(connection?.rev).toMatch(/^[0-9a-f]{12}$/) - expect(connection?.url).toBe(`/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=${connection?.rev ?? ''}`) - expect(connection?.immediately).toBe(true) - const layout = graph.entries[1] - expect(layout?.id).toBe('@deepseek-ai/dsh-client-ui-layout') - expect(layout?.inject).toEqual(['@deepseek-ai/dsh-client-runtime']) - expect(layout?.immediately).toBeUndefined() - expect(graph.entries).toHaveLength(2) - expect(registry.clientPath('@deepseek-ai/dsh-client-ui-layout')).toMatch(/lib[/\\]client\.js$/) - expect(registry.clientPath('@deepseek-ai/dsh-agent')).toBeUndefined() - registry.dispose() - }) - - it('skips entries that are unloaded, disabled, or declare another platform', () => { - const { deps } = makeDeps([ - { name: 'not-loaded', pkg: webDecl(), loaded: false }, - { name: 'disabled', pkg: webDecl(), disabled: true }, - { name: 'electron-only', pkg: { dshClient: { platform: 'electron' }, exports: { './client': './lib/client.js' } } }, - ]) - const registry = createHostWebPluginRegistry(deps) - expect(registry.graph().entries).toEqual([]) - registry.dispose() - }) - - it('fails loud at build time on a dshClient declaration without a "./client" export', () => { - const { deps } = makeDeps([ - { name: 'broken', pkg: { dshClient: { platform: 'web' }, exports: { '.': './lib/index.js' } } }, - ]) - expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/) - }) - - it('fails loud at build time on a registered bundle that is not built (rev hashing reads the file)', () => { - const { deps } = makeDeps([{ name: 'unbuilt', pkg: webDecl(), withBundle: false }]) - expect(() => createHostWebPluginRegistry(deps)).toThrow(/ENOENT/) - }) - - it('fails loud on malformed declaration fields', () => { - for (const dshClient of [42, { platform: 7 }, { platform: 'web', inject: 'nope' }, { platform: 'web', immediately: 'yes' }]) { - const { deps } = makeDeps([{ name: 'bad', pkg: { dshClient, exports: { './client': './lib/client.js' } } }]) - expect(() => createHostWebPluginRegistry(deps)).toThrow(/dshClient/) - } - }) - - it('rebuilt(id) re-hashes the bundle, updates the row and graph rev, and keeps the immediately mark', () => { - const { deps, root } = makeDeps([{ name: 'hot', pkg: webDecl({ immediately: true }) }]) - const registry = createHostWebPluginRegistry(deps) - const before = registry.graph() - const beforeRow = before.entries.find(e => e.id === 'hot') - writeFileSync(join(root, 'hot', 'lib', 'client.js'), '// rebuilt bundle contents') - const rev = registry.rebuilt('hot') - expect(rev).toMatch(/^[0-9a-f]{12}$/) - expect(rev).not.toBe(beforeRow?.rev) - const after = registry.graph() - const afterRow = after.entries.find(e => e.id === 'hot') - expect(afterRow?.rev).toBe(rev) - expect(afterRow?.url).toBe(`/plugins/hot/client.js?rev=${rev ?? ''}`) - expect(afterRow?.immediately).toBe(true) - expect(after.rev).not.toBe(before.rev) - // Unknown ids are not rebuildable. - expect(registry.rebuilt('nope')).toBeUndefined() - registry.dispose() - }) - - it('watch mode: a bundle content change re-hashes the row and notifies onRebuilt; dispose stops the watch', async () => { - const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }]) - deps.watch = { intervalMs: 20 } - const registry = createHostWebPluginRegistry(deps) - const before = registry.graph().entries[0]?.rev - const rebuilds: { id: string; rev: string }[] = [] - registry.onRebuilt((id, rev) => rebuilds.push({ id, rev })) - - writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// new bundle contents') - await vi.waitFor(() => { expect(rebuilds).toHaveLength(1) }, { timeout: 5000 }) - expect(rebuilds[0]?.id).toBe('watched') - expect(rebuilds[0]?.rev).not.toBe(before) - expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev) - - registry.dispose() - writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// post-dispose contents') - await new Promise((resolve) => { setTimeout(resolve, 100) }) - expect(rebuilds).toHaveLength(1) - }) - - it('watch mode: a failed rescan baseline preserves the published table and graph', async () => { - const { deps, entries, errors, ctx, root } = makeDeps([ - { name: 'stable', pkg: webDecl() }, - { name: 'late', pkg: webDecl(), loaded: false }, - ]) - deps.watch = { intervalMs: 1_000 } - const registry = createHostWebPluginRegistry(deps) - const before = registry.graph() - - ;(entries[1] as { fiber?: unknown }).fiber = {} - fsControl.failNextStatPath = join(root, 'late', 'lib', 'client.js') - ctx.emit('internal/plugin', ctx.fiber) - await Promise.resolve() - - expect(errors[0]?.message).toContain('staged bundle missing') - expect(registry.graph()).toBe(before) - expect(registry.clientPath('late')).toBeUndefined() - - ctx.emit('internal/plugin', ctx.fiber) - await Promise.resolve() - expect(registry.graph().entries.map(row => row.id)).toEqual(['stable', 'late']) - registry.dispose() - }) - - it('watch mode: a missing bundle forces a re-hash when identical metadata reappears', async () => { - vi.useFakeTimers() - const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }]) - const bundle = join(root, 'watched', 'lib', 'client.js') - const fixedTime = new Date(1_600_000_000_000) - utimesSync(bundle, fixedTime, fixedTime) - deps.watch = { intervalMs: 20 } - const registry = createHostWebPluginRegistry(deps) - const baseline = statSync(bundle) - const rebuilds: { id: string; rev: string }[] = [] - registry.onRebuilt((id, rev) => rebuilds.push({ id, rev })) - - unlinkSync(bundle) - await vi.advanceTimersByTimeAsync(20) - 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.advanceTimersByTimeAsync(20) - - expect(rebuilds).toHaveLength(1) - expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev) - registry.dispose() - }) - - it('rejects a non-positive or non-integer watch interval at build time', () => { - for (const intervalMs of [0, -5, 1.5]) { - const { deps } = makeDeps([{ name: 'p', pkg: webDecl() }]) - deps.watch = { intervalMs } - expect(() => createHostWebPluginRegistry(deps)).toThrow(/watch\.intervalMs/) - } - }) - - it('rescans on internal/plugin (debounced) and keeps the old graph when a rescan fails', async () => { - const { deps, entries, errors, ctx } = makeDeps([ - { name: 'late-loader', pkg: webDecl(), loaded: false }, - ]) - const registry = createHostWebPluginRegistry(deps) - expect(registry.graph().entries).toEqual([]) - - // Entry finishes loading; a fiber lifecycle event triggers the debounced rescan. - ;(entries[0] as { fiber?: unknown }).fiber = {} - ctx.emit('internal/plugin', ctx.fiber) - ctx.emit('internal/plugin', ctx.fiber) // debounce: two emissions, one rescan - await Promise.resolve() - expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader']) - - // A failing rescan reports the error and keeps serving the previous graph. - entries.push({ options: { name: 'ghost' }, fiber: {}, disabled: false }) - ctx.emit('internal/plugin', ctx.fiber) - await Promise.resolve() - expect(errors).toHaveLength(1) - expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader']) - - // After dispose, further fiber events no longer rescan. - registry.dispose() - entries.pop() - ctx.emit('internal/plugin', ctx.fiber) - await Promise.resolve() - expect(errors).toHaveLength(1) - }) -}) - -describe('injectBootManifest', () => { - it('injects the graph as the first script inside and escapes breakouts', () => { - const html = '' - const out = injectBootManifest(html, { - rev: 'r1', - entries: [{ id: 'x