Przeglądaj źródła

refactor(web): rewrite webserver as a plain route-registration plugin

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.
imccyu 2 miesięcy temu
rodzic
commit
f499872cec

+ 6 - 8
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.

+ 4 - 1
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:^"

+ 154 - 202
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/<id>/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<HostWebPluginRegistry, 'graph' | 'clientPath' | 'onRebuilt'>
+
+declare module 'cordis' {
+  interface Context {
+    httpServer: HttpServerService
+  }
+}
+
+/** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/<anything>. */
+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<void>
 }
 
-/** Listening web server handle. */
-export interface RunningWebServer {
-  /** The listening port, including the OS-assigned value when options.port is zero. */
+/** 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<void>
+  /** 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<RunningWebServer> {
-  const { host, port, distIndex, apiHandler, webPlugins } = options
-  const distRoot = dirname(distIndex)
-  const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
-    const html = await readFile(distIndex, 'utf8')
-    return injectBootManifest(html, webPlugins.graph())
+export class HttpServerService extends Service {
+  static Config: z<Config> = 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(),
+  })
+
+  private readonly exact = new Map<string, WebRoute>()
+  private readonly prefixes = new Map<string, WebRoute>()
+  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)
   }
-  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
-
-  const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
-    /* 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
+
+  /** 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}"`)
     }
-    if (webPlugins !== undefined && rawPath.startsWith('/plugins/') && rawPath.endsWith('/client.js')) {
-      await servePluginBundle(decodeURIComponent(rawPath), res, webPlugins)
-      return
+    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)
     }
-    await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex)
   }
-  // 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()
+
+  /** Listen; resolves once the socket is bound (rejection = FAILED fiber). */
+  async [Service.init](): Promise<void> {
+    const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
+      /* 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
+      }
+      // 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
       }
-      res.writeHead(400)
-      res.end()
+      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()
+      })
     })
-  })
 
-  let closing: Promise<void> | undefined
-  const close = (): Promise<void> => (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 })
+    await new Promise<void>((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()
+      })
     })
-  })
-}
 
-/**
- * Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
- * first script in <head> (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 = `<script>window.__DSH_BOOT__ = ${json}</script>`
-  const head = html.indexOf('<head>')
-  if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
-  // Headless fixture pages may lack <head>; 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<HostWebPluginRegistry, 'clientPath'>,
-): Promise<void> {
-  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()
+    // close + closeAllConnections: held-open responses (SSE) never end on
+    // their own; without the force-close, close() would hang teardown.
+    this.ctx.effect(() => () => new Promise<void>((resolve) => {
+      this.server.close(() => { resolve() })
+      this.server.closeAllConnections()
+    }), 'httpServer.listen')
   }
-}
 
-/** 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<void> {
-  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<void>((resolve) => {
-        const done = (): void => {
-          res.off('drain', done)
-          res.off('close', done)
-          resolve()
-        }
-        res.once('drain', done)
-        res.once('close', done)
-      })
+  /** 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<string> {
+    let html = await readFile(this.distIndex, 'utf8')
+    for (const transform of this.indexTaps) html = transform(html)
+    return html
   }
-  res.end()
 }
+
+export default HttpServerService

+ 20 - 18
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/<id>/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 })
 }

+ 0 - 56
packages/host/webserver/src/plugin-events.ts

@@ -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<ServerResponse>()
-  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)
-    },
-  }
-}

+ 0 - 360
packages/host/webserver/src/web-plugins.ts

@@ -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/<id>/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/<id>/client.js?rev=<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<LoaderEntryView>
-}
-
-/** 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<string, unknown>
-  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<string, unknown>)['./client']
-  if (client === undefined) return undefined
-  if (typeof client === 'string') return client
-  if (typeof client === 'object' && client !== null) {
-    const fallback = (client as Record<string, unknown>).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<string, WebPluginRecord>): 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<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 => {
-    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<string, WebPluginRecord> {
-  const table = new Map<string, WebPluginRecord>()
-  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<string, unknown>
-    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
-}

+ 0 - 50
packages/host/webserver/tests/invariant.spec.ts

@@ -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<Context> {
-  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/)
-  })
-})

+ 0 - 347
packages/host/webserver/tests/web-plugins.spec.ts

@@ -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<typeof import('node:fs')>()
-  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<string, unknown>, 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<string, unknown> = {}): Record<string, unknown> => ({
-  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<string, unknown>; loaded?: boolean; disabled?: boolean; withBundle?: boolean }[],
-): Fixture {
-  const root = mkdtempSync(join(tmpdir(), 'dsh-webplugins-'))
-  const paths = new Map<string, string>()
-  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 <head> and escapes </script> breakouts', () => {
-    const html = '<html><head><script src="app.js"></script></head><body></body></html>'
-    const out = injectBootManifest(html, {
-      rev: 'r1',
-      entries: [{ id: 'x</script><script>alert(1)', url: '/plugins/x/client.js?rev=r2', rev: 'r2' }],
-    })
-    expect(out.indexOf('window.__DSH_BOOT__')).toBeLessThan(out.indexOf('app.js'))
-    expect(out).not.toContain('</script><script>alert(1)')
-    expect(out).toContain('\\u003c/script')
-  })
-
-  it('prepends when the page has no <head>', () => {
-    const out = injectBootManifest('<body>x</body>', { rev: 'r0', entries: [] })
-    expect(out.startsWith('<script>window.__DSH_BOOT__')).toBe(true)
-  })
-})
-
-describe('clientExportOf shapes (through the registry build)', () => {
-  it('accepts the conditional {types, default} export form', () => {
-    const { deps } = makeDeps([{
-      name: 'conditional',
-      pkg: {
-        dshClient: { platform: 'web' },
-        exports: { './client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' } },
-      },
-    }])
-    const registry = createHostWebPluginRegistry(deps)
-    expect(registry.clientPath('conditional')).toMatch(/lib[/\\]client\.js$/)
-    registry.dispose()
-  })
-
-  it('rejects a conditional form without a string default, an array form, and a non-object exports field', () => {
-    for (const exportsField of [
-      { './client': { types: './x.d.ts' } },
-      { './client': ['./a.js'] },
-    ]) {
-      const { deps } = makeDeps([{ name: 'bad-shape', pkg: { dshClient: { platform: 'web' }, exports: exportsField } }])
-      expect(() => createHostWebPluginRegistry(deps)).toThrow(/unsupported shape/)
-    }
-    // Non-object exports: treated as "no ./client export" → the declares-but-no-bundle throw.
-    const { deps } = makeDeps([{ name: 'no-exports', pkg: { dshClient: { platform: 'web' }, exports: './single.js' } }])
-    expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
-  })
-
-  it('skips duplicate loader entries for the same package name (first wins)', () => {
-    const { deps, entries } = makeDeps([{ name: 'dup-entry', pkg: webDecl() }])
-    const first = entries[0] as LoaderEntryView
-    entries.push({ options: { name: 'dup-entry' }, fiber: {}, disabled: false })
-    void first
-    const registry = createHostWebPluginRegistry(deps)
-    expect(registry.graph().entries.filter(r => r.id === 'dup-entry')).toHaveLength(1)
-    registry.dispose()
-  })
-
-  it('rejects a null conditional form and wraps a non-Error rescan throw', async () => {
-    // client: null → the object-form branch's null guard.
-    const nulled = makeDeps([{ name: 'null-client', pkg: { dshClient: { platform: 'web' }, exports: { './client': null } } }])
-    expect(() => createHostWebPluginRegistry(nulled.deps)).toThrow(/unsupported shape/)
-
-    // Non-Error rescan throw: resolvePkgJson throws a string; onError must get a wrapped Error.
-    const { deps, entries, errors, ctx } = makeDeps([{ name: 'ok-one', pkg: webDecl() }])
-    const registry = createHostWebPluginRegistry(deps)
-    entries.push({ options: { name: 'ghost-two' }, fiber: {}, disabled: false })
-    const original = deps.resolvePkgJson
-    deps.resolvePkgJson = (name) => {
-
-      if (name === 'ghost-two') throw 'string failure'
-      return original(name)
-    }
-    ctx.emit('internal/plugin', ctx.fiber)
-    await Promise.resolve()
-    expect(errors[0]).toBeInstanceOf(Error)
-    expect(String(errors[0])).toContain('string failure')
-    registry.dispose()
-  })
-
-})

+ 0 - 400
packages/host/webserver/tests/webserver.spec.ts

@@ -1,400 +0,0 @@
-import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
-import { Server as NetServer } from 'node:net'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { startWebServer, type RunningWebServer } from '../src/index.ts'
-
-/** dist fixture: index.html + one asset of each MIME class + a subdir. */
-function makeDist(): { distIndex: string; distRoot: string } {
-  const distRoot = mkdtempSync(join(tmpdir(), 'dsh-webserver-'))
-  writeFileSync(join(distRoot, 'index.html'), '<html>INDEX</html>')
-  writeFileSync(join(distRoot, 'app.js'), 'console.log(1)')
-  writeFileSync(join(distRoot, 'app.css'), 'body{}')
-  writeFileSync(join(distRoot, 'logo.svg'), '<svg/>')
-  writeFileSync(join(distRoot, 'data.json'), '{}')
-  writeFileSync(join(distRoot, 'app.js.map'), '{}')
-  writeFileSync(join(distRoot, 'blob.bin'), 'BIN')
-  mkdirSync(join(distRoot, 'sub'))
-  writeFileSync(join(distRoot, 'sub', 'page.html'), '<html>SUB</html>')
-  return { distIndex: join(distRoot, 'index.html'), distRoot }
-}
-
-const echoingApi = {
-  fetch: async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
-    const req = input instanceof Request ? input : new Request(input, init)
-    if (req.url.endsWith('/api/echo')) {
-      return Response.json({ method: req.method, body: await req.text(), header: req.headers.get('x-probe') })
-    }
-    if (req.url.endsWith('/api/empty')) return new Response(null, { status: 204 })
-    if (req.url.endsWith('/api/big')) {
-      // Chunks far above any socket highWaterMark force res.write to return false.
-      const big = new Uint8Array(4 * 1024 * 1024).fill(65)
-      const stream = new ReadableStream<Uint8Array>({
-        start(controller) {
-          controller.enqueue(big)
-          controller.enqueue(big)
-          controller.close()
-        },
-      })
-      return new Response(stream, { headers: { 'content-type': 'application/octet-stream' } })
-    }
-    if (req.url.endsWith('/api/sse')) {
-      const encoder = new TextEncoder()
-      const stream = new ReadableStream<Uint8Array>({
-        start(controller) {
-          controller.enqueue(encoder.encode('data: one\n\n'))
-          controller.enqueue(encoder.encode('data: two\n\n'))
-          controller.close()
-        },
-      })
-      return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
-    }
-    if (req.url.endsWith('/api/throw-string')) {
-      // Non-Error rejection: the guard must wrap it for onError.
-      throw 'string failure'
-    }
-    if (req.url.endsWith('/api/explode-mid-stream')) {
-      // Headers go out with the first chunk, then the source errors: the
-      // guard's headersSent leg must destroy the socket, not writeHead again.
-      // The error is deferred a tick so the 200 + first chunk actually flush
-      // to the client before the teardown.
-      const stream = new ReadableStream<Uint8Array>({
-        start(controller) {
-          controller.enqueue(new TextEncoder().encode('data: first\n\n'))
-          setTimeout(() => { controller.error(new Error('stream exploded')) }, 20)
-        },
-      })
-      return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
-    }
-    if (req.url.endsWith('/api/abort-probe')) {
-      // Endless SSE that only ends when the request signal aborts.
-      const stream = new ReadableStream<Uint8Array>({
-        start(controller) {
-          req.signal.addEventListener('abort', () => {
-            try {
-              controller.close()
-            } catch { /* already closed by teardown: nothing else can reach this */ }
-          }, { once: true })
-          controller.enqueue(new TextEncoder().encode('data: open\n\n'))
-        },
-      })
-      return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
-    }
-    return new Response('nope', { status: 404 })
-  },
-}
-
-let server: RunningWebServer | undefined
-
-afterEach(async () => {
-  await server?.close()
-  server = undefined
-})
-
-async function boot(onError: (err: Error) => void = () => undefined): Promise<string> {
-  const { distIndex } = makeDist()
-  server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, onError)
-  return `http://127.0.0.1:${String(server.port)}`
-}
-
-describe('startWebServer', () => {
-  it('reports the listening port and closes idempotently', async () => {
-    const { distIndex } = makeDist()
-    server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
-    expect(server.port).toBeGreaterThan(0)
-    const first = server.close()
-    const second = server.close()
-    expect(second).toBe(first)
-    await first
-    server = undefined
-  })
-
-  it.each(['127.0.0.1', '0.0.0.0'])('forwards bind address %s without opening a socket', async (host) => {
-    const { distIndex } = makeDist()
-    const port = 3080
-    const listen = vi.spyOn(NetServer.prototype, 'listen').mockImplementation(function (
-      this: NetServer, ...args: unknown[]
-    ): NetServer {
-      const callback = args.at(-1)
-      if (typeof callback !== 'function') throw new TypeError('listen callback missing')
-      queueMicrotask(callback as () => void)
-      return this
-    })
-    const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port })
-    try {
-      const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined)
-      expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function))
-      await inertServer.close()
-    } finally {
-      address.mockRestore()
-      listen.mockRestore()
-    }
-  })
-
-  it('rejects when the port is already taken', async () => {
-    const { distIndex } = makeDist()
-    server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
-    const { port } = server
-    await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined))
-      .rejects.toMatchObject({ code: 'EADDRINUSE' })
-  })
-})
-
-describe.skipIf(process.platform === 'win32')('static serving', () => {
-  it('serves index at /, subpaths by MIME, octet-stream for unknown, SPA fallback on miss', async () => {
-    const base = await boot()
-    const index = await fetch(`${base}/`)
-    expect(index.status).toBe(200)
-    expect(index.headers.get('content-type')).toBe('text/html; charset=utf-8')
-    expect(await index.text()).toBe('<html>INDEX</html>')
-
-    expect((await fetch(`${base}/app.js`)).headers.get('content-type')).toBe('text/javascript; charset=utf-8')
-    expect((await fetch(`${base}/app.css`)).headers.get('content-type')).toBe('text/css; charset=utf-8')
-    expect((await fetch(`${base}/logo.svg`)).headers.get('content-type')).toBe('image/svg+xml')
-    expect((await fetch(`${base}/data.json`)).headers.get('content-type')).toBe('application/json')
-    expect((await fetch(`${base}/app.js.map`)).headers.get('content-type')).toBe('application/json')
-    expect((await fetch(`${base}/blob.bin`)).headers.get('content-type')).toBe('application/octet-stream')
-    expect(await (await fetch(`${base}/sub/page.html`)).text()).toBe('<html>SUB</html>')
-
-    const miss = await fetch(`${base}/routes/deep/link`)
-    expect(miss.status).toBe(200)
-    expect(await miss.text()).toBe('<html>INDEX</html>')
-  })
-
-  it('403s traversal outside the dist root and 405s non-GET/HEAD', async () => {
-    const base = await boot()
-    // %2e%2e would be dot-collapsed by WHATWG URL parsing on both ends; an
-    // encoded slash keeps the segment intact until the server's decodeURIComponent.
-    const traversal = await fetch(`${base}/..%2f..%2fetc%2fpasswd`)
-    expect(traversal.status).toBe(403)
-    const put = await fetch(`${base}/index.html`, { method: 'PUT', body: 'x' })
-    expect(put.status).toBe(405)
-  })
-
-  it('answers HEAD like GET (no 405)', async () => {
-    const base = await boot()
-    const head = await fetch(`${base}/`, { method: 'HEAD' })
-    expect(head.status).toBe(200)
-  })
-})
-
-describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injection + bundle endpoint + events channel)', () => {
-  const FETCH_ID = '@deepseek-ai/dsh-client-ui-layout'
-  const graphValue = {
-    rev: 'graphrev00001',
-    entries: [
-      { id: '@deepseek-ai/dsh-client-connection', url: '/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=eeee2222ffff', rev: 'eeee2222ffff', immediately: true },
-      { id: FETCH_ID, url: `/plugins/${FETCH_ID}/client.js?rev=aaaa0000bbbb`, rev: 'aaaa0000bbbb', inject: [] },
-    ],
-  }
-
-  /** Captures the server's onRebuilt subscription so tests can fire registry notifications by hand. */
-  interface RebuiltHarness {
-    notify: (id: string, rev: string) => void
-    unsubscribed: boolean
-  }
-
-  async function bootWithPlugins(harness?: RebuiltHarness): Promise<string> {
-    const { distIndex, distRoot } = makeDist()
-    writeFileSync(join(distRoot, 'bundle.js'), 'window.DSHClientProxy.loadPlugin({})')
-    const webPlugins = {
-      graph: () => graphValue,
-      clientPath: (id: string) => id === FETCH_ID ? join(distRoot, 'bundle.js') : undefined,
-      onRebuilt: (listener: (id: string, rev: string) => void) => {
-        if (harness !== undefined) harness.notify = listener
-        return () => {
-          if (harness !== undefined) harness.unsubscribed = true
-        }
-      },
-    }
-    server = await startWebServer(
-      { host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
-    )
-    return `http://127.0.0.1:${String(server.port)}`
-  }
-
-  it('injects the window.__DSH_BOOT__ graph into / and SPA fallbacks; asset requests stay verbatim', async () => {
-    const base = await bootWithPlugins()
-    const index = await (await fetch(`${base}/`)).text()
-    expect(index).toContain('window.__DSH_BOOT__')
-    const manifest = /window\.__DSH_BOOT__ = (.*?)<\/script>/.exec(index)?.[1]
-    expect(JSON.parse(manifest ?? '')).toEqual(graphValue)
-
-    const fallback = await (await fetch(`${base}/routes/deep/link`)).text()
-    expect(fallback).toContain('window.__DSH_BOOT__')
-    const direct = await (await fetch(`${base}/index.html`)).text()
-    expect(direct).toContain('window.__DSH_BOOT__')
-
-    expect(await (await fetch(`${base}/app.js`)).text()).toBe('console.log(1)')
-  })
-
-  it('serves registered client bundles with no-cache (rev query ignored) and 404s unknown ids (no SPA fallback)', async () => {
-    const base = await bootWithPlugins()
-    const bundle = await fetch(`${base}/plugins/${FETCH_ID}/client.js?rev=whatever`)
-    expect(bundle.status).toBe(200)
-    expect(bundle.headers.get('content-type')).toBe('text/javascript; charset=utf-8')
-    expect(bundle.headers.get('cache-control')).toBe('no-cache')
-    expect(await bundle.text()).toContain('DSHClientProxy')
-
-    expect((await fetch(`${base}/plugins/unknown/client.js`)).status).toBe(404)
-  })
-
-  it('404s a registered id whose bundle file is unreadable (unbuilt dist must fail loud, not fall back to HTML)', async () => {
-    const { distIndex } = makeDist()
-    const webPlugins = {
-      graph: () => graphValue,
-      clientPath: () => '/nonexistent/lib/client.js',
-      onRebuilt: () => () => undefined,
-    }
-    server = await startWebServer(
-      { host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
-    )
-    const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/${FETCH_ID}/client.js`)
-    expect(res.status).toBe(404)
-  })
-
-  it('keeps all plugin surfaces off without the webPlugins option', async () => {
-    const base = await boot()
-    expect(await (await fetch(`${base}/`)).text()).toBe('<html>INDEX</html>')
-    // No plugin routes: fall through to static SPA fallback semantics.
-    const res = await fetch(`${base}/plugins/x/client.js`)
-    expect(res.status).toBe(200)
-    expect(await res.text()).toBe('<html>INDEX</html>')
-    const events = await fetch(`${base}/plugins/events`)
-    expect(await events.text()).toBe('<html>INDEX</html>')
-  })
-
-  it('GET /plugins/events opens SSE with the current graph frame; a registry rebuild notification broadcasts', async () => {
-    const harness: RebuiltHarness = { notify: () => { throw new Error('onRebuilt never subscribed') }, unsubscribed: false }
-    const base = await bootWithPlugins(harness)
-    const events = await fetch(`${base}/plugins/events`)
-    expect(events.status).toBe(200)
-    expect(events.headers.get('content-type')).toBe('text/event-stream')
-    const reader = events.body?.getReader()
-    const decoder = new TextDecoder()
-    let buffer = ''
-    async function readUntil(marker: string): Promise<void> {
-      while (!buffer.includes(marker)) {
-        const chunk = await reader?.read()
-        if (chunk?.done !== false) throw new Error('SSE stream ended early')
-        buffer += decoder.decode(chunk.value, { stream: true })
-      }
-    }
-    await readUntil('"type":"graph"')
-    expect(buffer).toContain(': connected')
-    const graphLine = /data: (.*)\n\n/.exec(buffer)?.[1]
-    expect(JSON.parse(graphLine ?? '')).toEqual({ type: 'graph', graph: graphValue })
-
-    // The registry's bundle watch observed a rebuild: the server relays it as an SSE frame.
-    harness.notify(FETCH_ID, 'cccc1111dddd')
-    await readUntil('"type":"rebuilt"')
-    expect(buffer).toContain(JSON.stringify({ type: 'rebuilt', id: FETCH_ID, rev: 'cccc1111dddd' }))
-    await reader?.cancel()
-
-    // Shutdown unsubscribes the relay (no broadcast into a closed channel).
-    await server?.close()
-    server = undefined
-    expect(harness.unsubscribed).toBe(true)
-  })
-})
-
-describe('request-handling guard (one bad request must not kill the process)', () => {
-  it('400s malformed %-escapes, reports to onError, and stays alive', async () => {
-    const errors: Error[] = []
-    const base = await boot(err => errors.push(err))
-    for (const path of ['/%', '/%c0', '/%zz%']) {
-      expect((await fetch(`${base}${path}`)).status).toBe(400)
-    }
-    expect(errors.length).toBe(3)
-    expect(errors[0]?.name).toBe('URIError')
-    // The barrage left the server serving.
-    expect((await fetch(`${base}/`)).status).toBe(200)
-  })
-
-  it('wraps a non-Error throw for onError and still answers 400', async () => {
-    const errors: Error[] = []
-    const base = await boot(err => errors.push(err))
-    expect((await fetch(`${base}/api/throw-string`, { method: 'POST' })).status).toBe(400)
-    expect(errors[0]).toBeInstanceOf(Error)
-    expect(errors[0]?.message).toBe('string failure')
-  })
-
-  it('destroys the socket when the failure lands after headers went out', async () => {
-    const errors: Error[] = []
-    const base = await boot(err => errors.push(err))
-    const response = await fetch(`${base}/api/explode-mid-stream`)
-    expect(response.status).toBe(200) // headers made it out before the explosion
-    await expect(response.text()).rejects.toThrow() // then the socket is torn down
-    expect(errors.length).toBe(1)
-    expect((await fetch(`${base}/`)).status).toBe(200)
-  })
-})
-
-describe('/api bridge', () => {
-  it('forwards method, headers, and body; relays status and body back', async () => {
-    const base = await boot()
-    const response = await fetch(`${base}/api/echo`, {
-      method: 'POST',
-      headers: { 'content-type': 'application/json', 'x-probe': 'p1' },
-      body: JSON.stringify({ n: 1 }),
-    })
-    expect(response.status).toBe(200)
-    expect(await response.json()).toEqual({ method: 'POST', body: '{"n":1}', header: 'p1' })
-  })
-
-  it('relays a bodyless response', async () => {
-    const base = await boot()
-    const response = await fetch(`${base}/api/empty`, { method: 'POST' })
-    expect(response.status).toBe(204)
-    expect(await response.text()).toBe('')
-  })
-
-  it('streams SSE frames through chunk by chunk', async () => {
-    const base = await boot()
-    const response = await fetch(`${base}/api/sse`)
-    expect(response.headers.get('content-type')).toBe('text/event-stream')
-    expect(await response.text()).toBe('data: one\n\ndata: two\n\n')
-  })
-
-  it('waits for drain when a streamed chunk overfills the socket buffer', async () => {
-    // 4 MiB chunks dwarf the socket highWaterMark, so res.write returns false
-    // and the bridge parks on 'drain'; reading the body to completion proves
-    // the loop resumed instead of dropping the remainder.
-    const base = await boot()
-    const response = await fetch(`${base}/api/big`)
-    const body = new Uint8Array(await response.arrayBuffer())
-    expect(body.length).toBe(8 * 1024 * 1024)
-    expect(body[0]).toBe(65)
-    expect(body[body.length - 1]).toBe(65)
-  })
-
-  it('releases a drain wait when the client disconnects mid-chunk', async () => {
-    // The 'close' leg of the drain race: abort while the socket buffer is
-    // still full so the parked write wakes via 'close', not 'drain'.
-    const base = await boot()
-    const ac = new AbortController()
-    const response = await fetch(`${base}/api/big`, { signal: ac.signal })
-    const reader = response.body?.getReader()
-    const first = await reader?.read()
-    expect(first?.value?.length).toBeGreaterThan(0)
-    ac.abort()
-    // afterEach close() completing is the leak assertion, same as abort-probe.
-    await new Promise((resolve) => { setTimeout(resolve, 50) })
-  })
-
-  it('aborts the bridged request when the client disconnects mid-SSE', async () => {
-    const base = await boot()
-    const ac = new AbortController()
-    const response = await fetch(`${base}/api/abort-probe`, { signal: ac.signal })
-    const reader = response.body?.getReader()
-    expect(reader).toBeDefined()
-    const first = await reader?.read()
-    expect(new TextDecoder().decode(first?.value)).toContain('open')
-    ac.abort()
-    // server-side abort propagation has no client-observable handshake beyond
-    // the closed connection; close() would hang on a leaked live SSE socket,
-    // so afterEach completing IS the assertion that the bridge released it.
-    await new Promise((resolve) => { setTimeout(resolve, 50) })
-  })
-})

+ 3 - 0
packages/host/webserver/tsconfig.json

@@ -11,6 +11,9 @@
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../../../vendor/schemastery"
+    },
     {
       "path": "../../support/invariants"
     }