Web 服务器

September 5, 2026 · View on GitHub

English | 中文

dsh-host-webserver 是 GUI 宿主的浏览器 HTTP/HTTPS 载体:它是一个提供 ctx.webServer 的 Node 服务器插件,包含具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的 fallback handler。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 /api 桥接、插件 bundle 和 HMR(热模块替换)事件流(分层说明)。该服务器只服务浏览器:Electron 通过 file:// 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。

源码:packages/host/webserver/src/index.ts

路由

/** 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>
}

匹配顺序固定:先查 exact 表,再取最长匹配前缀,最后落到已注册的回退。注册顺序不携带任何面向请求的语义:具名路由在组合上互不相交,任何未被具名路由认领的请求都由回退席位应答;席位只有一个所有者,第二次注册会抛出异常。发布的 Web 组合用 dsh-host-frontend-static 认领席位,即遵循固定语义的 SPA dist 服务器:非 GET/HEAD 返回 405,越出 dist 根目录的遍历返回 403,任何未命中都以 HTTP 200 回退到 index.html(SPA 路由),未知扩展名按 octet-stream 发送。

配置

/** 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 只接受 127.0.0.10.0.0.0。回环可以使用 HTTP;全接口绑定要求成对提供证书与密钥路径,并通过 HTTPS/WSS 服务。认证与浏览器 origin 策略仍由独立的 connection 层负责。compression 默认为 none;发布的 Web 组合选择 gzip 级别 1 与 1024 字节阈值。dist 位置是认领席位的前端插件的组装事实。

服务

WebServerctx.webServer)在激活时立即监听;TLS 配置不完整、全接口明文绑定、证书读取失败或 socket 监听失败都会在就绪前拒绝初始化。register(route) 添加一条具名路由并返回其 disposer;重复的 (kind, path) 抛出异常,因为路由模式是组合层约定,冲突即配置错误。gzip 在服务器内部包装符合条件的、有 socket 支撑的响应,因此 route handler 仍持有直接的 ServerResponse 所有权,服务不新增任何写响应的 API;认证与路由观察不到任何变化。已有 content encoding 的响应(预压缩静态资源与 /api 桥接协商后的回复)、带 Cache-Control: no-transform、range 响应与 SSE 保持 identity 响应。tapIndex(transform) 添加纯 HTML 到 HTML 转换,并按注册顺序应用于每个 index 响应;dsh-client-modules 用它注入启动 manifest(元数据清单)。porthostprotocol 暴露活动 listener 的事实。

处理过程中抛出异常的请求会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。客户端重置的未完整请求会安静结束,因为已不存在响应对端。dispose(资源释放)把 close()closeAllConnections() 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 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.webServerWebServer

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

Source: packages/host/webserver/src/index.ts:119