feat(web): move connection downlinks to WebSocket

This commit is contained in:
imccyu
2026-08-04 16:07:35 +08:00
parent bcf595f41c
commit 8b4ddfe60c
35 changed files with 876 additions and 91 deletions

View File

@@ -1,5 +1,5 @@
/**
* events domain contract: signatures and frame unions for the two SSE
* events domain contract: signatures and frame unions for the two logical
* streams. Four-quadrant: streams yield the narrow form `RpcRequest<Frame>` (server-request
* view) — rpcId must be exposed to the business layer, because responses to answerable frames
* (approval/question requested) echo it; for pure pushes it identifies that one push.
@@ -42,7 +42,7 @@ export interface QueuedInboxItem {
message: Message
}
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
/** Streaming face of the contract: the two logical stream openers (mux + host). */
export interface EventsApi {
/**
* All-session aggregated mux stream. On open, emits a subscribed control frame for every

View File

@@ -1,7 +1,7 @@
/**
* apiproxy contract-layer barrel. api/ has zero Node dependencies and is
* importable from the browser; the TS interfaces are the authoritative contract, HTTP/SSE are
* merely physical channels (four-quadrant message model).
* importable from the browser; the TS interfaces are the authoritative contract, while HTTP,
* WebSocket, and in-process SSE are merely physical channels (four-quadrant message model).
*/
import type { SessionsApi } from './sessions.ts'

View File

@@ -1,7 +1,7 @@
/**
* Four-quadrant RPC message model. Channels and messages are
* decoupled: HTTP is the client→server physical channel, SSE the server→client one; logical
* messages are channel-independent, and the wire full form is a four-member discriminated union.
* Four-quadrant RPC message model. Channels and messages are decoupled: HTTP,
* WebSocket, and in-process SSE are physical carriers, while logical messages
* are channel-independent and form a four-member discriminated union.
* api/ contract layer: zero Node dependencies, importable from the browser.
*/
@@ -147,7 +147,7 @@ export interface ServerResponse {
}
/**
* Message initiated by the server (wire carrier: SSE frame). Answerable interactions
* Message initiated by the server (wire carrier: downstream stream frame). Answerable interactions
* (approval/question requested — stable rpcId, reused on replay) and pure pushes
* (session/event etc. — rpcId identifies that one push) share this shape; whether a
* response is expected is determined statically by method (a strict dichotomy, no third kind).

View File

@@ -1,6 +1,6 @@
/**
* Client side of the fetch carrier. AbstractApiClient holds every protocol invariant: rpcId minting,
* four-quadrant envelope wrap/unwrap, zod parsing, SSE frame decoding, and the payload-direct
* four-quadrant envelope wrap/unwrap, zod parsing, in-process SSE frame decoding, and the payload-direct
* IApiClient domain methods (business code never mints). Platform differences ride two aspects:
* abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched.
*/
@@ -69,8 +69,8 @@ import {
* Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls
* carry only that external signal. In both cases the signal rides beside the request, never
* on the wire, like the stream signatures.
* Stream methods accept an optional onOpen callback: it fires once the SSE transport is
* readable (response headers received, before any frame) — the "stream established" signal
* Stream methods accept an optional onOpen callback: it fires once the physical transport is
* readable (before any frame) — the "stream established" signal
* connection controllers need for the readiness handshake. Generators are lazy, so the
* underlying fetch (and therefore onOpen) only happens once iteration starts.
* Relationship: ApiProxy is the narrow-form signature contract the impl side implements;

View File

@@ -5,7 +5,7 @@
* platform subclasses on the client side), and the host-side implementation
* (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
* routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves.
* routes — physical carriers wrap `ctx.apiProxy` themselves.
*/
import { resolve } from 'node:path'

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/webserver/README.md
README.md: c3c7b222683bc7731a6c21f2fffd325225099bab
README.zh.md: 99c0560eb74dc8076772ba1deef3034000f5f0db
README.md: f01c1a66b19e4f49b9cad31a6d41555aecb28168
README.zh.md: 980bc3dbac4dac2e758e0043ead292fb5a42e674

View File

@@ -2,17 +2,17 @@
English | [中文](README.zh.md)
Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer` `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, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). 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.
Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP 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, and non-GET/HEAD is 405. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
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.
The package knows no harness concepts: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, while plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `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.
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. 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.
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP 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. An upgrade-handler exception is logged as a warning and destroys its socket. Disposal first calls `close()` and `closeAllConnections()`, then destroys upgraded sockets the webserver still tracks so they cannot hold teardown open.
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 routes other plugins register; nothing here reaches a model request.
None, as the package is a Web carrier between the browser and the HTTP/upgrade routes other plugins register; nothing here reaches a model request.
#### KV Cache effect

View File

@@ -2,17 +2,17 @@
[English](README.md) | 中文
朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer``register(route)` 添加具名的 `exact``prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist并遵循固定语义越出 dist 根目录的遍历返回 403任何未命中项都以 HTTP 200 回退到 `index.html`SPA 路由),未知扩展名按 octet-stream 提供GETHEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。
Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer``register(route)` 添加具名的 `exact``prefix` HTTP route`registerUpgrade(route)` 添加精确 pathname 的 upgrade route同一张表内的重复路径会抛错因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 会移除注册`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后回退到静态 dist并遵循固定语义越出 dist 根目录的遍历返回 403任何未命中项都以 HTTP 200 回退到 `index.html`SPA 路由),未知扩展名按 octet-stream 提供GETHEAD 之外的方法返回 405。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。
该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR热模块替换事件流则是 moduleshmr 插件的路由`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web浏览器形态Electron 通过 `file://` 加载 dist并经 IPC 桥接承载 fetch而不使用本服务器。该包从不打印内容URL 行属于 shell。
该包不了解任何 harness 概念:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR热模块替换事件流则是 moduleshmr 插件的 route。upgrade handler 拥有协议握手与连接内容webserver 只交付原始 socket 与 request`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web浏览器形态Electron 通过 `file://` 加载 dist并经 IPC 桥接承载 fetch而不使用本服务器。该包从不打印内容URL 行属于 shell。
监听失败EADDRINUSE……会从激活过程抛出以 bind 诊断使 Loader 组合 reject失败的候选 fiber 会被 dispose资源释放。处理请求时抛错例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400若响应头已经发出则销毁 socket并记录 warning但绝不会退出进程。资源释放会 `close()``closeAllConnections()` 配对,因为一直保持打开的 SSEServer-Sent Events响应不会自行结束
监听失败EADDRINUSE……会从激活过程抛出以 bind 诊断使 Loader 组合 reject失败的候选 fiber 会被 dispose资源释放。处理 HTTP 请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400若响应头已经发出则销毁 socket并记录 warning但绝不会退出进程。upgrade handler 抛错会记录 warning 并销毁其 socket。资源释放会先调用 `close()``closeAllConnections()`,再销毁 webserver 仍跟踪的升级 socket确保升级连接不会悬住 teardown
在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map再统一发布因此基线失败会保留先前的图。这样即时重建不会消失在异步建立的监听基线中重命名窗口会把路径标记为脏保留最近一次成功基线并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。
## 模型体验
无。该包只是浏览器与其他插件所注册路由之间的纯 HTTP 载体,其中没有任何内容会进入模型请求。
无。该包只是浏览器与其他插件所注册 HTTPupgrade route 之间的 Web 载体,其中没有任何内容会进入模型请求。
#### KV 缓存影响

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-host-webserver",
"description": "Plain HTTP route-registration plugin: named-route registry (webServer service) + index transform taps + static dist fallback; knows no harness concepts",
"description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -1,10 +1,9 @@
/**
* @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
* @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http
* server plus the `httpServer` service (HTTP and upgrade route registries,
* index transform taps, and static dist fallback). Knows no harness concepts;
* feature plugins own every registered protocol. Web shape only — Electron
* loads dist over file:// and carries fetch over an IPC bridge. This package
* never prints: the URL line belongs to the shell.
*/
@@ -12,6 +11,7 @@ import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse, Server } from 'node:http'
import { readFile } from 'node:fs/promises'
import type { AddressInfo } from 'node:net'
import type { Duplex } from 'node:stream'
import { dirname } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
@@ -35,6 +35,14 @@ export interface WebRoute {
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
}
/** One exact-path HTTP upgrade registration. */
export interface WebUpgradeRoute {
/** Absolute pathname, no trailing slash. */
path: string
/** Owns protocol negotiation and the upgraded socket after dispatch. */
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
}
/** 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. */
@@ -61,6 +69,8 @@ export class HttpServerService extends Service {
private readonly exact = new Map<string, WebRoute>()
private readonly prefixes = new Map<string, WebRoute>()
private readonly upgrades = new Map<string, WebUpgradeRoute>()
private readonly upgradedSockets = new Set<Duplex>()
private readonly indexTaps: ((html: string) => string)[] = []
private readonly distRoot: string
private readonly distIndex: string
@@ -98,6 +108,20 @@ export class HttpServerService extends Service {
return () => { table.delete(route.path) }
}
/**
* 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 {
if (this.upgrades.has(route.path)) {
throw new Error(`webserver: duplicate upgrade route "${route.path}"`)
}
this.upgrades.set(route.path, route)
return () => { this.upgrades.delete(route.path) }
}
/**
* Register an index.html transform, applied to every index response in
* registration order.
@@ -147,6 +171,32 @@ export class HttpServerService extends Service {
res.end()
})
})
this.server.on('upgrade', (req, socket, head) => {
let route: WebUpgradeRoute | undefined
try {
/* v8 ignore next -- node:http always sets url on server requests. */
route = this.upgrades.get(new URL(req.url ?? '/', 'http://x').pathname)
} catch (error) {
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
socket.destroy()
return
}
if (route === undefined) {
socket.destroy()
return
}
this.upgradedSockets.add(socket)
socket.once('close', () => { this.upgradedSockets.delete(socket) })
try {
Promise.resolve(route.handler(req, socket, head)).catch((error: unknown) => {
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
socket.destroy()
})
} catch (error) {
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
socket.destroy()
}
})
await new Promise<void>((resolve, reject) => {
this.server.once('error', reject)
@@ -158,11 +208,12 @@ export class HttpServerService extends Service {
})
})
// close + closeAllConnections: held-open responses (SSE) never end on
// their own; without the force-close, close() would hang teardown.
// Node does not include upgraded sockets in closeAllConnections(), so the
// service tracks and destroys them as part of the same ownership boundary.
this.ctx.effect(() => () => new Promise<void>((resolve) => {
this.server.close(() => { resolve() })
this.server.closeAllConnections()
for (const socket of this.upgradedSockets) socket.destroy()
}), 'httpServer.listen')
}

