From 8b4ddfe60c751f4596e497b739ef6b4979104bc1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:07:35 +0800 Subject: [PATCH 1/7] feat(web): move connection downlinks to WebSocket --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 +- ...026-07-19-gui-layering-and-rpc-protocol.md | 14 +- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 14 +- ...7-19-gui-web-client-architecture.i18n.yaml | 4 +- .../2026-07-19-gui-web-client-architecture.md | 2 +- ...26-07-19-gui-web-client-architecture.zh.md | 2 +- ...08-04-websocket-downlink-carrier.i18n.yaml | 6 + .../2026-08-04-websocket-downlink-carrier.md | 39 +++++ ...026-08-04-websocket-downlink-carrier.zh.md | 39 +++++ packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 8 +- packages/client/connection/README.zh.md | 8 +- packages/client/connection/package.json | 6 +- packages/client/connection/src/api-path.ts | 10 +- .../connection/src/api-request-trust.ts | 4 +- .../connection/src/client/connection.ts | 2 +- .../connection/src/client/web-api-client.ts | 87 +++++++++- packages/client/connection/src/index.ts | 31 +++- .../connection/src/websocket-downlink.ts | 150 ++++++++++++++++ .../connection/tests/client-apply.spec.ts | 127 +++++++++++++- .../client/connection/tests/node-half.spec.ts | 65 +++++-- .../tests/websocket-downlink.spec.ts | 164 ++++++++++++++++++ packages/host/apiproxy/src/api/events.ts | 4 +- packages/host/apiproxy/src/api/index.ts | 4 +- packages/host/apiproxy/src/api/rpc.ts | 8 +- packages/host/apiproxy/src/fetch/client.ts | 6 +- packages/host/apiproxy/src/index.ts | 2 +- packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 8 +- packages/host/webserver/README.zh.md | 8 +- packages/host/webserver/package.json | 2 +- packages/host/webserver/src/index.ts | 67 ++++++- packages/host/webserver/src/invariant.ts | 12 +- .../host/webserver/tests/webserver.spec.ts | 39 ++++- pnpm-lock.yaml | 13 ++ 35 files changed, 876 insertions(+), 91 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md create mode 100644 .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md create mode 100644 packages/client/connection/src/websocket-downlink.ts create mode 100644 packages/client/connection/tests/websocket-downlink.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index ca90cebb0e..bdb07d5f8a 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 7ad2a2403eb9962b369b016070e8ca378ed55c60 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 90850c469f1444e7f6cd105551e6cc21920e91d9 +2026-07-19-gui-layering-and-rpc-protocol.md: 34077302c53081f6ee9171d64dce9af342710d71 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: bc51542ac8159ee7cba234b4ee8b4db47a7f9b58 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 7ad2a2403e..34077302c5 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) -> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation (HTTP+SSE) is in the [web client architecture RFC](2026-07-19-gui-web-client-architecture.md). +> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation combines HTTP uplink with the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md), while the browser object layer is in the [web client architecture RFC](2026-07-19-gui-web-client-architecture.md). ## Problem @@ -15,7 +15,7 @@ We need a UI integration layer. Beyond the existing ACP/stdio baseline, more pro That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly. -At the same time the physical channels differ per consumer (HTTP/SSE, in-process direct calls, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable. +At the same time the physical channels differ per consumer (browser HTTP/WebSocket, in-process fetch/SSE, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable. ## Decision @@ -64,7 +64,7 @@ On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Nod |---|---|---|---| | Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx | | Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | -| Carrier layer | `dsh-host-webserver` | Web-shape HTTP: static serving + `/api/*`→handler forwarding + SSE write-out + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | +| Carrier layer | `dsh-host-webserver` | Web-shape HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | | Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell | | Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture RFC | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy | | Application shape | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per shape (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Shapes dynamic-import so they never load each other; workspace knowledge like dist location stays in the app | @@ -88,7 +88,7 @@ The sections from here down are the protocol body carried by the front layer (`d ``` client 发起 server 发起 request ① ClientRequest ③ ServerRequest - (POST /api/ body) (SSE 帧:session 事件、审批/问答 requested) + (POST /api/ body) (WebSocket message:session 事件、审批/问答 requested) response ② ServerResponse ④ ClientResponse (该 POST 的 HTTP 应答体) (POST /api/respond body,回填 ③ 的 rpcId) ``` @@ -99,7 +99,7 @@ The sections from here down are the protocol body carried by the front layer (`d |---|---|---|---|---| | `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mints | `POST /api/` body | | `ServerResponse` | `'server-response'` | `rpcId` `result` | echoes ① | that POST's response body (always HTTP 200) | -| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mints | SSE `data:` line | +| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mints | WebSocket text message | | `ClientResponse` | `'client-response'` | `rpcId` `result` | echoes ③ | `POST /api/respond` body | `RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`, narrowed via `switch (message.type)`. @@ -169,7 +169,7 @@ The remaining methods (`session.create`/`session.history`/`session.rename`/`sess ### Frames (server→client, named unions) -Two SSE streams: the mux stream (`GET /api/events.mux`, all-session aggregate) and the host stream (`GET /api/events.host`, host-level events). One example frame row: +Two logical streams: the mux stream (`/api/events.mux`, all-session aggregate) and the host stream (`/api/events.host`, host-level events). The browser consumes one downlink WebSocket per stream, while the in-process fetch carrier retains SSE to preserve the same shape; see the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) for the physical boundary. One example frame row: | frame type | payload | when | |---|---|---| @@ -216,7 +216,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | Subclass | Package | doFetch | Purpose | |---|---|---|---| | `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh -p` headless is the protocol's second real consumer | -| `WebApiClient` | dsh-client-connection | `globalThis.fetch` (same-origin `/api/*`) | the browser shape; HTTP+SSE carriage details in the web client architecture RFC | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | | (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 90850c469f..bc51542ac8 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文 -> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现(HTTP+SSE)见 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md)。 +> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)组成,浏览器对象层见 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md)。 ## Problem @@ -14,7 +14,7 @@ Status: implemented 那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。 -同时各消费端的物理通道不同(HTTP/SSE、进程内直调、将来 IPC),还需要一个通道无关的消息模型和单一契约事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。 +同时各消费端的物理通道不同(浏览器 HTTP/WebSocket、进程内 fetch/SSE、将来 IPC),还需要一个通道无关的消息模型和单一契约事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。 ## Decision @@ -62,7 +62,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. |---|---|---|---| | 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api | | 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | -| 承载层 | `dsh-host-webserver` | Web 形态 HTTP:静态服务 + `/api/*`→handler 转发 + SSE 写出 + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | +| 承载层 | `dsh-host-webserver` | Web 形态 HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | | client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 | | client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构 RFC | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy | | 应用态 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每形态一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 形态间动态 import 互不加载;dist 定位等 workspace 知识留在 app | @@ -86,7 +86,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. ``` client 发起 server 发起 request ① ClientRequest ③ ServerRequest - (POST /api/ body) (SSE 帧:session 事件、审批/问答 requested) + (POST /api/ body) (WebSocket message:session 事件、审批/问答 requested) response ② ServerResponse ④ ClientResponse (该 POST 的 HTTP 应答体) (POST /api/respond body,回填 ③ 的 rpcId) ``` @@ -97,7 +97,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. |---|---|---|---|---| | `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mint | `POST /api/` body | | `ServerResponse` | `'server-response'` | `rpcId` `result` | 回填 ① | 该 POST 的应答体(恒 HTTP 200) | -| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mint | SSE `data:` 行 | +| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mint | WebSocket text message | | `ClientResponse` | `'client-response'` | `rpcId` `result` | 回填 ③ | `POST /api/respond` body | `RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`,`switch (message.type)` 窄化。 @@ -167,7 +167,7 @@ export type ResponseValue = ### 帧(server→client,具名 union) -两条 SSE 流:mux 流(`GET /api/events.mux`,全 session 聚合)与 host 流(`GET /api/events.host`,host 级事件)。帧示例一行: +两条逻辑流:mux 流(`/api/events.mux`,全 session 聚合)与 host 流(`/api/events.host`,host 级事件)。浏览器通过每流一条下行 WebSocket 消费,进程内 fetch 载体以 SSE 保持同构;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)。帧示例一行: | 帧 type | 载荷 | 何时发 | |---|---|---| @@ -214,7 +214,7 @@ export type ResponseValue = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| | `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh -p` headless 即协议第二真实消费者 | -| `WebApiClient` | dsh-client-connection | `globalThis.fetch`(同源 `/api/*`) | 浏览器形态;HTTP+SSE 承载落地见 Web 客户端架构 RFC | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器形态;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | (将来)IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,契约/基类零改 | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 61e6a93e23..20c1d99991 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: b1f777172774f1cf8fef4d9494f15b38064d0c73 -2026-07-19-gui-web-client-architecture.zh.md: e43151b7d5ff096d574c786e3aae107523d22c96 +2026-07-19-gui-web-client-architecture.md: b306f3b155d9d9208066c3f25ad2c4fb4683b1ee +2026-07-19-gui-web-client-architecture.zh.md: 28632667c45b360eb2bc5f0d06f10b9df910770d diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index b1f7771727..b306f3b155 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -73,7 +73,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation. - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. - **TranscriptAdapter / PartialAccumulator**: the transcript is the append-origin surface projected in log order (`isAppendSurfaceEvent` from `@deepseek-ai/dsh-session/surface`) plus one marker per landed compaction checkpoint — never the model surface, which shadows replaced ranges and would erase conversation the reader already saw. Node order is seq-monotonic by construction, so there is no core `seq === index` assertion to satisfy and no degradation branch. Chunks contribute no node (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark. -- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; the Web carriage (HTTP POST for the two client→server quadrants, SSE for the two server→client) and the client class family are the layering RFC's territory. +- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; Web carriage uses HTTP POST for the two client→server quadrants and [one WebSocket per logical stream](2026-08-04-websocket-downlink-carrier.md) for the two server→client quadrants, while the client class family remains the layering RFC's territory. ## The React face (`packages/client/web-react`) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index e43151b7d5..28632667c4 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -73,7 +73,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。 - **TranscriptAdapter / PartialAccumulator**:对话记录是按日志顺序投影的 append 来源 surface(`@deepseek-ai/dsh-session/surface` 的 `isAppendSurfaceEvent`),外加每次落地的压缩检查点一个标记——绝不用模型 surface,后者遮蔽被替换的范围,会抹掉读者已经看过的对话。节点顺序天然按 seq 单调,因此既无核心 `seq === index` 断言需要满足,也没有降级分支。分片不贡献任何节点(O(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记。 -- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载(HTTP POST 载两个 client→server 象限、SSE 载两个 server→client 象限)与客户端类族归分层 RFC 属地。 +- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载以 HTTP POST 载两个 client→server 象限、以[每逻辑流一条 WebSocket](2026-08-04-websocket-downlink-carrier.md)载两个 server→client 象限,客户端类族归分层 RFC 属地。 ## React 面(`packages/client/web-react`) diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml new file mode 100644 index 0000000000..44ba8854df --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md +2026-08-04-websocket-downlink-carrier.md: 3b1f5cf7c8109546c30e95f55321622442d5e182 +2026-08-04-websocket-downlink-carrier.zh.md: d642269dbd82b062dccc79490a631caaa5e70c81 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md new file mode 100644 index 0000000000..3b1f5cf7c8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md @@ -0,0 +1,39 @@ +# Agent Note: WebSocket carrier for browser downlinks + +Status: implemented + +English | [中文](2026-08-04-websocket-downlink-carrier.zh.md) + +## Problem + +The browser Web GUI has long used two SSE responses for `events.mux` and `events.host`. HTTP/1.1 browsers typically allow only about six concurrent connections per origin; each page permanently occupying two makes same-origin tabs, plugin resources, and ordinary RPCs contend for connection slots, and reaching the limit causes requests to queue rather than merely slowing them down. The RPC protocol itself is channel-independent: a constraint of the browser's physical carrier must not leak into the session/runtime object layer. + +## Decision + +The real browser carrier opens one independent WebSocket for each downlink stream class: `/api/events.mux` sends only `MuxFrame`, and `/api/events.host` sends only `HostFrame`. Each text message is one complete `ServerRequest` JSON document; the client continues to validate the envelope first, then the concrete frame union for that path, and passes the narrow `RpcRequest` form to the existing `ConnectionController`. The streams retain independent lifecycles and provide no cross-stream ordering guarantee; either one ending still fails the entire connection generation and rebuilds it under the existing backoff policy. + +WebSocket carries only the host→browser downlink. All client→host unary calls and `respond` operations for server requests continue to use the existing `POST /api/*`; the WebSocket accepts no client application messages. `WebApiClient` therefore holds HTTP `fetch` for uplink and WebSocket for downlink, while the fixture and `InProcessApiClient(toFetchHandler(api))` continue to implement the same two-stream `IApiClient` abstraction. The in-process fetch carrier retains SSE encoding and decoding to verify the channel-independent protocol's isomorphism, but network GET requests to `/api/events.*` answer only Upgrade Required and do not provide a browser compatibility fallback. + +## Upgrade and lifecycle boundaries + +`dsh-host-webserver` provides an exact upgrade-route registration seam alongside ordinary routes, dispatches Node upgrade sockets by pathname only, and destroys surviving upgraded connections during server teardown; it knows nothing about Harness frames or WebSocket messages. `dsh-client-connection` owns the WebSocket handshake, frame output, and stream cancellation, and reuses the `/api` Host/Origin trust fence before upgrade. An untrusted authority or cross-origin Origin is rejected before `ctx.apiProxy.events.*` starts. + +A browser abort, socket close, or plugin teardown cancels the corresponding host stream. If a host stream throws midway, the carrier sends one existing `stream/error` frame and then closes the socket; the client treats that frame as connection loss rather than delivering it to a business sink. Each WebSocket reports open independently, and the existing readiness handshake still waits until mux and host are both open and the `host.describe` HTTP call has succeeded before publishing connected. + +## Verification + +Webserver contract tests pin upgrade-pathname dispatch, duplicate-registration rejection, disposal, and teardown; connection real-network tests pin each WebSocket's trust check, open, schema envelope, frame order, stream error, and close cancellation; client tests also prove that downlinks create `ws:`/`wss:` URLs while unary calls and `respond` still use HTTP `fetch`. The assembled keyless browser replay continues to cover Chromium, a real host, HTTP uplink, and the full WebSocket downlink chain. + +## Alternatives considered + +**Multiplex mux and host over one WebSocket.** This would add a channel tag, a multiplexing queue, and a single-connection backpressure policy, and would change the existing two-stream readiness semantics. Two WebSockets already avoid the HTTP/1.1 six-connection limit while keeping this change in the physical carrier layer. + +**Move unary calls and respond to a full-duplex WebSocket as well.** This would rewrite timeout, cancellation, HTTP-status, trust-fence, and request-correlation behavior without adding any benefit for the current downlink connection-slot problem. HTTP uplink is an explicitly retained boundary. + +**Keep a network SSE fallback.** Two carriers would let the production browser path silently fork because of proxy or handshake differences and would leave the connection-limit problem in a supported branch. During prerelease, only WebSocket downlink ships; the existing reconnect behavior and connection state expose failures explicitly. + +**Rely on HTTP/2 for greater connection concurrency.** The built-in development server uses plaintext Node HTTP/1.1, and a deployment's fronting proxy is not a product invariant. The physical downlink directly uses a browser primitive outside that connection pool. + +## Consequences + +Each Web page still has two long-lived downlink connections, but they no longer consume the browser's six-connection HTTP/1.1 quota. The runtime continues to consume the original two streams and retains all reconnect, seam-repair, and cross-stream unordered semantics. The cost is one more upgrade-registration surface in the webserver, a WebSocket implementation dependency in the connection package's host half, and separate maintenance of the browser WebSocket and in-process SSE physical codecs. They share the same `ServerRequest`/frame schemas and `IApiClient` semantics, avoiding a second application protocol. diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md new file mode 100644 index 0000000000..d642269dbd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 浏览器下行 WebSocket 载体 + +Status: implemented + +[English](2026-08-04-websocket-downlink-carrier.md) | 中文 + +## Problem + +浏览器 Web GUI 的 `events.mux` 与 `events.host` 长期使用两条 SSE(Server-Sent Events)响应。HTTP/1.1 浏览器通常只允许每个来源约六条并发连接;每个页面永久占住两条会让同源多标签页、插件资源和普通 RPC 争抢连接槽,达到上限后不是降速而是排队阻塞。RPC 协议本身是通道无关的,约束来自浏览器物理载体,不应渗入 session/runtime 对象层。 + +## Decision + +浏览器真实载体为两类下行流各开一条独立 WebSocket:`/api/events.mux` 只发送 `MuxFrame`,`/api/events.host` 只发送 `HostFrame`。每条 text message 是一份完整的 `ServerRequest` JSON;客户端继续先校验信封,再按路径校验具体 frame union,并把窄形 `RpcRequest` 交给既有 `ConnectionController`。两条流保持独立生命周期和无跨流顺序保证,任一条结束仍使整个 connection generation 失败并按既有退避策略重建。 + +WebSocket 只承担 host→browser 下行。所有 client→host unary 调用和对 server request 的 `respond` 继续使用既有 `POST /api/*`;不在 WebSocket 上接收任何客户端业务 message。`WebApiClient` 因而同时持有 HTTP `fetch` 上行与 WebSocket 下行,而 fixture 和 `InProcessApiClient(toFetchHandler(api))` 继续实现同一 `IApiClient` 双流抽象。进程内 fetch 载体保留 SSE 编解码来检验通道无关的协议同构,但网络 `/api/events.*` GET 只回答 upgrade required,不作为浏览器兼容回退。 + +## Upgrade 与生命周期边界 + +`dsh-host-webserver` 提供与普通 route 并列的精确 upgrade-route 注册缝,只按 pathname 分发 Node upgrade socket,并在 server teardown 销毁仍存活的升级连接;它不认识 Harness 帧或 WebSocket message。`dsh-client-connection` 拥有 WebSocket handshake、frame 写出和 stream cancellation,并在 upgrade 前复用 `/api` 的 Host/Origin 信任栅栏。未受信任的 authority 或跨来源 Origin 在 `ctx.apiProxy.events.*` 启动前即被拒绝。 + +浏览器 abort、socket close 与 plugin teardown 都会取消对应的 host stream。host stream 中途抛错时,载体发送一份现有的 `stream/error` frame 后关闭 socket;客户端把该 frame 收敛为连接丢失,不投递给业务 sink。每条 WebSocket 独立报告 open,既有 readiness handshake 仍等待 mux、host 都 open 且 `host.describe` HTTP 调用成功后才发布 connected。 + +## Verification + +webserver 契约测试钉住 upgrade pathname 分发、重复注册拒绝、disposer 与 teardown;connection 的真实网络测试钉住两条 WebSocket 各自的信任检查、open、schema 信封、frame 顺序、stream error 与 close cancellation;客户端测试同时证明下行创建 `ws:`/`wss:` URL,而 unary 与 `respond` 仍调用 HTTP `fetch`。组装后的 keyless browser replay 继续覆盖 Chromium、真实 host、HTTP 上行与 WebSocket 下行整链。 + +## Alternatives considered + +**用一条 WebSocket 复用 mux 与 host。** 这会新增 channel tag、复用队列与单连接背压策略,并改变现有双流 readiness 语义;两条 WebSocket 已避开 HTTP/1.1 六连接上限,同时让本次变更保持在物理载体层。 + +**把 unary 与 respond 一并迁入全双工 WebSocket。** 这会改写超时、取消、HTTP 状态、信任栅栏和请求关联面,却不能为当前的下行连接槽问题带来额外收益;上行 HTTP 是明确保留的边界。 + +**保留网络 SSE 回退。** 双载体会让生产浏览器路径可因代理或握手差异静默分叉,并让连接上限问题继续存在于一个受支持分支;预发布阶段只交付 WebSocket 下行,失败由既有重连与连接状态显式呈现。 + +**依赖 HTTP/2 扩大并发连接能力。** 内置开发服务器是明文 Node HTTP/1.1,部署前置代理也不是产品可依赖的不变式;物理下行应直接使用不受该连接池限制的浏览器原语。 + +## Consequences + +每个 Web 页面仍有两条长期下行连接,但它们不再消耗浏览器的 HTTP/1.1 六连接配额;runtime 继续消费原有双流并保留所有重连、补缝和跨流无序语义。代价是 webserver 多一个 upgrade 注册面,connection host 半依赖 WebSocket 实现,并需分别维护浏览器 WebSocket 与进程内 SSE 两种物理编解码;它们共享同一 `ServerRequest`/frame schema 和 `IApiClient` 语义,避免形成第二套业务协议。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index a636d8bc49..fc21fc0d32 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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/client/connection/README.md -README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9 -README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca +README.md: 11bfc950b7d4f09f1a3075e0f444966de841be70 +README.zh.md: 4b346de9e0dbc468d6b94546aa963a5b57b62127 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index f537fee327..11bfc950b7 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,11 +2,15 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The real browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the fixture and in-process carriers continue to satisfy the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. ## /api browser-trust fence -The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). +The node half guards every entry under `/api` before bridging or upgrading (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. Non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). + +## `/api` WebSocket downlinks + +`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier. ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index a29d2c00e7..4b346de9e0 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,11 +2,15 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。真实浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;fixture 与进程内载体继续满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 ## /api 浏览器信任栅栏 -node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 +node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 + +## `/api` WebSocket 下行 + +`/api/events.mux` 与 `/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` text message;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。普通网络 GET 这些路径会返回 426,不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。 ## 无密钥 fixture diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index d86b2bdf2a..b14dfc80e3 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-connection", - "description": "Wire consumer layer: IApiClient subclasses, ConnectionController (SSE dual-stream + reconnect), fixture api (no cordis)", + "description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api", "version": "0.0.1", "private": true, "type": "module", @@ -34,7 +34,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "schemastery": "^3.18.0" + "schemastery": "^3.18.0", + "ws": "^8.21.0" }, "files": [ "lib/index.js", @@ -52,6 +53,7 @@ "devDependencies": { "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/ws": "^8.18.1", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/client/connection/src/api-path.ts b/packages/client/connection/src/api-path.ts index 30e91522a2..f34aa231d4 100644 --- a/packages/client/connection/src/api-path.ts +++ b/packages/client/connection/src/api-path.ts @@ -1,8 +1,14 @@ /** * The /api URL prefix — single source for both halves of the web transport. - * The node half registers this prefix on the web server; browser-side path - * literals currently live in the apiproxy client layer (out of scope here). + * The node half registers this prefix on the web server; both halves share the + * event paths below for the browser WebSocket downlinks. */ /** Route prefix owning every api request (`/api` and `/api/`). */ export const API_PATH = '/api' + +/** Browser mux-frame WebSocket pathname. */ +export const MUX_EVENTS_PATH = `${API_PATH}/events.mux` + +/** Browser host-frame WebSocket pathname. */ +export const HOST_EVENTS_PATH = `${API_PATH}/events.host` diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index ecb180dca7..4e897ccf87 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -4,7 +4,7 @@ * the attacker's domain while the socket reaches this server) and cross-site * requests fired from a malicious page. The Host fence binds every request, * browser-looking or not: over plain HTTP a browser attaches neither Origin - * nor Fetch-Metadata to reads (EventSource, images, navigations — those + * nor Fetch-Metadata to reads (images and navigations — those * headers go only to trustworthy destinations), so an unmarked request may * still be a rebound browser read and Host is the one header rebinding cannot * forge. Non-browser and remote clients pass the same fence via loopback, the @@ -97,7 +97,7 @@ export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: read // fills Host from the URL it believes it is talking to, so a rebound page // carries the attacker's domain here even though the socket lands on this // server. There is no marker shortcut — a browser read over plain HTTP - // (EventSource, images, navigations) arrives with neither Origin nor + // (images and navigations) arrives with neither Origin nor // Fetch-Metadata, indistinguishable from curl, and its response is readable // by the rebound page. const host = header(request.headers, 'host') diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 6eb6491e2f..18d22f878d 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -126,7 +126,7 @@ export class ConnectionController { try { // Strict readiness handshake (audit C2): describe proves unary reachability, onOpen - // proves each SSE transport is established (response headers in, before any frame) — + // proves each physical stream is established before any frame — // only then may onConnected fire, so the resync it triggers cannot outrun the // subscribed baseline. The timeout guards against a carrier that never fires onOpen // (see ConnectionConfig.streamOpenTimeoutMs). diff --git a/packages/client/connection/src/client/web-api-client.ts b/packages/client/connection/src/client/web-api-client.ts index 9ae6eeae7d..a2c2d95b7b 100644 --- a/packages/client/connection/src/client/web-api-client.ts +++ b/packages/client/connection/src/client/web-api-client.ts @@ -1,12 +1,91 @@ -// WebApiClient: the browser platform subclass — transport = global fetch over same-origin -// /api/* (base resolution handled by AbstractApiClient). Envelope observation comes from the -// base batching aspect; subscribers attach via subscribeEnvelopes (see boot). +/** Browser API carrier: HTTP upstream plus one WebSocket per downstream event stream. */ +import type { ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest } from './api.ts' import { AbstractApiClient } from './api.ts' +import { hostFrameSchema, muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema' +import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema' +import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../api-path.ts' -/** Browser platform subclass: transport = global fetch over same-origin /api/*. */ +type SocketItem = { kind: 'frame'; envelope: RpcRequest } | { kind: 'end' } +type Parser = { parse(value: unknown): F } + +/** Browser platform subclass: unary/respond use fetch; mux/host use downlink-only WebSockets. */ export class WebApiClient extends AbstractApiClient { protected doFetch(input: URL, init?: RequestInit): Promise { return globalThis.fetch(input, init) } + + protected override openMux( + _payload: Parameters[0]['payload'], + signal: AbortSignal, + onOpen?: () => void, + ): AsyncIterable> { + return this.readWebSocket(MUX_EVENTS_PATH, signal, muxFrameSchema, onOpen) + } + + protected override openHost( + _payload: Parameters[0]['payload'], + signal: AbortSignal, + onOpen?: () => void, + ): AsyncIterable> { + return this.readWebSocket(HOST_EVENTS_PATH, signal, hostFrameSchema, onOpen) + } + + private async *readWebSocket( + path: string, + signal: AbortSignal, + frameSchema: Parser, + onOpen?: () => void, + ): AsyncGenerator> { + const url = new URL(path, this.resolveBase()) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + const socket = new WebSocket(url) + const inbox: SocketItem[] = [] + let wake: (() => void) | undefined + const enqueue = (item: SocketItem): void => { + inbox.push(item) + wake?.() + wake = undefined + } + const handleOpen = (): void => { onOpen?.() } + const handleMessage = (event: MessageEvent): void => { + let full: ServerRequest + let frame: F + try { + if (typeof event.data !== 'string') throw new Error('binary WebSocket frame') + full = serverRequestSchema.parse(JSON.parse(event.data)) + frame = frameSchema.parse(full.payload) + } catch (error) { + console.error(`[client-connection] dropping malformed WebSocket frame on ${path}:`, error) + return + } + this.onEnvelope(full) + enqueue({ kind: 'frame', envelope: { rpcId: full.rpcId, payload: frame } }) + } + const handleClose = (): void => { enqueue({ kind: 'end' }) } + const handleAbort = (): void => { + if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) socket.close() + } + socket.addEventListener('open', handleOpen) + socket.addEventListener('message', handleMessage) + socket.addEventListener('close', handleClose, { once: true }) + signal.addEventListener('abort', handleAbort, { once: true }) + if (signal.aborted) handleAbort() + try { + while (true) { + while (inbox.length > 0) { + const item = inbox.shift() as SocketItem + if (item.kind === 'end') return + yield item.envelope + } + await new Promise((resolve) => { wake = resolve }) + } + } finally { + signal.removeEventListener('abort', handleAbort) + socket.removeEventListener('open', handleOpen) + socket.removeEventListener('message', handleMessage) + socket.removeEventListener('close', handleClose) + handleAbort() + } + } } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index ed4af2d21f..d3107ed037 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -2,13 +2,14 @@ import type { Context } from 'cordis' import z from 'schemastery' // Activates the httpServer Context merge used below. -import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' +import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -import { API_PATH } from './api-path.ts' +import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' +import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts' -export { API_PATH } from './api-path.ts' +export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' @@ -76,6 +77,7 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) const apiHandler = toFetchHandler(ctx.apiProxy) + const downlinks = new WebSocketDownlinks(ctx.apiProxy) const route: WebRoute = { kind: 'prefix', path: API_PATH, @@ -92,8 +94,31 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { res.end('forbidden') return } + if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { + res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' }) + res.end('upgrade required') + return + } await bridge(req, res, apiHandler) }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') + const registerDownlink = ( + path: string, + handle: WebUpgradeRoute['handler'], + ): void => { + ctx.effect(() => ctx.httpServer.registerUpgrade({ + path, + handler: (req, socket, head) => { + if (!isTrustedApiRequest(req, trustedHosts)) { + rejectWebSocketUpgrade(socket) + return + } + return handle(req, socket, head) + }, + }), `client-connection: ${path} WebSocket`) + } + ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks') + registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) }) + registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) }) } diff --git a/packages/client/connection/src/websocket-downlink.ts b/packages/client/connection/src/websocket-downlink.ts new file mode 100644 index 0000000000..09a0844ada --- /dev/null +++ b/packages/client/connection/src/websocket-downlink.ts @@ -0,0 +1,150 @@ +/** Host-side WebSocket carrier for the two server-to-browser event streams. */ + +import { randomUUID } from 'node:crypto' +import type { IncomingMessage } from 'node:http' +import type { Duplex } from 'node:stream' +import WebSocket, { WebSocketServer } from 'ws' +import type { + ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest, +} from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' + +type Frame = MuxFrame | HostFrame + +function serverRequest(frame: RpcRequest): ServerRequest { + return { + type: 'server-request', + rpcId: frame.rpcId, + method: frame.payload.type, + payload: frame.payload, + } +} + +function send(socket: WebSocket, frame: RpcRequest): Promise { + return new Promise((resolve, reject) => { + if (socket.readyState !== WebSocket.OPEN) { + reject(new Error('websocket downlink closed before frame delivery')) + return + } + socket.send(JSON.stringify(serverRequest(frame)), (error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) +} + +function failureFrame(error: unknown): RpcRequest { + return { + rpcId: RpcId(randomUUID()), + payload: { + type: 'stream/error', + error: { code: 'internal', message: String(error), details: {} }, + }, + } +} + +/** + * Owns WebSocket negotiation and frame pumping for the connection plugin's + * two downlinks. Client messages are a protocol violation: upstream traffic + * remains on HTTP. + */ +export class WebSocketDownlinks { + private readonly server = new WebSocketServer({ noServer: true }) + + /** @param api - host API supplying the typed event streams. */ + constructor(private readonly api: ApiProxy) {} + + /** + * Upgrade one socket and pump the mux stream until either side closes. + * @param req - HTTP upgrade request. + * @param socket - Raw socket transferred by the HTTP server. + * @param head - Bytes already read after the upgrade headers. + */ + handleMux(req: IncomingMessage, socket: Duplex, head: Buffer): void { + this.upgrade(req, socket, head, signal => this.api.events.mux({ + rpcId: RpcId(randomUUID()), + payload: {}, + }, signal)) + } + + /** + * Upgrade one socket and pump the host stream until either side closes. + * @param req - HTTP upgrade request. + * @param socket - Raw socket transferred by the HTTP server. + * @param head - Bytes already read after the upgrade headers. + */ + handleHost(req: IncomingMessage, socket: Duplex, head: Buffer): void { + this.upgrade(req, socket, head, signal => this.api.events.host({ + rpcId: RpcId(randomUUID()), + payload: {}, + }, signal)) + } + + /** + * Terminate owned sockets and await the no-server acceptor's close. + * @returns A promise resolving after every accepted socket has closed. + */ + close(): Promise { + for (const socket of this.server.clients) socket.terminate() + return new Promise((resolve, reject) => { + this.server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) + } + + private upgrade( + req: IncomingMessage, + socket: Duplex, + head: Buffer, + open: (signal: AbortSignal) => AsyncIterable>, + ): void { + this.server.handleUpgrade(req, socket, head, (websocket) => { + const abort = new AbortController() + websocket.once('close', () => { abort.abort() }) + websocket.once('error', () => { abort.abort() }) + websocket.once('message', () => { + websocket.close(1008, 'downlink only') + }) + void this.pump(websocket, open(abort.signal), abort) + }) + } + + private async pump( + socket: WebSocket, + frames: AsyncIterable>, + abort: AbortController, + ): Promise { + try { + for await (const frame of frames) await send(socket, frame) + } catch (error) { + if (!abort.signal.aborted) { + try { + await send(socket, failureFrame(error)) + } catch { + // Socket loss won the race; no downstream remains to receive the failure frame. + } + } + } finally { + abort.abort() + if (socket.readyState === WebSocket.OPEN) socket.close() + else if (socket.readyState === WebSocket.CONNECTING) socket.terminate() + } + } +} + +/** + * Reject an untrusted upgrade before protocol negotiation. + * @param socket - Raw HTTP socket that remains owned by the caller. + */ +export function rejectWebSocketUpgrade(socket: Duplex): void { + socket.end([ + 'HTTP/1.1 403 Forbidden', + 'Connection: close', + 'Content-Type: text/plain; charset=utf-8', + 'Content-Length: 9', + '', + 'forbidden', + ].join('\r\n')) +} diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 43c71dffb7..322398d371 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -3,15 +3,55 @@ * selection off the page URL, and the single-consumer stream-loop ownership. */ import { Context } from 'cordis' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { apply, type ConnectionHandle } from '../src/client/index.ts' +import type { RpcMessage } from '../src/client/api.ts' +import { RpcId } from '../src/client/api.ts' import { FixtureApiClient } from '../src/client/fixture.ts' import { WebApiClient } from '../src/client/web-api-client.ts' -type Win = { location?: { hostname: string; search: string } } +type Win = { location?: { hostname: string; search: string; origin?: string } } +type WebSocketGlobal = { WebSocket?: typeof WebSocket } + +const originalWebSocket = globalThis.WebSocket +const sockets: FakeWebSocket[] = [] + +class FakeWebSocket extends EventTarget { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + + readonly url: string + readyState = FakeWebSocket.CONNECTING + + constructor(url: string | URL) { + super() + this.url = String(url) + sockets.push(this) + queueMicrotask(() => { + if (this.readyState !== FakeWebSocket.CONNECTING) return + this.readyState = FakeWebSocket.OPEN + this.dispatchEvent(new Event('open')) + }) + } + + close(): void { + if (this.readyState === FakeWebSocket.CLOSED) return + this.readyState = FakeWebSocket.CLOSED + this.dispatchEvent(new Event('close')) + } + + receive(data: unknown): void { + this.dispatchEvent(new MessageEvent('message', { data })) + } +} afterEach(() => { delete (globalThis as Win).location + sockets.length = 0 + if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket + else globalThis.WebSocket = originalWebSocket }) async function mount(): Promise { @@ -53,7 +93,7 @@ describe('connection client apply', () => { loop.stop() // teardown must not throw; the fixture streams abort quietly }) - it('WebApiClient carries requests over globalThis.fetch', async () => { + it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() const original = globalThis.fetch @@ -65,9 +105,88 @@ describe('connection client apply', () => { try { // Schema rejection is fine — the transport hop is the assertion. await (handle.api as WebApiClient).host.describe({}).catch(() => undefined) + await handle.api.respond({ + type: 'client-response', + rpcId: RpcId('response-over-http'), + result: { ok: true, value: {} }, + }).catch(() => undefined) } finally { globalThis.fetch = original } - expect(seen.some(u => u.includes('/api/'))).toBe(true) + expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true) + expect(seen.some(u => u.includes('/api/respond'))).toBe(true) + }) + + it('opens one WebSocket per downlink, parses frames, and aborts both without using fetch', async () => { + ;(globalThis as Win).location = { + hostname: 'localhost', search: '', origin: 'http://localhost:3080', + } + ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket + const fetch = vi.spyOn(globalThis, 'fetch') + const client = (await mount()).api as WebApiClient + const envelopes: RpcMessage[][] = [] + client.subscribeEnvelopes(batch => { envelopes.push([...batch]) }) + const opened: string[] = [] + const muxAbort = new AbortController() + const hostAbort = new AbortController() + const mux = client.events.mux({}, muxAbort.signal, () => { opened.push('mux') })[Symbol.asyncIterator]() + const host = client.events.host({}, hostAbort.signal, () => { opened.push('host') })[Symbol.asyncIterator]() + const muxFrame = mux.next() + const hostFrame = host.next() + await vi.waitFor(() => { expect(sockets).toHaveLength(2) }) + expect(sockets.map(socket => socket.url)).toEqual([ + 'ws://localhost:3080/api/events.mux', + 'ws://localhost:3080/api/events.host', + ]) + await vi.waitFor(() => { expect(opened).toEqual(['mux', 'host']) }) + + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + sockets[0]!.receive(new Uint8Array([1, 2, 3])) + sockets[1]!.receive(JSON.stringify({ type: 'server-request', rpcId: 'bad', method: 'host/session-status', payload: {} })) + sockets[0]!.receive(JSON.stringify({ + type: 'server-request', + rpcId: 'mux-browser', + method: 'session/subscribed', + payload: { type: 'session/subscribed', sessionId: 'session-browser', lastSeq: 8 }, + })) + sockets[1]!.receive(JSON.stringify({ + type: 'server-request', + rpcId: 'host-browser', + method: 'host/commands-changed', + payload: { type: 'host/commands-changed' }, + })) + expect(await muxFrame).toMatchObject({ + value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } }, + }) + expect(await hostFrame).toMatchObject({ + value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } }, + }) + expect(errors).toHaveBeenCalledTimes(2) + await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) }) + expect(fetch).not.toHaveBeenCalled() + + const muxEnd = mux.next() + const hostEnd = host.next() + muxAbort.abort() + hostAbort.abort() + await expect(muxEnd).resolves.toMatchObject({ done: true }) + await expect(hostEnd).resolves.toMatchObject({ done: true }) + expect(sockets.every(socket => socket.readyState === FakeWebSocket.CLOSED)).toBe(true) + errors.mockRestore() + fetch.mockRestore() + }) + + it('maps an HTTPS page origin to a secure WebSocket URL', async () => { + ;(globalThis as Win).location = { + hostname: 'harness.example', search: '', origin: 'https://harness.example', + } + ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket + const client = (await mount()).api + const abort = new AbortController() + const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]() + const pending = iterator.next() + await vi.waitFor(() => { expect(sockets[0]?.url).toBe('wss://harness.example/api/events.mux') }) + abort.abort() + await expect(pending).resolves.toMatchObject({ done: true }) }) }) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 08c65de2ba..551902f42e 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,22 +1,29 @@ /** Node half: registers the /api prefix route bridging to the api gateway. */ -import { EventEmitter } from 'node:events' +import { EventEmitter, once } from 'node:events' import { createServer, request as httpRequest } from 'node:http' -import { Readable } from 'node:stream' +import { PassThrough, Readable } from 'node:stream' import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, apply, inject } from '../src/index.ts' +import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' +import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts' -/** Structural httpServer fake: the plugin only touches register(). */ -function fakeHttpServer(routes: WebRoute[]): Pick { +/** Structural httpServer fake recording both route registries. */ +function fakeHttpServer( + routes: WebRoute[], + upgrades: WebUpgradeRoute[], +): Pick { return { register(route) { routes.push(route) return () => { routes.splice(routes.indexOf(route), 1) } }, + registerUpgrade(route) { + upgrades.push(route) + return () => { upgrades.splice(upgrades.indexOf(route), 1) } + }, tapIndex: () => () => {}, port: 0, } @@ -45,33 +52,67 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b return { response, state } } -async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise }> { +async function mounted(config?: { trustedHosts?: string[] }): Promise<{ + routes: WebRoute[] + upgrades: WebUpgradeRoute[] + dispose: () => Promise +}> { const ctx = new Context() const routes: WebRoute[] = [] - ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + const upgrades: WebUpgradeRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() - return { routes, dispose: () => fiber.dispose() } + return { routes, upgrades, dispose: () => fiber.dispose() } } describe('connection node half', () => { it('fails the load on a trustedHosts entry that is not a bare authority', async () => { const routes: WebRoute[] = [] + const upgrades: WebUpgradeRoute[] = [] const ctx = new Context() - ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/) expect(routes).toHaveLength(0) + expect(upgrades).toHaveLength(0) }) - it('registers the /api prefix route and removes it with the fiber', async () => { - const { routes, dispose } = await mounted() + it('registers one HTTP route plus one upgrade route per downlink and removes all three with the fiber', async () => { + const { routes, upgrades, dispose } = await mounted() expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) + expect(upgrades.map(route => route.path)).toEqual([MUX_EVENTS_PATH, HOST_EVENTS_PATH]) await dispose() expect(routes).toHaveLength(0) + expect(upgrades).toHaveLength(0) + }) + + it('requires WebSocket upgrade for network GETs to either event path', async () => { + const { routes, dispose } = await mounted() + for (const path of [MUX_EVENTS_PATH, HOST_EVENTS_PATH]) { + const { response, state } = fakeResponse() + await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }, path), response) + expect(state.status).toBe(426) + expect(state.body).toBe('upgrade required') + } + await dispose() + }) + + it('rejects an untrusted WebSocket upgrade before protocol negotiation', async () => { + const { upgrades, dispose } = await mounted() + const socket = new PassThrough() + const chunks: Buffer[] = [] + socket.on('data', (chunk: Buffer) => { chunks.push(chunk) }) + const ended = once(socket, 'end') + await upgrades[0]!.handler(fakeRequest({ + host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', + }, MUX_EVENTS_PATH), socket, Buffer.alloc(0)) + await ended + expect(Buffer.concat(chunks).toString()).toContain('HTTP/1.1 403 Forbidden') + await dispose() }) it('refuses an untrusted Host on any /api path before the bridge runs', async () => { diff --git a/packages/client/connection/tests/websocket-downlink.spec.ts b/packages/client/connection/tests/websocket-downlink.spec.ts new file mode 100644 index 0000000000..254f761691 --- /dev/null +++ b/packages/client/connection/tests/websocket-downlink.spec.ts @@ -0,0 +1,164 @@ +import { once } from 'node:events' +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import type { + ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest, +} from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' +import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../src/api-path.ts' +import { WebSocketDownlinks } from '../src/websocket-downlink.ts' + +type MuxSource = (signal: AbortSignal) => AsyncIterable> +type HostSource = (signal: AbortSignal) => AsyncIterable> + +const running: (() => Promise)[] = [] + +afterEach(async () => { + await Promise.all(running.splice(0).map(close => close())) +}) + +function untilAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise(resolve => signal.addEventListener('abort', () => { resolve() }, { once: true })) +} + +async function * idle(signal: AbortSignal): AsyncGenerator> { + await untilAbort(signal) +} + +function api(mux: MuxSource, host: HostSource): ApiProxy { + return { + events: { + mux: (_request, signal) => mux(signal), + host: (_request, signal) => host(signal), + }, + } as ApiProxy +} + +async function serve(downlinks: WebSocketDownlinks): Promise<{ + origin: string + close: () => Promise +}> { + const server = createServer() + server.on('upgrade', (request, socket, head) => { + const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname + if (pathname === MUX_EVENTS_PATH) downlinks.handleMux(request, socket, head) + else if (pathname === HOST_EVENTS_PATH) downlinks.handleHost(request, socket, head) + else socket.destroy() + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + return { + origin: `ws://127.0.0.1:${String(port)}`, + close: async () => { + await downlinks.close() + await new Promise(resolve => server.close(() => { resolve() })) + }, + } +} + +function read(socket: WebSocket): Promise { + return once(socket, 'message').then(([data]) => JSON.parse(String(data)) as ServerRequest) +} + +describe('WebSocket downlinks', () => { + it('carries mux and host over independent downstream sockets and cancels each source on close', async () => { + let muxAborted = false + let hostAborted = false + const downlinks = new WebSocketDownlinks(api( + async function * (signal) { + try { + yield { + rpcId: RpcId('mux-1'), + payload: { type: 'session/subscribed', sessionId: 'session-1' as never, lastSeq: 4 }, + } + await untilAbort(signal) + } finally { + muxAborted = true + } + }, + async function * (signal) { + try { + yield { rpcId: RpcId('host-1'), payload: { type: 'host/commands-changed' } } + await untilAbort(signal) + } finally { + hostAborted = true + } + }, + )) + const host = await serve(downlinks) + running.push(host.close) + + const mux = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + const hostSocket = new WebSocket(`${host.origin}${HOST_EVENTS_PATH}`) + const muxFrame = read(mux) + const hostFrame = read(hostSocket) + expect(await muxFrame).toEqual({ + type: 'server-request', + rpcId: 'mux-1', + method: 'session/subscribed', + payload: { type: 'session/subscribed', sessionId: 'session-1', lastSeq: 4 }, + }) + expect(await hostFrame).toEqual({ + type: 'server-request', + rpcId: 'host-1', + method: 'host/commands-changed', + payload: { type: 'host/commands-changed' }, + }) + + const muxClosed = once(mux, 'close') + const hostClosed = once(hostSocket, 'close') + mux.close() + hostSocket.close() + await Promise.all([muxClosed, hostClosed]) + await vi.waitFor(() => { + expect(muxAborted).toBe(true) + expect(hostAborted).toBe(true) + }) + }) + + it('rejects client messages because upstream remains HTTP', async () => { + let aborted = false + const downlinks = new WebSocketDownlinks(api( + async function * (signal) { + try { + await untilAbort(signal) + } finally { + aborted = true + } + }, + idle, + )) + const host = await serve(downlinks) + running.push(host.close) + const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + await once(socket, 'open') + const closed = once(socket, 'close') + socket.send('upstream payload') + const [code, reason] = await closed + expect(code).toBe(1008) + expect(String(reason)).toBe('downlink only') + await vi.waitFor(() => { expect(aborted).toBe(true) }) + }) + + it('sends stream/error before closing when a source fails', async () => { + const downlinks = new WebSocketDownlinks(api( + async function * () { + throw new Error('mux source failed') + }, + idle, + )) + const host = await serve(downlinks) + running.push(host.close) + const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + const failure = read(socket) + const closed = once(socket, 'close') + expect((await failure).payload).toEqual({ + type: 'stream/error', + error: { code: 'internal', message: 'Error: mux source failed', details: {} }, + }) + await closed + }) +}) diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index f4460fadc2..d15d5c69ee 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -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` (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 diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 7f2f55cba4..8beeda582a 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -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' diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index f48a2ca562..f435f23e93 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -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). diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 758fe638ca..a767ea8fd9 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -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; diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 21330079c2..e279575ff4 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -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' diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index a79958e9d2..f9d277d3fd 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.i18n.yaml @@ -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 diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index c3c7b22268..f01c1a66b1 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -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 diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 99c0560eb7..980bc3dbac 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -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 提供,GET/HEAD 之外的方法返回 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 提供,GET/HEAD 之外的方法返回 405。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。 -该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`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(热模块替换)事件流则是 modules/hmr 插件的 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()` 配对,因为一直保持打开的 SSE(Server-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 载体,其中没有任何内容会进入模型请求。 +无。该包只是浏览器与其他插件所注册 HTTP/upgrade route 之间的 Web 载体,其中没有任何内容会进入模型请求。 #### KV 缓存影响 diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 0dab038f41..291294d1fe 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -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", diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 37298cb178..d701061530 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -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 } +/** 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 +} + /** 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() private readonly prefixes = new Map() + private readonly upgrades = new Map() + private readonly upgradedSockets = new Set() 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((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((resolve) => { this.server.close(() => { resolve() }) this.server.closeAllConnections() + for (const socket of this.upgradedSockets) socket.destroy() }), 'httpServer.listen') } diff --git a/packages/host/webserver/src/invariant.ts b/packages/host/webserver/src/invariant.ts index b5c8492566..7becf3543b 100644 --- a/packages/host/webserver/src/invariant.ts +++ b/packages/host/webserver/src/invariant.ts @@ -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 }) } diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index c64208eb8e..743784fae0 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -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> { + 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() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07ca290fd6..dfd4f4e1eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1018,6 +1018,9 @@ importers: schemastery: specifier: ^3.18.0 version: link:../../../vendor/schemastery + ws: + specifier: ^8.21.0 + version: 8.21.0 devDependencies: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ @@ -1025,6 +1028,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -8971,6 +8977,9 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/parser@8.61.0': resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -14028,6 +14037,10 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.0 + '@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.61.0 From a36c641db6e6007fd46b7f1055f8b40ff32dc445 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:21:09 +0800 Subject: [PATCH 2/7] chore(docs): refresh WebSocket API catalogs --- docs/config-catalog.md | 4 ++-- docs/cordis-catalog/services.md | 10 +++++++++- packages/cordis/tool-cordis/src/api-catalog.ts | 8 ++++++++ scripts/gen-cordis-catalog.ts | 2 ++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6d0de60ab9..0c6d58384e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -296,7 +296,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:21`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` @@ -574,7 +574,7 @@ export interface Config { } ``` -Source: [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:47`](../packages/host/webserver/src/index.ts) ## `@deepseek-ai/dsh-invariants` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a713efa09c..cb8cb3b3b2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -770,6 +770,14 @@ The web-shape HTTP carrier service. Activation listens immediately (route regist */ 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 + /** * Register an index.html transform, applied to every index response in * registration order. @@ -779,7 +787,7 @@ register(route: WebRoute): () => void tapIndex(transform: (html: string) => string): () => void ``` -Source: [`packages/host/webserver/src/index.ts:55`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:63`](../../packages/host/webserver/src/index.ts) ## `ctx.invariants` — `InvariantService` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5f9af11105..6efc54849c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -388,6 +388,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'register(route: WebRoute): () => void', jsDoc: '/**\n * Register a named route. Duplicate (kind, path) throws — route patterns are\n * a composition-level contract, so a collision is a misconfiguration.\n * @param route - kind, path, and the owning handler.\n * @returns the disposer removing the route.\n */', }, + { + signature: 'registerUpgrade(route: WebUpgradeRoute): () => void', + jsDoc: '/**\n * Register an exact-path HTTP upgrade route. Duplicate paths throw because\n * one socket can have only one protocol owner.\n * @param route - pathname and handler owning negotiation plus socket use.\n * @returns the disposer removing the route.\n */', + }, { signature: 'tapIndex(transform: (html: string) => string): () => void', jsDoc: '/**\n * Register an index.html transform, applied to every index response in\n * registration order.\n * @param transform - pure html-to-html function.\n * @returns the disposer removing the transform.\n */', @@ -3129,6 +3133,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WebSource', declaration: 'export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n}', }, + { + name: 'WebUpgradeRoute', + declaration: 'export interface WebUpgradeRoute {\n path: string;\n handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise;\n}', + }, { name: 'WorkflowMeta', declaration: 'export interface WorkflowMeta {\n name: string;\n description: string;\n whenToUse?: string;\n phases?: WorkflowPhase[];\n}', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index bdbd3a0ceb..4dd381af69 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -277,6 +277,8 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts', WebBootGraph: 'web boot graph wire shape is owned by packages/client/modules/src/client/index.ts', WebRoute: 'route registration contract is owned by packages/host/webserver/src/index.ts', + WebUpgradeRoute: + 'upgrade route registration contract is owned by packages/host/webserver/src/index.ts', ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts', InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', From 98a24a395d3f55ea4db0230ad99b1626bd5ea9f5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:25:49 +0800 Subject: [PATCH 3/7] docs: refresh third-party notices --- THIRD_PARTY_NOTICES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8cd2964da6..8b04f25504 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -79,6 +79,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`turndown`](https://github.com/mixmark-io/turndown) | MIT | | [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 | | [`use-sync-external-store`](https://github.com/facebook/react) | MIT | +| [`ws`](https://github.com/websockets/ws) | MIT | | [`yaml`](https://github.com/eemeli/yaml) | ISC | | [`zod`](https://github.com/colinhacks/zod) | MIT | | [`zustand`](https://github.com/pmndrs/zustand) | MIT | @@ -109,6 +110,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@typescript-eslint/parser`](https://github.com/typescript-eslint/typescript-eslint) | MIT | | [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT | | [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT | From c6d0cbd8dec68ddd6a622ec8442282d7de132be2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:34:56 +0800 Subject: [PATCH 4/7] test(web): cover WebSocket downlink races --- .../connection/src/websocket-downlink.ts | 5 +- .../connection/tests/client-apply.spec.ts | 14 +++ .../tests/websocket-downlink.spec.ts | 101 ++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/packages/client/connection/src/websocket-downlink.ts b/packages/client/connection/src/websocket-downlink.ts index 09a0844ada..996edd2551 100644 --- a/packages/client/connection/src/websocket-downlink.ts +++ b/packages/client/connection/src/websocket-downlink.ts @@ -27,8 +27,8 @@ function send(socket: WebSocket, frame: RpcRequest): Promise { return } socket.send(JSON.stringify(serverRequest(frame)), (error) => { - if (error === undefined) resolve() - else reject(error) + if (error) reject(error) + else resolve() }) }) } @@ -129,7 +129,6 @@ export class WebSocketDownlinks { } finally { abort.abort() if (socket.readyState === WebSocket.OPEN) socket.close() - else if (socket.readyState === WebSocket.CONNECTING) socket.terminate() } } } diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 322398d371..9bc645c847 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -189,4 +189,18 @@ describe('connection client apply', () => { abort.abort() await expect(pending).resolves.toMatchObject({ done: true }) }) + + it('closes a WebSocket immediately when its signal was already aborted', async () => { + ;(globalThis as Win).location = { + hostname: 'localhost', search: '', origin: 'http://localhost:3080', + } + ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket + const client = (await mount()).api + const abort = new AbortController() + abort.abort() + const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + expect(sockets).toHaveLength(1) + expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) + }) }) diff --git a/packages/client/connection/tests/websocket-downlink.spec.ts b/packages/client/connection/tests/websocket-downlink.spec.ts index 254f761691..40bab41135 100644 --- a/packages/client/connection/tests/websocket-downlink.spec.ts +++ b/packages/client/connection/tests/websocket-downlink.spec.ts @@ -63,6 +63,16 @@ function read(socket: WebSocket): Promise { return once(socket, 'message').then(([data]) => JSON.parse(String(data)) as ServerRequest) } +async function acceptedSocket(downlinks: WebSocketDownlinks): Promise { + const server = (downlinks as unknown as { server: { clients: Set } }).server + let accepted: WebSocket | undefined + await vi.waitFor(() => { + accepted = server.clients.values().next().value + expect(accepted).toBeDefined() + }) + return accepted as WebSocket +} + describe('WebSocket downlinks', () => { it('carries mux and host over independent downstream sockets and cancels each source on close', async () => { let muxAborted = false @@ -161,4 +171,95 @@ describe('WebSocket downlinks', () => { }) await closed }) + + it('aborts the source when an accepted socket reports a transport error', async () => { + let aborted = false + const downlinks = new WebSocketDownlinks(api( + async function * (signal) { + try { + await untilAbort(signal) + } finally { + aborted = true + } + }, + idle, + )) + const host = await serve(downlinks) + running.push(host.close) + const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + await once(socket, 'open') + const accepted = await acceptedSocket(downlinks) + const closed = once(socket, 'close') + accepted.emit('error', new Error('transport failed')) + await closed + expect(aborted).toBe(true) + }) + + it('drops a source frame that races after the client has closed', async () => { + let release!: () => void + const gate = new Promise(resolve => { release = resolve }) + let finish!: () => void + const finished = new Promise(resolve => { finish = resolve }) + let sourceSignal: AbortSignal | undefined + const downlinks = new WebSocketDownlinks(api( + async function * (signal) { + sourceSignal = signal + try { + await gate + yield { rpcId: RpcId('late'), payload: { type: 'host/commands-changed' } } + } finally { + finish() + } + }, + idle, + )) + const host = await serve(downlinks) + running.push(host.close) + const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + await once(socket, 'open') + const closed = once(socket, 'close') + socket.close() + await closed + await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) }) + release() + await finished + }) + + it('contains socket send callback failures and closes the downlink', async () => { + let release!: () => void + const gate = new Promise(resolve => { release = resolve }) + const downlinks = new WebSocketDownlinks(api( + async function * () { + await gate + yield { rpcId: RpcId('send-failure'), payload: { type: 'host/commands-changed' } } + }, + idle, + )) + const host = await serve(downlinks) + running.push(host.close) + const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + await once(socket, 'open') + const accepted = await acceptedSocket(downlinks) + const send = vi.spyOn(accepted, 'send').mockImplementation((( + _data: unknown, + optionsOrCallback?: unknown, + callback?: (error?: Error) => void, + ) => { + const done = typeof optionsOrCallback === 'function' + ? optionsOrCallback as (error?: Error) => void + : callback + done?.(new Error('socket send failed')) + }) as WebSocket['send']) + const closed = once(socket, 'close') + release() + await closed + expect(send).toHaveBeenCalledTimes(2) + send.mockRestore() + }) + + it('rejects when its acceptor has already closed', async () => { + const downlinks = new WebSocketDownlinks(api(idle, idle)) + await downlinks.close() + await expect(downlinks.close()).rejects.toThrow('The server is not running') + }) }) From 7f3a2dae91fde3dbc68d40d67280163ef05a5291 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:42:50 +0800 Subject: [PATCH 5/7] fix(web): quiesce websocket teardown --- ...08-04-websocket-downlink-carrier.i18n.yaml | 4 +- .../2026-08-04-websocket-downlink-carrier.md | 4 +- ...026-08-04-websocket-downlink-carrier.zh.md | 4 +- apps/web/tests/scaffold.ts | 4 +- docs/web-styling.i18n.yaml | 6 +-- docs/web-styling.md | 6 +-- docs/web-styling.zh.md | 6 +-- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/websocket-downlink.ts | 14 ++++-- .../tests/websocket-downlink.spec.ts | 47 +++++++++++++++++-- packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 2 +- packages/host/webserver/README.zh.md | 2 +- packages/host/webserver/src/index.ts | 24 ++++++++-- .../host/webserver/tests/webserver.spec.ts | 32 +++++++++++-- 17 files changed, 124 insertions(+), 43 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml index 44ba8854df..2a4879b6aa 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md -2026-08-04-websocket-downlink-carrier.md: 3b1f5cf7c8109546c30e95f55321622442d5e182 -2026-08-04-websocket-downlink-carrier.zh.md: d642269dbd82b062dccc79490a631caaa5e70c81 +2026-08-04-websocket-downlink-carrier.md: b41ad687725c55acb8517fe7e93d645f007a0453 +2026-08-04-websocket-downlink-carrier.zh.md: 568240ec14592fba8444e5cc0a3bae2b35c45b94 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md index 3b1f5cf7c8..b41ad68772 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md @@ -16,9 +16,9 @@ WebSocket carries only the host→browser downlink. All client→host unary call ## Upgrade and lifecycle boundaries -`dsh-host-webserver` provides an exact upgrade-route registration seam alongside ordinary routes, dispatches Node upgrade sockets by pathname only, and destroys surviving upgraded connections during server teardown; it knows nothing about Harness frames or WebSocket messages. `dsh-client-connection` owns the WebSocket handshake, frame output, and stream cancellation, and reuses the `/api` Host/Origin trust fence before upgrade. An untrusted authority or cross-origin Origin is rejected before `ctx.apiProxy.events.*` starts. +`dsh-host-webserver` provides an exact upgrade-route registration seam alongside ordinary routes, dispatches Node upgrade sockets by pathname only, contains raw-socket errors, and waits for surviving upgraded connections to close during server teardown; it knows nothing about Harness frames or WebSocket messages. `dsh-client-connection` owns the WebSocket handshake, frame output, and stream cancellation, and reuses the `/api` Host/Origin trust fence before upgrade. An untrusted authority or cross-origin Origin is rejected before `ctx.apiProxy.events.*` starts. -A browser abort, socket close, or plugin teardown cancels the corresponding host stream. If a host stream throws midway, the carrier sends one existing `stream/error` frame and then closes the socket; the client treats that frame as connection loss rather than delivering it to a business sink. Each WebSocket reports open independently, and the existing readiness handshake still waits until mux and host are both open and the `host.describe` HTTP call has succeeded before publishing connected. +A browser abort or socket close cancels the corresponding host stream; plugin teardown also waits for that source iterator's cleanup. If a host stream throws midway, the carrier sends one existing `stream/error` frame and then closes the socket; the client treats that frame as connection loss rather than delivering it to a business sink. Each WebSocket reports open independently, and the existing readiness handshake still waits until mux and host are both open and the `host.describe` HTTP call has succeeded before publishing connected. ## Verification diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md index d642269dbd..568240ec14 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md @@ -16,9 +16,9 @@ WebSocket 只承担 host→browser 下行。所有 client→host unary 调用和 ## Upgrade 与生命周期边界 -`dsh-host-webserver` 提供与普通 route 并列的精确 upgrade-route 注册缝,只按 pathname 分发 Node upgrade socket,并在 server teardown 销毁仍存活的升级连接;它不认识 Harness 帧或 WebSocket message。`dsh-client-connection` 拥有 WebSocket handshake、frame 写出和 stream cancellation,并在 upgrade 前复用 `/api` 的 Host/Origin 信任栅栏。未受信任的 authority 或跨来源 Origin 在 `ctx.apiProxy.events.*` 启动前即被拒绝。 +`dsh-host-webserver` 提供与普通 route 并列的精确 upgrade-route 注册缝,只按 pathname 分发 Node upgrade socket,隔离原始 socket 错误,并在 server teardown 期间等待仍存活的升级连接关闭;它不认识 Harness 帧或 WebSocket message。`dsh-client-connection` 拥有 WebSocket handshake、frame 写出和 stream cancellation,并在 upgrade 前复用 `/api` 的 Host/Origin 信任栅栏。未受信任的 authority 或跨来源 Origin 在 `ctx.apiProxy.events.*` 启动前即被拒绝。 -浏览器 abort、socket close 与 plugin teardown 都会取消对应的 host stream。host stream 中途抛错时,载体发送一份现有的 `stream/error` frame 后关闭 socket;客户端把该 frame 收敛为连接丢失,不投递给业务 sink。每条 WebSocket 独立报告 open,既有 readiness handshake 仍等待 mux、host 都 open 且 `host.describe` HTTP 调用成功后才发布 connected。 +浏览器 abort 或 socket close 会取消对应的 host stream;plugin teardown 还会等待该 source iterator 完成清理。host stream 中途抛错时,载体发送一份现有的 `stream/error` frame 后关闭 socket;客户端把该 frame 收敛为连接丢失,不投递给业务 sink。每条 WebSocket 独立报告 open,既有 readiness handshake 仍等待 mux、host 都 open 且 `host.describe` HTTP 调用成功后才发布 connected。 ## Verification diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 6b8067235f..ac6c92d5f0 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -2,8 +2,8 @@ // .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). // Boots the REAL web composition — the shipped base plus web overlay through // the vendored Loader (the same include boot AppCLIEntry drives), patched the -// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the -// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT: +// snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket +// downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT: // replay (default, keyless: normally disables the llm-deepseek row and // inserts dsh-llm-replay in providers mode), record (real adapter + key, // harvests fixtures from live session memory), refresh (keyless replay that diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index 5509012e3e..9c79aa8a99 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -web-styling.md: af05faca30fc968828f5a850f59d9d48ae382b05 -web-styling.zh.md: d0838cd8a6ee4290cdddff16b979950bec396314 +# pnpm run verify-translation-pairing --write docs/web-styling.md +web-styling.md: 173ce4482ec01f95cf38c62ebc2879e03a2d8192 +web-styling.zh.md: b38ba267cc7cc66ed12f000a93744dc81d940d06 diff --git a/docs/web-styling.md b/docs/web-styling.md index af05faca30..173ce4482e 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -62,14 +62,14 @@ Font sizes and spacing are **not tokenized** (matching the baseline repository's - Input card: floats centered at the same width as the conversation column (840px, reduced to 712px below 1024px) with bottom spacing; radius `--radius-xl`, border `--border-l2`, background `--bg-base`, shadow `--shadow-card`; two internal vertical sections = textarea (16px/24px, minimum 2 lines, maximum 14 lines = 336px, auto-growing through a mirror div) + action row (a 34px primary round button nested at bottom right); focus does not change the border or shadow (matching the baseline). - Primary input button (the three decisions made on 2026-07-20, visually based on the Codex App): a 32px solid circular icon button (inline SVG). Idle = `--accent` background with a white ↑ “Send” arrow; while running it changes in place to an accent ■ “Stop” icon on `--accent-soft` (the same color family, not a warning, and not red). **Input is locked while running** (decision 3, replacing the earlier hover-menu design): the textarea is disabled (gray, with draft content still visible), there is no queue/interjection menu, and Stop is the only action. When the turn ends, input unlocks and regains focus. Enter sends; Ctrl/Meta+Enter inserts a newline (the keyboard path is disabled with the locked input while running). - Scrollbars: nearly invisible, darkening on hover, with `scrollbar-gutter: stable` so they do not consume layout space (always use `.scrollable`; see § 3.9). -- Four-quadrant RPC direction symbols (the official visual vocabulary, using the spatial metaphor that up goes to the server, down comes from the server; single line = unary, double line = SSE): +- Four-quadrant RPC direction symbols (the official visual vocabulary, using the spatial metaphor that up goes to the server, down comes from the server; single line = client-initiated exchange, double line = server-initiated exchange): | Symbol | Quadrant | Badge colors | | --- | --- | --- | | `↑` | client-request (unary outbound) | `--accent` / `--accent-soft` | | `↓` | server-response (unary response) | ok `--ok`/`--ok-soft`, error `--error`/`--error-soft` | -| `⇟` | server-request (SSE frame push) | mux `--color-frame-mux`/`--frame-mux-soft`, host `--color-frame-host`/`--frame-host-soft` | -| `⇞` | client-response (SSE-side response) | `--accent`/`--accent-soft` at reduced opacity | +| `⇟` | server-request (downlink stream) | mux `--color-frame-mux`/`--frame-mux-soft`, host `--color-frame-host`/`--frame-host-soft` | +| `⇞` | client-response (reply to server request) | `--accent`/`--accent-soft` at reduced opacity | ## 3. Style implementation rules (review checklist) diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index d0838cd8a6..b38ba267cc 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -62,14 +62,14 @@ - 输入卡片:与会话列同宽(840px,<1024px 降 712px)居中悬浮(距底留白带);圆角 `--radius-xl`、边框 `--border-l2`、底 `--bg-base`、阴影 `--shadow-card`;内部上下两段=textarea(16px/24px,min 2 行 max 14 行=336px,镜像 div 自增高)+ 操作行(右下嵌 34px 主圆钮);focus 无边框/阴影变化(基线同款)。 - 输入主按钮(拍板 2026-07-20 三连,视觉参照 Codex App):32px 实心正圆图标钮(内联 SVG)——空闲=`--accent` 底白↑箭头「发送」,运行中原地变 `--accent-soft` 底 accent ■「停止」(同色系不告警、不用红)。**运行中锁输入**(拍板 3,取代早先 hover 菜单方案):textarea disabled(灰、草稿内容保留可见)、无任何排队/插话菜单,停止是唯一动作;turn 结束解禁并 refocus。键盘 Enter=发送、Ctrl/Meta+Enter=换行(运行中键盘路径随锁失效)。 - 滚动条:近隐形、hover 加深、`scrollbar-gutter: stable` 不占布局(统一走 `.scrollable`,见 §3-9)。 -- RPC 四象限方向符(官方视觉词汇,空间隐喻:上=去 server、下=来自 server;单线=unary、双线=SSE): +- RPC 四象限方向符(官方视觉词汇,空间隐喻:上=去 server、下=来自 server;单线=客户端发起的交互、双线=服务端发起的交互): | 符号 | 象限 | 徽章配色 | | --- | --- | --- | | `↑` | client-request(unary 出站) | `--accent` / `--accent-soft` | | `↓` | server-response(unary 回包) | ok `--ok`/`--ok-soft`,error `--error`/`--error-soft` | -| `⇟` | server-request(SSE 帧推送) | mux `--color-frame-mux`/`--frame-mux-soft`,host `--color-frame-host`/`--frame-host-soft` | -| `⇞` | client-response(SSE 侧回应) | `--accent`/`--accent-soft` 降透明度 | +| `⇟` | server-request(下行流) | mux `--color-frame-mux`/`--frame-mux-soft`,host `--color-frame-host`/`--frame-host-soft` | +| `⇞` | client-response(对 server request 的回应) | `--accent`/`--accent-soft` 降透明度 | ## 3. 样式编码规范(review 对照打勾) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index fc21fc0d32..a09f0fa0cd 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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/client/connection/README.md -README.md: 11bfc950b7d4f09f1a3075e0f444966de841be70 -README.zh.md: 4b346de9e0dbc468d6b94546aa963a5b57b62127 +README.md: faf093964a740092983e13bf88f2cccd853c3e36 +README.zh.md: b06ab245dedbde13957aa416be044ef107b2753c diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 11bfc950b7..faf093964a 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -10,7 +10,7 @@ The node half guards every entry under `/api` before bridging or upgrading (`src ## `/api` WebSocket downlinks -`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier. +`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Host teardown terminates both sockets, aborts their sources, and waits for source cleanup before returning. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier. ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 4b346de9e0..b06ab245de 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -10,7 +10,7 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r ## `/api` WebSocket 下行 -`/api/events.mux` 与 `/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` text message;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。普通网络 GET 这些路径会返回 426,不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。 +`/api/events.mux` 与 `/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` text message;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source,并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426,不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。 ## 无密钥 fixture diff --git a/packages/client/connection/src/websocket-downlink.ts b/packages/client/connection/src/websocket-downlink.ts index 996edd2551..72ae5e94ef 100644 --- a/packages/client/connection/src/websocket-downlink.ts +++ b/packages/client/connection/src/websocket-downlink.ts @@ -50,6 +50,7 @@ function failureFrame(error: unknown): RpcRequest { */ export class WebSocketDownlinks { private readonly server = new WebSocketServer({ noServer: true }) + private readonly pumps = new Set>() /** @param api - host API supplying the typed event streams. */ constructor(private readonly api: ApiProxy) {} @@ -81,17 +82,18 @@ export class WebSocketDownlinks { } /** - * Terminate owned sockets and await the no-server acceptor's close. - * @returns A promise resolving after every accepted socket has closed. + * Terminate owned sockets and await the no-server acceptor plus frame pumps. + * @returns A promise resolving after every socket and source iterator stops. */ - close(): Promise { + async close(): Promise { for (const socket of this.server.clients) socket.terminate() - return new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { this.server.close((error) => { if (error === undefined) resolve() else reject(error) }) }) + await Promise.all(this.pumps) } private upgrade( @@ -107,7 +109,9 @@ export class WebSocketDownlinks { websocket.once('message', () => { websocket.close(1008, 'downlink only') }) - void this.pump(websocket, open(abort.signal), abort) + const pump = this.pump(websocket, open(abort.signal), abort) + this.pumps.add(pump) + void pump.then(() => { this.pumps.delete(pump) }) }) } diff --git a/packages/client/connection/tests/websocket-downlink.spec.ts b/packages/client/connection/tests/websocket-downlink.spec.ts index 40bab41135..1702dbcf83 100644 --- a/packages/client/connection/tests/websocket-downlink.spec.ts +++ b/packages/client/connection/tests/websocket-downlink.spec.ts @@ -21,7 +21,9 @@ afterEach(async () => { function untilAbort(signal: AbortSignal): Promise { if (signal.aborted) return Promise.resolve() - return new Promise(resolve => signal.addEventListener('abort', () => { resolve() }, { once: true })) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) } async function * idle(signal: AbortSignal): AsyncGenerator> { @@ -147,7 +149,7 @@ describe('WebSocket downlinks', () => { await once(socket, 'open') const closed = once(socket, 'close') socket.send('upstream payload') - const [code, reason] = await closed + const [code, reason] = await closed as [number, Buffer] expect(code).toBe(1008) expect(String(reason)).toBe('downlink only') await vi.waitFor(() => { expect(aborted).toBe(true) }) @@ -197,9 +199,9 @@ describe('WebSocket downlinks', () => { it('drops a source frame that races after the client has closed', async () => { let release!: () => void - const gate = new Promise(resolve => { release = resolve }) + const gate = new Promise((resolve) => { release = resolve }) let finish!: () => void - const finished = new Promise(resolve => { finish = resolve }) + const finished = new Promise((resolve) => { finish = resolve }) let sourceSignal: AbortSignal | undefined const downlinks = new WebSocketDownlinks(api( async function * (signal) { @@ -227,7 +229,7 @@ describe('WebSocket downlinks', () => { it('contains socket send callback failures and closes the downlink', async () => { let release!: () => void - const gate = new Promise(resolve => { release = resolve }) + const gate = new Promise((resolve) => { release = resolve }) const downlinks = new WebSocketDownlinks(api( async function * () { await gate @@ -262,4 +264,39 @@ describe('WebSocket downlinks', () => { await downlinks.close() await expect(downlinks.close()).rejects.toThrow('The server is not running') }) + + it('waits for source cleanup before teardown resolves', async () => { + let cleanupStarted!: () => void + const started = new Promise((resolve) => { cleanupStarted = resolve }) + let releaseCleanup!: () => void + const cleanupGate = new Promise((resolve) => { releaseCleanup = resolve }) + let cleaned = false + const downlinks = new WebSocketDownlinks(api( + async function * (signal) { + try { + await untilAbort(signal) + } finally { + cleanupStarted() + await cleanupGate + cleaned = true + } + }, + idle, + )) + const host = await serve(downlinks) + const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + await once(socket, 'open') + let closed = false + const closing = host.close().then(() => { closed = true }) + try { + await started + expect(closed).toBe(false) + releaseCleanup() + await closing + expect(cleaned).toBe(true) + } finally { + releaseCleanup() + await closing + } + }) }) diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index f9d277d3fd..8b53e55af5 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.i18n.yaml @@ -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: f01c1a66b19e4f49b9cad31a6d41555aecb28168 -README.zh.md: 980bc3dbac4dac2e758e0043ead292fb5a42e674 +README.md: 196f350d87c5322cd3e9cda6e40587d35acd08c4 +README.zh.md: 0ae0470eab0aae2f6b539404621c611d95827977 diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index f01c1a66b1..196f350d87 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -6,7 +6,7 @@ Web HTTP and upgrade-route registration plugin (default-exported `HttpServerServ 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. 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. +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 or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed. 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. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 980bc3dbac..0ae0470eab 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -6,7 +6,7 @@ Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配 该包不了解任何 harness 概念:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的 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(资源释放)。处理 HTTP 请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错会记录 warning 并销毁其 socket。资源释放会先调用 `close()` 与 `closeAllConnections()`,再销毁 webserver 仍跟踪的升级 socket,确保升级连接不会悬住 teardown。 +监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。 在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。 diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index d701061530..6b46b8704d 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -172,6 +172,15 @@ export class HttpServerService extends Service { }) }) this.server.on('upgrade', (req, socket, head) => { + const onError = (error: Error): void => { + this.ctx.logger.warn(error) + socket.destroy() + } + socket.on('error', onError) + socket.once('close', () => { + socket.off('error', onError) + this.upgradedSockets.delete(socket) + }) let route: WebUpgradeRoute | undefined try { /* v8 ignore next -- node:http always sets url on server requests. */ @@ -186,7 +195,6 @@ export class HttpServerService extends Service { 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))) @@ -210,11 +218,17 @@ export class HttpServerService extends Service { // 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((resolve) => { - this.server.close(() => { resolve() }) + this.ctx.effect(() => async () => { + const serverClosed = new Promise((resolve) => { + this.server.close(() => { resolve() }) + }) this.server.closeAllConnections() - for (const socket of this.upgradedSockets) socket.destroy() - }), 'httpServer.listen') + const upgradedClosed = [...this.upgradedSockets].map(socket => new Promise((resolve) => { + socket.once('close', () => { resolve() }) + socket.destroy() + })) + await Promise.all([serverClosed, ...upgradedClosed]) + }, 'httpServer.listen') } /** Longest-prefix-wins over the prefix table after an exact-table miss. */ diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 743784fae0..19a252d53a 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -87,7 +87,7 @@ async function upgrade(port: number, path: string): Promise { // 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. + let upgradedServerClosed = false const disposeUpgrade = server.registerUpgrade({ path: '/events', handler: (_req, socket) => { + socket.once('close', () => { upgradedServerClosed = true }) socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: dsh-test\r\n\r\n') }, }) @@ -166,10 +168,34 @@ describe('real Loader composition', () => { disposeUpgrade() expect(() => server.registerUpgrade({ path: '/events', handler: () => {} })).not.toThrow() + // The webserver contains raw-socket errors even before an upgrade handler + // has installed its protocol implementation. + server.registerUpgrade({ + path: '/upgrade-error', + handler: async (_req, socket) => { + await Promise.resolve() + socket.destroy(new Error('test upgrade transport failure')) + }, + }) + const failedUpgrade = connect(port, '127.0.0.1') + failedUpgrade.on('error', () => { /* The server-side reset is the fixture outcome. */ }) + await once(failedUpgrade, 'connect') + const failedUpgradeClosed = once(failedUpgrade, 'close') + failedUpgrade.write([ + 'GET /upgrade-error HTTP/1.1', + `Host: 127.0.0.1:${String(port)}`, + 'Connection: Upgrade', + 'Upgrade: dsh-test', + '', + '', + ].join('\r\n')) + await failedUpgradeClosed + expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' }) + // Teardown closes both ordinary and upgraded sockets before it resolves. - const upgradedClosed = once(upgraded, 'close') await loaded.fiber.dispose() - await upgradedClosed + expect(upgradedServerClosed).toBe(true) + upgraded.destroy() await expect(request(port, '/probe')).rejects.toThrow() }) From 3caa1752867f81db5f366bdbffd46bcc0690169c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:49:06 +0800 Subject: [PATCH 6/7] test(connection): use valid mux frames --- .../client/connection/tests/websocket-downlink.spec.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/client/connection/tests/websocket-downlink.spec.ts b/packages/client/connection/tests/websocket-downlink.spec.ts index 1702dbcf83..9d53a82820 100644 --- a/packages/client/connection/tests/websocket-downlink.spec.ts +++ b/packages/client/connection/tests/websocket-downlink.spec.ts @@ -208,7 +208,10 @@ describe('WebSocket downlinks', () => { sourceSignal = signal try { await gate - yield { rpcId: RpcId('late'), payload: { type: 'host/commands-changed' } } + yield { + rpcId: RpcId('late'), + payload: { type: 'session/subscribed', sessionId: 'session-late' as never, lastSeq: 0 }, + } } finally { finish() } @@ -233,7 +236,10 @@ describe('WebSocket downlinks', () => { const downlinks = new WebSocketDownlinks(api( async function * () { await gate - yield { rpcId: RpcId('send-failure'), payload: { type: 'host/commands-changed' } } + yield { + rpcId: RpcId('send-failure'), + payload: { type: 'session/subscribed', sessionId: 'session-send' as never, lastSeq: 0 }, + } }, idle, )) From b0b50be64f41f416fb60ea940065425faef6f4f9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:58:40 +0800 Subject: [PATCH 7/7] style(connection): satisfy callback lint --- packages/client/connection/tests/client-apply.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 9bc645c847..524983fb4f 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -125,7 +125,7 @@ describe('connection client apply', () => { const fetch = vi.spyOn(globalThis, 'fetch') const client = (await mount()).api as WebApiClient const envelopes: RpcMessage[][] = [] - client.subscribeEnvelopes(batch => { envelopes.push([...batch]) }) + client.subscribeEnvelopes((batch) => { envelopes.push([...batch]) }) const opened: string[] = [] const muxAbort = new AbortController() const hostAbort = new AbortController()