Web Server
September 5, 2026 · View on GitHub
English | 中文
dsh-host-webserver is the browser HTTP/HTTPS carrier for the GUI host: a single Node server plugin providing ctx.webServer, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the /api bridge, plugin bundles, and the HMR event stream (layering note). It serves browsers only: Electron loads the built files over file:// and sends fetch requests through an IPC bridge instead of this server.
Source: packages/host/webserver/src/index.ts
Routes
/** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/<anything>. */
type WebRouteKind = 'exact' | 'prefix'
/** One named route registration. */
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>
}
Match order is fixed: exact table first, then longest matching prefix, then the registered fallback. Registration order carries no request-facing semantics — named routes are composed to be disjoint, and the fallback seat answers anything no named route claims; one owner only, a second registration throws. The shipped Web composition claims the seat with dsh-host-frontend-static, the SPA dist server with locked semantics: non-GET/HEAD is 405, traversal outside the dist root is 403, any miss falls back to index.html with HTTP 200 (SPA routing), and unknown extensions ship as octet-stream.
Config
/** Web server listen and response-compression config. */
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
/** Response compression for socket-backed HTTP requests. @default 'none' */
compression?: 'none' | 'gzip'
/** Gzip DEFLATE level from 0 through 9. @default 1 */
compressionLevel?: number
/** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
compressionThresholdBytes?: number
/** TLS certificate path for HTTPS/WSS serving. */
tlsCertPath?: string
/** TLS private key path for HTTPS/WSS serving. */
tlsKeyPath?: string
}
host accepts only 127.0.0.1 and 0.0.0.0. Loopback may use HTTP; all-interfaces binding requires paired certificate and key paths and serves HTTPS/WSS. Authentication and browser-origin policy remain separate connection-layer responsibilities. compression defaults to none; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The dist location is an assembly fact of the frontend plugin that claims the seat.
The service
WebServer (ctx.webServer) listens immediately on activation; incomplete TLS configuration, plaintext all-interfaces binding, certificate reads, or socket listen failures reject initialization before readiness. register(route) adds one named route and returns its disposer; a duplicate (kind, path) throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct ServerResponse ownership and no response-writing API is added to the service; authentication and routing observe no change. Existing content encodings (the pre-compressed static assets and the /api bridge's negotiated replies), Cache-Control: no-transform, range responses, and SSE remain identity responses. tapIndex(transform) adds a pure html-to-html transform applied to every index response in registration order; dsh-client-modules uses it to inject the boot manifest. port, host, and protocol expose the active listener facts.
A request whose handling throws is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. An incomplete request reset by its client ends quietly because no response peer remains. Disposal pairs close() with closeAllConnections() because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the README.
Cordis API
Generated from source by scripts/gen-cordis-catalog.ts (verified fresh by pnpm run verify-cordis-catalog in doc-sync; regenerate with pnpm run gen-cordis-catalog) — this section is byte-identical in both language sides of the page. Signature blocks use a ts cordis-catalog fence and keep the original source JSDoc; dispatch modes are defined in the primer, and the framework-inherited ctx API lives in cordis-api/inherited.md.
ctx.webServer — WebServer
The browser Web carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber.
/**
* 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
/**
* Register an exact-path HTTP upgrade route. Duplicate paths throw because
* one socket can have only one protocol owner.
* @param route - pathname and handler owning negotiation plus socket use.
* @returns the disposer removing the route.
*/
registerUpgrade(route: WebUpgradeRoute): () => void
/**
* Claim the fallback seat: the handler answering every request no named
* route matches (the SPA dist server in the shipped Web composition). One
* owner only — a second registration throws, because two fallbacks cannot
* compose.
* @param handler - owns the full response lifecycle of unmatched requests.
* @returns the disposer releasing the seat.
*/
registerFallback(handler: WebRoute['handler']): () => void
/**
* Register an index.html transform, applied by the fallback owner to every
* index response ({@link applyIndexTaps}) in registration order.
* @param transform - pure html-to-html function.
* @returns the disposer removing the transform.
*/
tapIndex(transform: (html: string) => string): () => void
/**
* Run an index.html body through the registered taps in registration order
* — called by the fallback owner on every index response it renders.
* @param html - the raw index.html body.
* @returns the transformed body.
*/
applyIndexTaps(html: string): string