View File

@@ -15,7 +15,7 @@ export const name = 'host-webserver-invariant'
export const inject = ['invariants']
/**
* Owned relation: route registrations and their disposers must stay
* Owned relation: HTTP and upgrade 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
@@ -26,7 +26,10 @@ export const inject = ['invariants']
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/plugin', () => {
const server = ctx.get('httpServer') as
| { register(route: { kind: 'exact'; path: string; handler: () => void }): () => void }
| {
register(route: { kind: 'exact'; path: string; handler: () => void }): () => void
registerUpgrade(route: { path: string; handler: () => void }): () => void
}
| undefined
if (server === undefined) return // no webserver row in this composition
// Register/dispose probe on a reserved path: if dispose leaves the route
@@ -37,8 +40,11 @@ const install: InvariantInstaller = (ctx, fail) => {
try {
server.register(probe)()
server.register(probe)()
const upgradeProbe = { path: '/__dsh_invariant_upgrade_probe__', handler: () => {} }
server.registerUpgrade(upgradeProbe)()
server.registerUpgrade(upgradeProbe)()
} catch {
fail('httpServer.register() disposer left the route registered — route table and fiber lifecycles diverged')
fail('httpServer route disposer left a route registered — route tables and fiber lifecycles diverged')
}
}, { global: true })
}

View File

@@ -7,6 +7,8 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdir } from 'node:fs/promises'
import { once } from 'node:events'
import { connect } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -72,6 +74,24 @@ async function request(port: number, path: string, init?: RequestInit): Promise<
return { status: response.status, body: (await response.text()).slice(0, 80) }
}
/** Open one raw upgrade request and return after the handler writes its response. */
async function upgrade(port: number, path: string): Promise<ReturnType<typeof connect>> {
const socket = connect(port, '127.0.0.1')
await once(socket, 'connect')
const response = once(socket, 'data')
socket.write([
`GET ${path} HTTP/1.1`,
`Host: 127.0.0.1:${String(port)}`,
'Connection: Upgrade',
'Upgrade: dsh-test',
'',
'',
].join('\r\n'))
const [data] = await response
expect(String(data)).toContain('101 Switching Protocols')
return socket
}
describe('real Loader composition', () => {
// Real-Loader composition resolves workspace packages through tsx at test
// time; first resolution after the host/client program split is slow enough
@@ -131,8 +151,25 @@ describe('real Loader composition', () => {
expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback
expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow()
// Teardown: fiber dispose closes the socket and severs held connections.
// Upgrade routes match exact pathnames, reject duplicate ownership, and
// become registrable again after disposal. The accepted socket stays open
// so the teardown assertion also covers upgraded-connection ownership.
const disposeUpgrade = server.registerUpgrade({
path: '/events',
handler: (_req, socket) => {
socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: dsh-test\r\n\r\n')
},
})
expect(() => server.registerUpgrade({ path: '/events', handler: () => {} }))
.toThrow(/duplicate upgrade route/)
const upgraded = await upgrade(port, '/events?stream=mux')
disposeUpgrade()
expect(() => server.registerUpgrade({ path: '/events', handler: () => {} })).not.toThrow()
// Teardown closes both ordinary and upgraded sockets before it resolves.
const upgradedClosed = once(upgraded, 'close')
await loaded.fiber.dispose()
await upgradedClosed
await expect(request(port, '/probe')).rejects.toThrow()
})