Files
deepseek-harness/docs/subsystems/web-server.zh.md
T
imccyu 73931626be feat(webserver): add resource routes and in-process readResource (#6901)
* feat(webserver): add resource routes and in-process readResource

A webserver route or the fallback seat may now answer through a resource,
a function from a Fetch Request to a Response, besides the existing
node:http handler. register accepts either route form and registerFallback
accepts a handler or { resource }; existing handler callers are unchanged.
HTTP GET and HEAD requests reach a resource; webServer.readResource(request,
peer) follows the same match order and reaches only resources, so handler
routes stay unreachable to in-process readers.

client-modules registers /plugins as the fetchBundle resource, and
frontend-static claims the fallback seat with a resource that renders the
index for a supplied Peer, or runs Connection's existing authorizeIndex for
an HTTP request and renders for the browser Peer.

* fix(webserver): flush resource headers and keep one drain listener per response

Resource responses send their status and headers before the first body
chunk, register one drain/close listener pair per response so the gzip
middleware's zlib stream does not accumulate listeners across backpressure
waits, and readResource drops the body of a HEAD read as HTTP does. HTTP
requests and readResource share one responder lookup.

* chore(scripts): prune the retired client-modules unknown-cast baseline entry

* feat(frontend-static): let browsers revalidate dist files except the index

Every dist file other than the rendered index now carries
cache-control: no-cache and a weak ETag from a SHA-1 of its bytes; a
request whose If-None-Match equals that ETag receives an empty 304. The
index keeps sending no cache headers. In-process reads through
webServer.readResource revalidate the same way.

* fix(webserver): stop reading Host for resource URLs and copy no held body

A resource request's URL is the request target resolved against the same
placeholder origin the route match uses, so a missing or unparsable Host no
longer turns a dist or /plugins request into 400. The webserver ends a
resource response itself and treats any earlier close as an abort, which
also covers the Web Worker tunnel marking an aborted response ended first.
client-modules bundle bodies and frontend-static file bodies are the held
bytes themselves instead of copies, and frontend-static no longer exports
its internal serveStatic helper.

* docs(webserver): document resource route types and the handler-or-resource fallback

Paste WebResource and WebResourceRoute beside WebRoute on the web-server
subsystem page and link the catalog signatures to it, replace the remaining
'fallback handler' wording now that the seat takes a handler or a resource,
and note why the drain listener removal is a no-op under gzip.
2026-10-22 13:41:18 +08:00

348 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# HTTP 服务器
[English](web-server.md) | 中文
[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退(handler 或 resource)。它不属于 agent loop(智能体循环),也不是能力 seam;它唯一涉及的 harness 概念是 index 渲染所代表的 Peer。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
## 路由
```ts type-equiv
/** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/<anything>. */
type WebRouteKind = 'exact' | 'prefix'
```
```ts type-equiv
/** 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>
}
```
```ts type-equiv
/**
* Answers one GET or HEAD request with a Fetch response. An HTTP request
* arrives without a Peer and carries the client's headers; an in-process
* {@link WebServer.readResource} read passes the Peer its caller admitted.
*/
type WebResource = (request: Request, peer?: PeerScope) => Promise<Response>
```
```ts type-equiv
/** One named route answered by a resource instead of a handler. */
interface WebResourceRoute {
kind: WebRouteKind
/** Absolute pathname, no trailing slash. */
path: string
/** Answers HTTP GET and HEAD requests and in-process reads. */
resource: WebResource
}
```
匹配顺序固定:先查 exact 表,再取最长匹配前缀,最后落到已注册的回退。注册顺序不携带任何面向请求的语义:具名路由在组合上互不相交,任何未被具名路由认领的请求都由回退席位应答;席位只有一个所有者,第二次注册会抛出异常。发布的 Web 组合用 [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts) 认领席位,即遵循固定语义的 SPA dist 服务器:Connection 在读取 dist 根目录和配置 index 的 HTML 前完成认证;非 index 资产保持公开;非 GET/HEAD 返回 405,越出 dist 根目录的遍历返回 403,现有文件直接提供,缺失或不是文件的目标返回空的 404,未知扩展名按 octet-stream 发送。
路由或回退可以用 resource——一个把 Fetch `Request` 映射为 `Response` 的函数——代替 handler。HTTP 的 `GET`、`HEAD` 请求不带 Peer 到达它,`readResource(request, peer)` 则在进程内按调用方准入的 Peer 读取它;读取遵循同样的匹配顺序但只到达 resource,因此返回 owner 私有数据或会执行操作的路由即使应答 `GET` 也保持为 handler。
<a id="config"></a>
## 配置
```ts type-equiv
/** Web server listen, TLS, and response-compression config. */
interface Config {
/**
* Listen address: a concrete IPv4 or IPv6 literal of one local interface,
* for example the container's own Pod address from `hostname -i`. A loopback
* literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
* server on this machine; any other literal serves the network that address
* belongs to, over plain HTTP unless `tls` is set. The unspecified address —
* IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at
* load: it would expose the port on every interface at once.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/**
* Serve HTTPS with this certificate and key instead of plain HTTP. Both files
* are read once, before the listener binds: an unreadable or empty file,
* invalid PEM, or a key that does not match the certificate rejects
* initialization rather than falling back to HTTP. The material is never
* re-read, so replacing a certificate takes a reload. Omitted or null listens
* over plain HTTP.
*/
tls?: TlsConfig
/** 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
}
```
```ts type-equiv
/**
* TLS material for the HTTPS listener: one certificate chain file and the
* private key file it pairs with. Both hold PEM text and both resolve against
* the process working directory.
*/
interface TlsConfig {
/** Certificate chain file, leaf certificate first, PEM, no passphrase. */
certFile: string
/** Private key file for the chain's leaf certificate; unencrypted PEM. */
keyFile: string
}
```
没有组合能一次性绑定所有网络接口:未指定地址在加载时即被拒绝。导出的 `isWildcardHost` 与 `isLoopbackHost` 按解析后的地址值分类,IPv6 的 `%zone` 会保留给 `listen`,而分类只读取地址本身。载体不拥有认证或 Origin 策略,并且除非 `tls` 提供证书与密钥,否则以明文 HTTP 提供服务。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。随附的 `dsh web` 命令默认选择 loopback 并接受一个具体的 `--host` 地址,非 loopback 绑定以明文 HTTP 提供服务时会在启动时警告;其 Connection 插件为每个 Host API route 与 stream 提供 Host/Origin 校验和浏览器会话认证,绑定地址加 `--trusted-host` 决定接受哪个浏览器 Host;`--public-url` 只改变公告的 URL。其他组合自行拥有绑定与路由认证策略。dist 位置是认领席位的前端插件的组装事实。
## 服务
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 handler 继续直接持有 `ServerResponse`,resource 则把 `Response` 交给服务写回。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections(peer)` 经一次传入页面所代表 Peer 的 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html, peer)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。
处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。TLS socket 只有完成握手后才计入连接,因此服务器会把原始 TLS socket 与升级后的 socket 一并跟踪,并在 dispose 时销毁两者。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## 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`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxconnection--hostconnectionhandle"></a>
### `ctx.connection` — `HostConnectionHandle`
Host `ctx.connection` members consumed by transport-independent adapters.
```ts cordis-catalog
/**
* Compose exact Fetch routes and the shared-channel RPC interceptor.
* @param channel - shared channel mounted by Connection.
* @returns Fetch handler for trusted, authenticated requests.
*/
createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler
/**
* Apply Connection's Host/Origin checks and browser authentication to
* another Web route.
* @param request - request headers from the HTTP or upgrade request.
* @returns rejection status, or undefined when the route may accept the request.
*/
requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection
/**
* Admit an HTTP request against its signed browser or API credential and live Peer.
* @param request - request headers from the HTTP or upgrade request.
* @returns the credential's Peer, or the rejection status.
*/
admit(request: ConnectionTrustRequest): PeerAdmission
/**
* Create an independent in-process Peer, disposed with the Context that read `ctx.connection`.
* @param options - fixed principal and optional interaction participation (default `answer`).
* @returns a new Peer distinct from every other Peer.
*/
createPeer(options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' }): PeerScope
/**
* Claim a physical socket for an API Peer; browser sockets share their owner Peer.
* @param request - authenticated upgrade request headers.
* @returns the Peer and idempotent release, or 409 for duplicate API ownership.
* The carrier releases on socket termination or failed upgrade; release removes
* API admission synchronously without cancelling independent HTTP calls.
*/
admitConnection(request: ConnectionTrustRequest): ConnectionAdmission
/**
* Authenticate one frontend index request, owning a token redirect or 401.
* The cookie this mints is `Secure` when the mounted Web carrier serves TLS,
* which only that listener's protocol decides.
* @param request - root or configured-index HTTP request.
* @param response - response owned when the result is false.
* @returns true only when the frontend may serve index.html.
*/
authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean
/**
* Add the fresh process token to an ordinary Web application URL.
* @param baseUrl - clean application URL whose authority and mount are preserved.
* @returns tokenized URL for initial login; a mount proxy strips its prefix before {@link authorizeIndex}.
*/
authenticatedUrl(baseUrl: string): string
```
Types: [Principal](typert.zh.md)
Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connection/src/rpc.ts)
<a id="ctxwebapp--webapp"></a>
### `ctx.webApp` — `WebApp`
The Web GUI this process serves, provided by the `web-runtime` row.
```ts cordis-catalog
/**
* The URL this GUI advertises, also used for `DSH_WEB_URL` and the startup URL line.
* @returns the configured `publicUrl`, otherwise the listener origin.
*/
getAdvertisedUrl(): string
```
Source: [`packages/bundle/web-app/src/index.ts`](../../packages/bundle/web-app/src/index.ts)
<a id="ctxwebserver--webserver"></a>
### `ctx.webServer` — `WebServer`
The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and a request no named route matches is answered 404 until the fallback owner registers.
```ts cordis-catalog
/**
* The root URL local clients use to reach the bound listener: the protocol,
* the {@link bindUrlHost} form of the bind address, and the listening port.
* @returns the origin, for example `http://127.0.0.1:3080`.
* @throws before `listen` binds a port, or when the bind address has no URL form.
*/
getOrigin(): string
/**
* 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 or resource.
* @returns the disposer removing the route.
*/
register(route: WebRoute | WebResourceRoute): () => 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 or resource 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 fallback - a handler owning the full response lifecycle of
* unmatched requests, or `{ resource }` answering them.
* @returns the disposer releasing the seat.
*/
registerFallback(fallback: WebRoute['handler'] | Pick<WebResourceRoute, 'resource'>): () => void
/**
* Read the response an HTTP request for the same URL would receive, as the
* resource computes it for `peer`, without an HTTP exchange. The request
* path picks the responder by the HTTP match order — exact, longest prefix,
* then the fallback — and only a resource answers. This method checks no
* cookie, Host, or Origin: the caller admits the reader before reading.
* @param request - the read; its URL path selects the responder.
* @param peer - the Peer the read speaks for, passed to the resource.
* @returns the resource's response, without a body for HEAD as over HTTP;
* 405 with `Allow: GET, HEAD` for any other method, checked before matching,
* where over HTTP a handler route or an unmatched path answers such a
* method itself; 404 when the path matches a handler route, or reaches the
* fallback seat while it is unclaimed or holds a handler. A resource that
* throws rejects the read, where an HTTP request would receive 400.
*/
async readResource(request: Request, peer: PeerScope): Promise<Response>
/**
* Register a raw-HTML index transform, the escape hatch for markup no
* {@link IndexInjection} row expresses: {@link renderIndex} applies taps in
* registration order after rendering the structured rows.
* @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
/**
* Gather the structured injection table for one Peer: one
* `webserver/index-inject` emit, every subscriber pushes its current rows.
* Fresh per call, so subscribers read live state (module graph, theme
* preference, the Peer's access) at emit time.
* @param peer - the Peer the page speaks for.
* @returns rows in subscriber activation order.
*/
collectIndexInjections(peer: PeerScope): IndexInjection[]
/**
* Render one index.html body for one Peer: the structured injection table
* first, then the raw `tapIndex` transforms over the result.
* @param html - the raw index.html body.
* @param peer - the Peer the page speaks for.
* @returns the transformed body.
*/
renderIndex(html: string, peer: PeerScope): string
```
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
<a id="connection-events"></a>
### `connection/*` events
<a id="connectionrequest--waterfall"></a>
#### `connection/request` — waterfall
Admit or wrap an authenticated shared API request, including body transfer. Existing requests continue when a listener refuses subsequent requests.
```ts cordis-catalog
/**
* Admit or wrap an authenticated shared API request, including body transfer.
* Existing requests continue when a listener refuses subsequent requests.
* @param request - Authenticated incoming HTTP request.
* @param response - Response owned until the delegated bridge settles.
* @param next - Delegate to the next listener or the shared API bridge.
* @mode waterfall
*/
'connection/request'(request: IncomingMessage, response: ServerResponse, next: () => Promise<void>): Promise<void>
```
Source: [`packages/client/connection/src/index.ts`](../../packages/client/connection/src/index.ts)
<a id="webserver-events"></a>
### `webserver/*` events
<a id="webserverindex-inject--emit"></a>
#### `webserver/index-inject` — emit
Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
```ts cordis-catalog
/**
* Collect the structured index injection table for the page `peer` speaks
* for. Emitted on every index render and every worker boot-payload
* request; listeners push their current rows, so a row's data is read
* fresh at emit time.
* @param table - Mutable row table; listeners append in activation order.
* @param peer - The Peer the rendered page speaks for.
* @mode emit
*/
'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void
```
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
<!-- END GENERATED cordis-surface -->