* 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.
348 lines
18 KiB
Markdown
348 lines
18 KiB
Markdown
# 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 -->
|