SaylorTwift HF Staff commited on
Commit
797be33
·
verified ·
1 Parent(s): e142a32

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. packages/client/connection/README.i18n.yaml +6 -0
  2. packages/client/connection/README.md +81 -0
  3. packages/client/connection/README.zh.md +81 -0
  4. packages/client/connection/package.json +57 -0
  5. packages/client/connection/src/api-path.ts +7 -0
  6. packages/client/connection/src/api-request-trust.ts +118 -0
  7. packages/client/connection/src/browser-auth.ts +313 -0
  8. packages/client/connection/src/client/api.ts +25 -0
  9. packages/client/connection/src/client/connection.ts +323 -0
  10. packages/client/connection/src/client/index.ts +324 -0
  11. packages/client/connection/src/client/random-uuid.ts +14 -0
  12. packages/client/connection/src/client/rpc.ts +120 -0
  13. packages/client/connection/src/http-bridge.ts +111 -0
  14. packages/client/connection/src/index.ts +143 -0
  15. packages/client/connection/src/loopback-hostname.ts +18 -0
  16. packages/client/connection/src/recovery-config.ts +44 -0
  17. packages/client/connection/src/rpc-host.ts +303 -0
  18. packages/client/connection/src/rpc-schema.ts +53 -0
  19. packages/client/connection/src/rpc.ts +251 -0
  20. packages/client/connection/tests/api-helpers.client.spec.ts +21 -0
  21. packages/client/connection/tests/api-request-trust.host.spec.ts +115 -0
  22. packages/client/connection/tests/browser-auth.host.spec.ts +250 -0
  23. packages/client/connection/tests/browser-credentials.ts +36 -0
  24. packages/client/connection/tests/client-apply.client.spec.ts +620 -0
  25. packages/client/connection/tests/connection.client.spec.ts +800 -0
  26. packages/client/connection/tests/fake-generation.client.ts +80 -0
  27. packages/client/connection/tests/fetch-routes.host.spec.ts +83 -0
  28. packages/client/connection/tests/generation.client.spec.ts +65 -0
  29. packages/client/connection/tests/http-bridge.host.spec.ts +147 -0
  30. packages/client/connection/tests/loopback-hostname.client.spec.ts +18 -0
  31. packages/client/connection/tests/node-half.host.spec.ts +565 -0
  32. packages/client/connection/tests/recovery-config.client.spec.ts +32 -0
  33. packages/client/connection/tests/rpc-schema.host.spec.ts +58 -0
  34. packages/client/connection/tsconfig.client.json +42 -0
  35. packages/client/connection/tsconfig.host.json +43 -0
  36. packages/client/connection/tsconfig.json +11 -0
  37. packages/client/connection/tsdown.config.ts +3 -0
  38. packages/client/file-upload/src/client/contract.ts +33 -0
  39. packages/client/file-upload/src/client/index.ts +26 -0
  40. packages/client/file-upload/src/client/runtime.ts +345 -0
  41. packages/client/file-upload/src/http-route.ts +81 -0
  42. packages/client/file-upload/src/index.ts +265 -0
  43. packages/client/file-upload/src/protocol.ts +2 -0
  44. packages/client/file-upload/src/types.ts +35 -0
  45. packages/client/file-upload/tests/file-upload-http.host.spec.ts +126 -0
  46. packages/client/file-upload/tests/file-upload.client.spec.ts +497 -0
  47. packages/client/locale/README.i18n.yaml +6 -0
  48. packages/client/locale/README.md +142 -0
  49. packages/client/locale/README.zh.md +142 -0
  50. packages/client/locale/package.json +70 -0
packages/client/connection/README.i18n.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/client/connection/README.md
5
+ README.md: c8c2b2f6edb0ce43a07a664bcc2b3c9ece047b7b
6
+ README.zh.md: 2c25c0d3520fd7e5d3eeeec98c41cc1d3d4d25d8
packages/client/connection/README.md ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ description: "Browser-host wire layer for the web GUI: Remote RPC, event-stream delivery with reconnect, exact Fetch routes, the /api HTTP bridge, and the browser-trust fence."
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-client-connection
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, generic RPC, the active generation and its Host facts, observable recovery state, an immediate reconnect command, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` applies its retry policy.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Browser authentication and request trust](#browser-authentication-and-request-trust)
18
+ - [Connection generation](#connection-generation)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md).
29
+
30
+ -----
31
+
32
+ <a id="browser-authentication-and-request-trust"></a>
33
+ ## Browser authentication and request trust
34
+
35
+ Every Host RPC method and WebSocket stream requires one browser session; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens the ordinary root URL with `?token=...`; `frontend-static` delegates root and index requests to `ctx.connection.authorizeIndex`, which accepts that token only on `GET /`, writes an authority-bound signed cookie, and redirects to clean `/`. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. Static assets remain public. The HTTP carrier accepts no query token outside the root exchange and no Authorization-header token.
36
+
37
+ The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`; they deliberately omit `Secure` because the shipped server uses loopback HTTP.
38
+
39
+ Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md).
40
+
41
+ <a id="connection-generation"></a>
42
+ ## Connection generation
43
+
44
+ API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready', clientId, host: { home } }` item before events. `ConnectionController` publishes that generation and calls `onConnected` only after the ready item arrives, so baseline acquisition cannot race ahead of incremental observation.
45
+
46
+ An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. A pending handshake logs a slow-Host warning after 3 seconds and logs the readiness timeout and aborts after 15 seconds by default, including time spent waiting for the physical socket. The source must stop delivery, release resources, and settle after cancellation before a replacement starts; late readiness from a cancelled source cannot publish a generation. While the browser reports network availability, the controller publishes `connecting` and retries with 50%–100% jitter under caps of 500ms, 1s, 2s, 4s, 8s, and 10s, continuing at the final cap until recovery. Every retry asks Gateway to replace the physical WebSocket once and reopens `$events`. The [continuous recovery decision](../../../.agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.md) owns the deadlines and retry policy.
47
+
48
+ `ctx.connection.reconnect()` interrupts active work, resets the sequence, and starts retry 1 immediately. Browser `offline` aborts active work, publishes `disconnected`, and suspends automatic attempts; the next `online` transition resets the sequence and starts at the 500ms tier. Only a ready item publishes `connected`. Gateway mux owns no independent retry schedule.
49
+
50
+ Set the Host Connection row's `config.recovery` to override retry caps, the growth factor, or handshake warning and cancellation times; the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-connection) lists accepted fields. The Host validates these values and injects them into each served page. The Client validates the bootstrap data before providing Connection and uses those defaults when Gateway starts its loop; explicit `start()` timing overrides take precedence. The growth factor must be finite and at least one. Readiness, failure, cancellation, or a hard deadline that occurs before the warning cancels that warning. Reload the page after changing Host recovery configuration.
51
+
52
+
53
+ <a id="model-experience"></a>
54
+ ## Model Experience
55
+
56
+ None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request.
57
+
58
+ #### KV Cache effect
59
+
60
+ None; this package neither assembles nor sends a provider request.
61
+
62
+ ## Known Limitations and Deferred Work
63
+
64
+ <a id="known-limitations-and-deferred-work"></a>
65
+
66
+ - **Buffered `/api` routes retain each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) bounds ordinary image and RPC envelopes. Opt-in streaming routes receive backpressured chunks and bypass the aggregate cap; route implementations own persistence, cancellation, and any storage quota.
67
+ - **The browser cookie is not marked `Secure`** — loopback HTTP is the shipped transport, so exposing the same authority over plaintext networking can expose the bearer cookie in transit.
68
+ - **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record and restarting `dsh` revokes every session.
69
+
70
+
71
+ <a id="dev-note"></a>
72
+ ### Dev Note
73
+
74
+ <details>
75
+ <summary>Working context for maintainers — click to expand</summary>
76
+
77
+ None.
78
+
79
+ </details>
80
+
81
+ **Runtime invariant:** No companion is published. Browser-session verification reads the credential record asynchronously at the request that authorizes work, while the credentials companion owns record commit-event lifetime. Stream/reconnect sequencing and rpcId round-trip discipline are exercised directly by behavior specs, and route register/dispose symmetry is audited by the webserver companion.
packages/client/connection/README.zh.md ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ description: "Web GUI 的浏览器与 Host 之间的协议层:Remote RPC、带重连的事件流投递、精确 Fetch 路由、/api HTTP 桥与浏览器信任栅栏。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-client-connection
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ 本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC、当前 generation 及其 Host 信息、可观察的恢复状态、立即重连命令,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 执行重试策略。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [浏览器认证与请求信任](#browser-authentication-and-request-trust)
18
+ - [Connection generation](#connection-generation)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与暂缓事项](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。
29
+
30
+ -----
31
+
32
+ <a id="browser-authentication-and-request-trust"></a>
33
+ ## 浏览器认证与请求信任
34
+
35
+ 每个 Host RPC 方法和 WebSocket 流都要求一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的普通根 URL;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `/`。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。
36
+
37
+ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml`;`BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`。
38
+
39
+ 认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。
40
+
41
+ <a id="connection-generation"></a>
42
+ ## Connection generation
43
+
44
+ API Gateway Client 把内部 `$events` 逻辑流注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事���。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。
45
+
46
+ `$events` 结束、Remote 流报错、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。默认情况下,挂起的握手在 3 秒后记录 Host 响应缓慢告警,在 15 秒后记录就绪超时并中止,包含等待物理 socket 的时间。取消后,source 必须停止投递、释放资源并结束,替换 source 才能启动;已取消 source 迟到的 ready 不能发布 generation。浏览器报告网络可用时,Controller 发布 `connecting`,并在 500ms、1s、2s、4s、8s 与 10s 上限内采用 50%–100% 抖动重试,达到终档后继续尝试直到恢复。每次重试都要求 Gateway 替换一次物理 WebSocket,再重开 `$events`。[持续恢复决策](../../../.agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.zh.md)规定握手期限与重试策略。
47
+
48
+ `ctx.connection.reconnect()` 会中断活动工作、重置序列,并立即开始 retry 1。浏览器 `offline` 会中断活动工作、发布 `disconnected` 并暂停自动尝试;下一次 `online` 转换会重置序列并从 500ms 档开始。只有 ready 项会发布 `connected`。Gateway mux 不拥有独立重试调度。
49
+
50
+ 可通过 Host Connection 行的 `config.recovery` 覆盖重试上限、增长因子或握手告警与取消时间;[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-connection)列出接受的字段。Host 校验这些值,并将其注入所提供的每个页面。Client 在提供 Connection 前校验启动数据,并在 Gateway 启动循环时采用这些默认值;显式传给 `start()` 的时序覆盖优先。增长因子必须是至少为一的有限数。若就绪、失败、取消或硬期限先于告警发生,该告警会被取消。修改 Host 恢复配置后需重新加载页面。
51
+
52
+
53
+ <a id="model-experience"></a>
54
+ ## 模型体验
55
+
56
+ 无。协议消费层只在浏览器与主机之间搬运已经组合好的消息;这里没有任何内容进入模型请求。
57
+
58
+ #### KV Cache 影响
59
+
60
+ 无;该包既不组装也不发送提供方请求。
61
+
62
+ ## 已知限制与暂缓事项
63
+
64
+ <a id="known-limitations-and-deferred-work"></a>
65
+
66
+ - **缓冲型 `/api` 路由会把每个请求体保留在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)限制普通图片与 RPC 信封。显式启用的流式路由接收带背压的分块并绕过总量上限;路由实现负责持久化、取消与存储配额。
67
+ - **浏览器 cookie 不带 `Secure`**:当前随产品提供的传输方式是 loopback HTTP;若部署经明文网络暴露同一 authority,bearer cookie 可能在传输中泄露。
68
+ - **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录并重启 `dsh` 会撤销全部会话。
69
+
70
+
71
+ <a id="dev-note"></a>
72
+ ### 开发备注
73
+
74
+ <details>
75
+ <summary>维护者工作上下文——点击展开</summary>
76
+
77
+ 无。
78
+
79
+ </details>
80
+
81
+ **运行时不变式:** 不发布伴生入口。浏览器会话验证会在请求授权工作时异步读取凭据记录,而记录的 commit-event 生命周期由 credentials 伴生入口负责;流与重连的时序及 rpcId 往返约束由行为规范直接验证,路由注册与 dispose(资源释放)的对称性由 webserver 伴生入口审计。
packages/client/connection/package.json ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@deepseek-ai/dsh-client-connection",
3
+ "description": "Authenticated RPC transport and generation lifecycle",
4
+ "version": "0.1.6-alpha.1",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/client/connection"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./client": {
22
+ "types": "./lib/types/client/index.d.ts",
23
+ "default": "./lib/client.js"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "dsh": {
29
+ "client": {
30
+ "inject": [],
31
+ "platform": "web",
32
+ "immediately": true
33
+ }
34
+ },
35
+ "license": "MIT",
36
+ "dependencies": {
37
+ "@deepseek-ai/dsh-credentials": "workspace:^",
38
+ "@deepseek-ai/schemastery": "workspace:^",
39
+ "zod": "^4.4.3"
40
+ },
41
+ "files": [
42
+ "lib/index.js",
43
+ "lib/client.js",
44
+ "lib/types/**/*.d.ts"
45
+ ],
46
+ "peerDependencies": {
47
+ "@deepseek-ai/cordis": "workspace:^"
48
+ },
49
+ "devDependencies": {
50
+ "@deepseek-ai/cordis": "workspace:^",
51
+ "@deepseek-ai/dsh-attachment": "workspace:^",
52
+ "@deepseek-ai/dsh-brand": "workspace:^",
53
+ "@deepseek-ai/dsh-host-webserver": "workspace:^",
54
+ "@deepseek-ai/dsh-llm": "workspace:^",
55
+ "@deepseek-ai/dsh-session": "workspace:^"
56
+ }
57
+ }
packages/client/connection/src/api-path.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The /api URL prefix — single source for both halves of the web transport.
3
+ * The node half registers this prefix on the web server.
4
+ */
5
+
6
+ /** Route prefix owning every api request (`/api` and `/api/<anything>`). */
7
+ export const API_PATH = '/api'
packages/client/connection/src/api-request-trust.ts ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Browser-trust fence for every /api request. Defends the two confused-deputy
3
+ * paths a browser opens against a local HTTP API — DNS rebinding (Host names
4
+ * the attacker's domain while the socket reaches this server) and cross-site
5
+ * requests fired from a malicious page. The Host fence binds every request,
6
+ * browser-looking or not: over plain HTTP a browser attaches neither Origin
7
+ * nor Fetch-Metadata to reads (images and navigations — those
8
+ * headers go only to trustworthy destinations), so an unmarked request may
9
+ * still be a rebound browser read and Host is the one header rebinding cannot
10
+ * forge. Non-browser and remote clients pass the same fence via loopback,
11
+ * deployment-derived LAN IP literals, or a declared `trustedHosts` authority.
12
+ * Network reachability and authentication stay out of scope: binding policy
13
+ * belongs to the webserver config, and this fence is not an auth layer.
14
+ */
15
+
16
+ import { isLoopbackHostname } from './loopback-hostname.ts'
17
+ import type { ConnectionTrustRequest } from './rpc.ts'
18
+
19
+ function header(headers: ConnectionTrustRequest['headers'], name: string): string | undefined {
20
+ if (headers instanceof Headers) return headers.get(name) ?? undefined
21
+ const value = headers[name]
22
+ return typeof value === 'string' ? value : undefined
23
+ }
24
+
25
+ /** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
26
+ function parseAuthority(authority: string): URL | undefined {
27
+ try {
28
+ // http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws.
29
+ return new URL(`http://${authority}`)
30
+ } catch {
31
+ return undefined
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Assert one configured `trustedHosts` entry is a bare authority (`host` or
37
+ * `host:port`) in canonical form: it must survive WHATWG parsing unchanged
38
+ * (case aside). Anything parsing would silently rewrite is refused as a typo
39
+ * that must fail the load loudly instead of being ignored until requests 403
40
+ * or quietly changing the grant: URL parts beyond the authority
41
+ * (`harness.internal/path`, `user@harness.internal` — which would authorize
42
+ * the embedded hostname), stripped whitespace, a dangling colon or
43
+ * zero-padded port (which would broaden an intended exact-port grant to every
44
+ * port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
45
+ * unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
46
+ * carries).
47
+ * @param entry - the configured value, verbatim.
48
+ */
49
+ export function assertTrustedAuthority(entry: string): void {
50
+ const entryUrl = parseAuthority(entry)
51
+ if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return
52
+ throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)
53
+ }
54
+
55
+ /**
56
+ * Canonical form of a parsed authority: `hostname` when no port was written,
57
+ * else `hostname:port`. The port is judged from URL parses under both special
58
+ * schemes (their default ports differ, so `:80` and `:443` still count as
59
+ * explicit), never from the raw string, where WHATWG trimming would misread
60
+ * shapes like `host:port ` as port-less.
61
+ */
62
+ function canonicalAuthority(entry: string, entryUrl: URL): string {
63
+ // An authority that parsed under http cannot fail under https.
64
+ const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
65
+ return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
66
+ }
67
+
68
+ /**
69
+ * Whether the request authority matches a `trustedHosts` entry. An entry with
70
+ * an explicit port matches that exact authority; a port-less entry matches the
71
+ * hostname on any port (the shape the CLI derives for IP-literal LAN serving,
72
+ * where the bound port may be OS-assigned). Both sides compare through WHATWG
73
+ * normalization, so case and a redundant `:80` never decide trust.
74
+ */
75
+ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
76
+ return trustedHosts.some((entry) => {
77
+ const entryUrl = parseAuthority(entry)
78
+ if (entryUrl === undefined) return false
79
+ return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
80
+ ? entryUrl.hostname === hostUrl.hostname
81
+ : entryUrl.host === hostUrl.host
82
+ })
83
+ }
84
+
85
+ /**
86
+ * Decide whether one /api request may reach the RPC bridge.
87
+ * @param request - Node HTTP or Fetch request facts (headers).
88
+ * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
89
+ * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
90
+ */
91
+ export function isTrustedApiRequest(request: ConnectionTrustRequest, trustedHosts: readonly string[]): boolean {
92
+ // Host fence (DNS-rebinding defense), applied to every request: the browser
93
+ // fills Host from the URL it believes it is talking to, so a rebound page
94
+ // carries the attacker's domain here even though the socket lands on this
95
+ // server. There is no marker shortcut — a browser read over plain HTTP
96
+ // (images and navigations) arrives with neither Origin nor
97
+ // Fetch-Metadata, indistinguishable from curl, and its response is readable
98
+ // by the rebound page.
99
+ const host = header(request.headers, 'host')
100
+ if (host === undefined) return false
101
+ const hostUrl = parseAuthority(host)
102
+ if (hostUrl === undefined) return false
103
+ if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
104
+ // Cross-site fence: modern browsers label the initiator relationship on
105
+ // every fetch; an explicit cross-site marker is refused regardless of Origin.
106
+ if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
107
+ // Origin fence: when a browser attaches an Origin it must be exactly this
108
+ // authority (compared through the same normalization as the Host). Absent
109
+ // Origin is fine — the Host fence above already bound the request. The
110
+ // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.
111
+ const origin = header(request.headers, 'origin')
112
+ if (origin === undefined) return true
113
+ try {
114
+ return new URL(origin).host === hostUrl.host
115
+ } catch {
116
+ return false
117
+ }
118
+ }
packages/client/connection/src/browser-auth.ts ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Browser-session authentication for the Host Connection carrier. */
2
+
3
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
4
+ import { credentialKey } from '@deepseek-ai/dsh-credentials'
5
+ import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials'
6
+ import type {
7
+ ConnectionIndexRequest,
8
+ ConnectionIndexResponse,
9
+ ConnectionTrustRequest,
10
+ } from './rpc.ts'
11
+
12
+ const AUTH_RECORD_KEY = credentialKey('client-connection', 'browser-session')
13
+ const DAY_MILLISECONDS = 24 * 60 * 60 * 1000
14
+ const SECRET_BYTES = 32
15
+ const TOKEN_QUERY = 'token'
16
+ const COOKIE_PREFIX = 'dsh-auth-'
17
+ const COOKIE_PAYLOAD_VERSION = 1
18
+ const STORED_SECRET_VERSION = 1
19
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/
20
+ const PROCESS_LAUNCH_TOKENS = new WeakMap<object, string>()
21
+
22
+ interface StoredSecretPayload {
23
+ readonly version: typeof STORED_SECRET_VERSION
24
+ readonly secret: string
25
+ }
26
+
27
+ interface BrowserCookiePayload {
28
+ readonly version: typeof COOKIE_PAYLOAD_VERSION
29
+ readonly authority: string
30
+ readonly issuedAt: number
31
+ readonly expiresAt: number
32
+ }
33
+
34
+ function isRecord(value: unknown): value is Record<string, unknown> {
35
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
36
+ }
37
+
38
+ function encodeBase64Url(value: Uint8Array): string {
39
+ return Buffer.from(value).toString('base64')
40
+ .replaceAll('+', '-')
41
+ .replaceAll('/', '_')
42
+ .replace(/=+$/u, '')
43
+ }
44
+
45
+ function decodeBase64Url(value: string): Buffer | undefined {
46
+ if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return undefined
47
+ const padding = '='.repeat((4 - value.length % 4) % 4)
48
+ const decoded = Buffer.from(value.replaceAll('-', '+').replaceAll('_', '/') + padding, 'base64')
49
+ return encodeBase64Url(decoded) === value ? decoded : undefined
50
+ }
51
+
52
+ function processLaunchToken(owner: object): string {
53
+ const existing = PROCESS_LAUNCH_TOKENS.get(owner)
54
+ if (existing !== undefined) return existing
55
+ const created = encodeBase64Url(randomBytes(SECRET_BYTES))
56
+ PROCESS_LAUNCH_TOKENS.set(owner, created)
57
+ return created
58
+ }
59
+
60
+ function header(
61
+ headers: ConnectionTrustRequest['headers'],
62
+ name: string,
63
+ ): string | undefined {
64
+ if (headers instanceof Headers) return headers.get(name) ?? undefined
65
+ const value = headers[name]
66
+ return typeof value === 'string' ? value : undefined
67
+ }
68
+
69
+ /** Canonical request authority used as the cookie name and signed audience. */
70
+ function requestAuthority(headers: ConnectionTrustRequest['headers']): string | undefined {
71
+ const host = header(headers, 'host')
72
+ if (host === undefined) return undefined
73
+ try {
74
+ return new URL(`http://${host}`).host
75
+ } catch {
76
+ return undefined
77
+ }
78
+ }
79
+
80
+ function canonicalSecret(value: unknown): Buffer | undefined {
81
+ if (typeof value !== 'string') return undefined
82
+ const decoded = decodeBase64Url(value)
83
+ if (decoded === undefined || decoded.byteLength !== SECRET_BYTES) return undefined
84
+ return decoded
85
+ }
86
+
87
+ function storedSecret(record: CredentialRecord | undefined): Buffer | undefined {
88
+ if (record === undefined) return undefined
89
+ if (record.kind !== 'grant' || !isRecord(record.payload)
90
+ || record.payload.version !== STORED_SECRET_VERSION) {
91
+ throw new Error('client-connection: browser-session credential record has an unsupported format')
92
+ }
93
+ const secret = canonicalSecret(record.payload.secret)
94
+ if (secret === undefined) {
95
+ throw new Error('client-connection: browser-session credential record has an invalid secret')
96
+ }
97
+ return secret
98
+ }
99
+
100
+ function tokenMatches(actual: string, expected: string): boolean {
101
+ const actualBytes = Buffer.from(actual, 'utf8')
102
+ const expectedBytes = Buffer.from(expected, 'utf8')
103
+ return actualBytes.byteLength === expectedBytes.byteLength && timingSafeEqual(actualBytes, expectedBytes)
104
+ }
105
+
106
+ function cookieName(authority: string): string {
107
+ return COOKIE_PREFIX + encodeBase64Url(createHash('sha256').update(authority).digest())
108
+ }
109
+
110
+ /** Read the exact generated cookie without implementing general Cookie decoding. */
111
+ function cookieValue(headerValue: string, name: string): string | undefined {
112
+ for (const segment of headerValue.split(';')) {
113
+ const at = segment.indexOf('=')
114
+ if (at === -1 || segment.slice(0, at).trim() !== name) continue
115
+ return segment.slice(at + 1).trim()
116
+ }
117
+ return undefined
118
+ }
119
+
120
+ /** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */
121
+ function sessionCookie(name: string, value: string, expiresAt: number, maxAgeSeconds: number): string {
122
+ return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`
123
+ }
124
+
125
+ function signature(secret: Buffer, body: string): Buffer {
126
+ return createHmac('sha256', secret).update(body).digest()
127
+ }
128
+
129
+ function encodeCookie(payload: BrowserCookiePayload, secret: Buffer): string {
130
+ const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), 'utf8'))
131
+ return `v1.${body}.${encodeBase64Url(signature(secret, body))}`
132
+ }
133
+
134
+ function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | undefined {
135
+ const parts = value.split('.')
136
+ const [version, body, encodedSignature] = parts
137
+ if (parts.length !== 3 || version !== 'v1' || body === undefined || encodedSignature === undefined) {
138
+ return undefined
139
+ }
140
+ const actualSignature = decodeBase64Url(encodedSignature)
141
+ if (actualSignature === undefined) return undefined
142
+ const expectedSignature = signature(secret, body)
143
+ if (actualSignature.byteLength !== expectedSignature.byteLength
144
+ || !timingSafeEqual(actualSignature, expectedSignature)) return undefined
145
+ let decoded: unknown
146
+ try {
147
+ const bodyBytes = decodeBase64Url(body)
148
+ if (bodyBytes === undefined) return undefined
149
+ decoded = JSON.parse(bodyBytes.toString('utf8'))
150
+ } catch {
151
+ return undefined
152
+ }
153
+ if (!isRecord(decoded)
154
+ || decoded.version !== COOKIE_PAYLOAD_VERSION
155
+ || typeof decoded.authority !== 'string'
156
+ || !Number.isSafeInteger(decoded.issuedAt)
157
+ || !Number.isSafeInteger(decoded.expiresAt)) return undefined
158
+ return decoded as unknown as BrowserCookiePayload
159
+ }
160
+
161
+ async function initializeSecret(credentials: CredentialProvider): Promise<Buffer> {
162
+ const generated: StoredSecretPayload = {
163
+ version: STORED_SECRET_VERSION,
164
+ secret: encodeBase64Url(randomBytes(SECRET_BYTES)),
165
+ }
166
+ const record = await credentials.modifyRecord(AUTH_RECORD_KEY, (current) => {
167
+ if (current !== undefined) {
168
+ storedSecret(current)
169
+ return Promise.resolve(undefined)
170
+ }
171
+ return Promise.resolve({ kind: 'grant', payload: generated })
172
+ })
173
+ const secret = storedSecret(record)
174
+ if (secret === undefined) {
175
+ throw new Error('client-connection: browser-session credential record was not created')
176
+ }
177
+ return secret
178
+ }
179
+
180
+ /**
181
+ * Process launch-token exchange and persistent signed-cookie verification.
182
+ * Connection loads the credential provider's signing secret during activation
183
+ * and retains it for synchronous request authentication.
184
+ */
185
+ export class BrowserAuth {
186
+ private readonly launchToken: string
187
+ private readonly maxAgeMilliseconds: number
188
+
189
+ private constructor(
190
+ processOwner: object,
191
+ private readonly secret: Buffer,
192
+ maxAgeDays: number,
193
+ ) {
194
+ this.launchToken = processLaunchToken(processOwner)
195
+ this.maxAgeMilliseconds = maxAgeDays * DAY_MILLISECONDS
196
+ if (!Number.isSafeInteger(this.maxAgeMilliseconds)
197
+ || !Number.isSafeInteger(Date.now() + this.maxAgeMilliseconds)) {
198
+ throw new Error('client-connection: cookieMaxAgeDays exceeds the safe timestamp range')
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Initialize browser authentication and create its durable signing secret
204
+ * when this Harness home has none.
205
+ * @param processOwner - root application context retaining one token across Connection reloads.
206
+ * @param credentials - persistent credential provider for the Web profile.
207
+ * @param maxAgeDays - positive absolute browser-cookie lifetime in days.
208
+ * @returns initialized authentication owner with the process owner's launch token.
209
+ */
210
+ static async create(
211
+ processOwner: object,
212
+ credentials: CredentialProvider,
213
+ maxAgeDays: number,
214
+ ): Promise<BrowserAuth> {
215
+ return new BrowserAuth(processOwner, await initializeSecret(credentials), maxAgeDays)
216
+ }
217
+
218
+ /**
219
+ * Add this process's launch token to the ordinary application root URL.
220
+ * @param baseUrl - canonical browser origin without credentials.
221
+ * @returns root URL carrying the process token as its sole authentication input.
222
+ */
223
+ authenticatedUrl(baseUrl: string): string {
224
+ const url = new URL(baseUrl)
225
+ url.pathname = '/'
226
+ url.search = ''
227
+ url.hash = ''
228
+ url.searchParams.set(TOKEN_QUERY, this.launchToken)
229
+ return url.href
230
+ }
231
+
232
+ /**
233
+ * Authenticate an index request. A valid root query token mints the cookie
234
+ * and redirects to clean `/`; a valid cookie lets the caller serve the
235
+ * index; every other request receives the same minimal 401 response.
236
+ * @param req - incoming root or configured-index request.
237
+ * @param res - response owned when this method returns false.
238
+ * @returns true only when the caller may serve index.html.
239
+ */
240
+ authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): boolean {
241
+ /* v8 ignore next -- node:http always supplies url on server requests. */
242
+ const url = new URL(req.url ?? '/', 'http://dsh.invalid')
243
+ const tokens = url.searchParams.getAll(TOKEN_QUERY)
244
+ if (tokens.length > 0) {
245
+ const authority = requestAuthority(req.headers)
246
+ if (req.method === 'GET' && url.pathname === '/' && tokens.length === 1
247
+ && authority !== undefined && tokenMatches(tokens.join(''), this.launchToken)) {
248
+ const issuedAt = Date.now()
249
+ const expiresAt = issuedAt + this.maxAgeMilliseconds
250
+ const value = encodeCookie({
251
+ version: COOKIE_PAYLOAD_VERSION,
252
+ authority,
253
+ issuedAt,
254
+ expiresAt,
255
+ }, this.secret)
256
+ res.writeHead(303, {
257
+ 'cache-control': 'no-store',
258
+ 'location': '/',
259
+ 'referrer-policy': 'no-referrer',
260
+ 'set-cookie': sessionCookie(
261
+ cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1000),
262
+ ),
263
+ })
264
+ res.end()
265
+ return false
266
+ }
267
+ if (req.method === 'GET' && url.pathname === '/' && this.isAuthenticated(req)) {
268
+ res.writeHead(303, {
269
+ 'cache-control': 'no-store',
270
+ 'location': '/',
271
+ 'referrer-policy': 'no-referrer',
272
+ })
273
+ res.end()
274
+ return false
275
+ }
276
+ this.writeUnauthorized(req, res)
277
+ return false
278
+ }
279
+ if (this.isAuthenticated(req)) return true
280
+ this.writeUnauthorized(req, res)
281
+ return false
282
+ }
283
+
284
+ /**
285
+ * Verify the authority-bound browser cookie on a Host request.
286
+ * @param request - request headers carrying Host and Cookie.
287
+ * @returns true only for an unexpired cookie signed by this activation's loaded secret.
288
+ */
289
+ isAuthenticated(request: ConnectionTrustRequest): boolean {
290
+ const authority = requestAuthority(request.headers)
291
+ const rawCookie = header(request.headers, 'cookie')
292
+ if (authority === undefined || rawCookie === undefined) return false
293
+ const value = cookieValue(rawCookie, cookieName(authority))
294
+ if (value === undefined) return false
295
+ const payload = decodeCookie(value, this.secret)
296
+ if (payload === undefined || payload.authority !== authority) return false
297
+ const now = Date.now()
298
+ return payload.issuedAt <= now
299
+ && payload.expiresAt > now
300
+ && payload.expiresAt > payload.issuedAt
301
+ && payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds
302
+ }
303
+
304
+ private writeUnauthorized(req: ConnectionIndexRequest, res: ConnectionIndexResponse): void {
305
+ res.writeHead(401, {
306
+ 'cache-control': 'no-store',
307
+ 'content-type': 'text/plain; charset=utf-8',
308
+ })
309
+ res.end(req.method === 'HEAD'
310
+ ? undefined
311
+ : 'dsh web authentication required; reopen the URL printed by dsh web.\n')
312
+ }
313
+ }
packages/client/connection/src/client/api.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Browser-safe Connection protocol and shared application value exports. */
2
+
3
+ export type {
4
+ ClientRequest,
5
+ RpcMessage,
6
+ RpcRequest,
7
+ RpcResponse,
8
+ RpcResult,
9
+ ServerResponse,
10
+ } from '../rpc.ts'
11
+ export { RpcId, transportError } from '../rpc.ts'
12
+ export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
13
+ export type { MessageId } from '@deepseek-ai/dsh-llm/brand'
14
+ export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
15
+
16
+ import type { RpcResponse, RpcResult } from '../rpc.ts'
17
+
18
+ /**
19
+ * Return the business result carried by an RPC response.
20
+ * @param response - RPC response to unwrap.
21
+ * @returns the response's business result.
22
+ */
23
+ export function resultOf<T>(response: RpcResponse<T>): RpcResult<T> {
24
+ return response.result
25
+ }
packages/client/connection/src/client/connection.ts ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Connection generation readiness, cancellation, and continuous recovery. */
2
+ import { resolveConnectionConfig, type ConnectionRecoveryConfig } from '../recovery-config.ts'
3
+
4
+ export type { ConnectionRecoveryConfig } from '../recovery-config.ts'
5
+
6
+ /** Stable Host facts delivered by one established Remote event generation. */
7
+ export interface ConnectionHostInfo {
8
+ /** Host account home used only to abbreviate displayed filesystem paths. */
9
+ readonly home: string
10
+ }
11
+
12
+ /** One successfully established Host generation. */
13
+ export interface ConnectionGeneration {
14
+ /** Monotone generation number within this Client runtime. */
15
+ readonly id: number
16
+ /** Host facts carried by this generation's opening frame. */
17
+ readonly host: ConnectionHostInfo
18
+ }
19
+
20
+ const MANUAL_RECONNECT = new Error('connection: manual reconnect requested')
21
+ const NETWORK_STATE_CHANGED = new Error('connection: browser network state changed')
22
+
23
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
24
+ return new Promise((resolve) => {
25
+ const t = setTimeout(done, ms)
26
+ signal.addEventListener('abort', done, { once: true })
27
+ function done(): void {
28
+ clearTimeout(t)
29
+ signal.removeEventListener('abort', done)
30
+ resolve()
31
+ }
32
+ })
33
+ }
34
+
35
+ function waitForAbort(signal: AbortSignal): Promise<void> {
36
+ if (signal.aborted) return Promise.resolve()
37
+ return new Promise((resolve) => {
38
+ signal.addEventListener('abort', () => { resolve() }, { once: true })
39
+ })
40
+ }
41
+
42
+ /** Connection lifecycle state published after the first attempt has an outcome. */
43
+ export type ConnectionState =
44
+ | 'connected'
45
+ | 'disconnected'
46
+ | 'connecting'
47
+
48
+ /** Connection-generation callbacks owned by API Gateway. */
49
+ export interface ConnectionSinks {
50
+ /** After the generation source reports ready, first connect included. */
51
+ onConnected?: (host: ConnectionHostInfo) => void
52
+ /** State transitions after the initial attempt has an outcome. Equivalent states are deduplicated. */
53
+ onStateChange?: (state: ConnectionState) => void
54
+ /** Start one fresh physical-carrier attempt before each logical retry. */
55
+ onReconnectRequested?: () => void
56
+ }
57
+
58
+ /**
59
+ * One long-lived source defining a Connection generation. The source must
60
+ * attach its incremental listeners before calling `ready`, then remain pending
61
+ * until the generation is lost or `signal` aborts. On abort it must stop
62
+ * delivery, release its resources, and settle before a replacement can start.
63
+ * @param signal - cancellation for the current generation.
64
+ * @param ready - one-shot report that incremental delivery is attached.
65
+ * @returns a promise settling only when this generation ends or fails.
66
+ */
67
+ export type ConnectionGenerationSource = (
68
+ signal: AbortSignal,
69
+ ready: (host: ConnectionHostInfo) => void,
70
+ ) => Promise<void>
71
+
72
+ /**
73
+ * Opens the registered generation source, reconnecting with exponential backoff on loss.
74
+ * State (generation/attempt) is instance-private, never in the store.
75
+ * Sink exceptions do not kill the generation loop.
76
+ */
77
+ export class ConnectionController {
78
+ private generation = 0
79
+ private attempt = 0
80
+ private current: AbortController | null = null
81
+ private retryDelay: AbortController | null = null
82
+ private running = false
83
+ private immediateRetry = false
84
+ private networkAvailable = true
85
+ private lastState: ConnectionState | undefined
86
+ private readonly config: Required<ConnectionRecoveryConfig>
87
+
88
+ constructor(
89
+ private readonly source: ConnectionGenerationSource,
90
+ private readonly sinks: ConnectionSinks = {},
91
+ config: ConnectionRecoveryConfig = {},
92
+ ) {
93
+ this.config = resolveConnectionConfig(config)
94
+ }
95
+
96
+ /** Idempotent: begin the connect/pump/reconnect loop. */
97
+ start(): void {
98
+ if (this.running) return
99
+ this.running = true
100
+ void this.loop()
101
+ }
102
+
103
+ /** Stop the loop and abort the current generation source. */
104
+ stop(): void {
105
+ this.running = false
106
+ this.current?.abort()
107
+ this.current = null
108
+ this.retryDelay?.abort()
109
+ this.retryDelay = null
110
+ }
111
+
112
+ /** Reset the retry sequence and replace the current generation or retry delay immediately. */
113
+ reconnect(): void {
114
+ if (!this.running) return
115
+ this.attempt = 0
116
+ this.immediateRetry = true
117
+ this.emitState('connecting')
118
+ if (!this.isRunning()) return
119
+ this.current?.abort(MANUAL_RECONNECT)
120
+ this.retryDelay?.abort(MANUAL_RECONNECT)
121
+ }
122
+
123
+ /**
124
+ * Suspend automatic retries while offline and restart backoff when the network returns.
125
+ * @param available - whether the browser reports network access.
126
+ */
127
+ setNetworkAvailable(available: boolean): void {
128
+ if (this.networkAvailable === available) return
129
+ this.networkAvailable = available
130
+ this.attempt = 0
131
+ this.immediateRetry = false
132
+ if (!this.running) return
133
+ this.emitState(available ? 'connecting' : 'disconnected')
134
+ if (!this.isRunning()) return
135
+ this.current?.abort(NETWORK_STATE_CHANGED)
136
+ this.retryDelay?.abort(NETWORK_STATE_CHANGED)
137
+ }
138
+
139
+ private backoffCap(attempt: number): number {
140
+ const { backoffBaseMs, backoffFactor, backoffMaxMs } = this.config
141
+ return Math.min(backoffMaxMs, backoffBaseMs * backoffFactor ** Math.max(0, attempt - 1))
142
+ }
143
+
144
+ private backoffDelay(attempt: number): number {
145
+ const cap = this.backoffCap(attempt)
146
+ return cap / 2 + Math.random() * (cap / 2)
147
+ }
148
+
149
+ /** Re-read retry inputs after a potentially reentrant state sink. */
150
+ private isRetryInterrupted(immediate: boolean): boolean {
151
+ return this.immediateRetry || (!this.networkAvailable && !immediate)
152
+ }
153
+
154
+ /** Read through a method: stop() flips the flag across awaits, so narrowing from the loop condition must not stick. */
155
+ private isRunning(): boolean {
156
+ return this.running
157
+ }
158
+
159
+ /** Re-read both mutable liveness guards after a potentially reentrant sink. */
160
+ private isGenerationActive(controller: AbortController): boolean {
161
+ return this.isRunning() && !controller.signal.aborted
162
+ }
163
+
164
+ private async loop(): Promise<void> {
165
+ let retry = false
166
+ while (this.running) {
167
+ if (!this.networkAvailable && !this.immediateRetry) {
168
+ const retryDelay = new AbortController()
169
+ this.retryDelay = retryDelay
170
+ this.emitState('disconnected')
171
+ await waitForAbort(retryDelay.signal)
172
+ if (this.retryDelay === retryDelay) this.retryDelay = null
173
+ if (!this.isRunning()) return
174
+ retry = true
175
+ continue
176
+ }
177
+
178
+ let manualAttempt = false
179
+ if (retry) {
180
+ const immediate = this.immediateRetry
181
+ this.immediateRetry = false
182
+ if (immediate) this.attempt = 0
183
+ manualAttempt = immediate
184
+ const attempt = ++this.attempt
185
+ this.emitState('connecting')
186
+ if (!this.isRunning()) return
187
+ if (this.isRetryInterrupted(immediate)) continue
188
+ if (!immediate) {
189
+ const retryDelay = new AbortController()
190
+ this.retryDelay = retryDelay
191
+ await sleep(this.backoffDelay(attempt), retryDelay.signal)
192
+ if (this.retryDelay === retryDelay) this.retryDelay = null
193
+ if (!this.isRunning()) return
194
+ if (retryDelay.signal.aborted) continue
195
+ }
196
+ console.warn(`[connection] connection lost, retry #${String(attempt)}`)
197
+ this.callSink(() => { this.sinks.onReconnectRequested?.() })
198
+ if (!this.isRunning()) return
199
+ }
200
+
201
+ const gen = ++this.generation
202
+ const ac = new AbortController()
203
+ this.current = ac
204
+
205
+ let sourceReady = false
206
+ let resolveReady!: (host: ConnectionHostInfo) => void
207
+ let rejectReady!: (error: Error) => void
208
+ let rejectSourceLost!: (error: Error) => void
209
+ const ready = new Promise<ConnectionHostInfo>((resolve, reject) => {
210
+ resolveReady = resolve
211
+ rejectReady = reject
212
+ })
213
+ const sourceLost = new Promise<never>((_resolve, reject) => {
214
+ rejectSourceLost = reject
215
+ })
216
+ const reportReady = (host: ConnectionHostInfo): void => {
217
+ if (sourceReady || gen !== this.generation || !this.isGenerationActive(ac)) return
218
+ sourceReady = true
219
+ resolveReady(host)
220
+ }
221
+
222
+ const failed = new Promise<void>((resolve) => {
223
+ const settle = (): void => {
224
+ if (gen === this.generation && !ac.signal.aborted) ac.abort()
225
+ resolve()
226
+ }
227
+ void Promise.resolve()
228
+ .then(() => this.source(ac.signal, reportReady))
229
+ .then(
230
+ () => {
231
+ const error = new Error('connection generation ended')
232
+ if (!sourceReady) rejectReady(error)
233
+ rejectSourceLost(error)
234
+ settle()
235
+ },
236
+ (error: unknown) => {
237
+ const failure = error instanceof Error
238
+ ? error
239
+ : new Error('connection generation failed', { cause: error })
240
+ if (!sourceReady) rejectReady(failure)
241
+ rejectSourceLost(failure)
242
+ settle()
243
+ },
244
+ )
245
+ })
246
+
247
+ try {
248
+ const host = await Promise.race([
249
+ waitForReady(ready, this.config, ac.signal),
250
+ sourceLost,
251
+ ])
252
+ if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
253
+ this.attempt = 0
254
+ this.emitState('connected')
255
+ // A state sink may synchronously stop this controller.
256
+ if (this.isGenerationActive(ac)) {
257
+ this.callSink(() => { this.sinks.onConnected?.(host) })
258
+ }
259
+ } catch (error) {
260
+ if (!ac.signal.aborted) ac.abort(error)
261
+ }
262
+
263
+ await failed
264
+ if (!this.isRunning()) return
265
+ if (manualAttempt) this.attempt = 0
266
+ retry = true
267
+ }
268
+ }
269
+
270
+ /** Deduplicated state emission (sink isolation applies). */
271
+ private emitState(state: ConnectionState): void {
272
+ if (this.lastState === state) return
273
+ this.lastState = state
274
+ this.callSink(() => this.sinks.onStateChange?.(state))
275
+ }
276
+
277
+ /** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */
278
+ private callSink(fn: () => void): void {
279
+ try {
280
+ fn()
281
+ } catch (error) {
282
+ console.error('[connection] connection sink threw:', error)
283
+ }
284
+ }
285
+ }
286
+
287
+ /** Report a slow handshake before the hard deadline ends its generation. */
288
+ function waitForReady<T>(
289
+ ready: Promise<T>,
290
+ config: Required<ConnectionRecoveryConfig>,
291
+ signal: AbortSignal,
292
+ ): Promise<T> {
293
+ return new Promise<T>((resolve, reject) => {
294
+ let settled = false
295
+ const warning = setTimeout(() => {
296
+ console.warn(`[connection] generation is still not ready after ${String(config.generationReadyWarnMs)}ms`)
297
+ }, config.generationReadyWarnMs)
298
+ const timeout = setTimeout(() => {
299
+ const error = new Error(`connection generation was not ready within ${String(config.generationReadyTimeoutMs)}ms`)
300
+ console.warn(`[connection] ${error.message}; cancelling generation`)
301
+ finish({ error })
302
+ }, config.generationReadyTimeoutMs)
303
+ const aborted = (): void => {
304
+ finish({ error: new Error('connection generation aborted', { cause: signal.reason }) })
305
+ }
306
+ const finish = (outcome: { readonly value: T } | { readonly error: Error }): void => {
307
+ if (settled) return
308
+ settled = true
309
+ clearTimeout(warning)
310
+ clearTimeout(timeout)
311
+ signal.removeEventListener('abort', aborted)
312
+ if ('error' in outcome) reject(outcome.error)
313
+ else resolve(outcome.value)
314
+ }
315
+ signal.addEventListener('abort', aborted, { once: true })
316
+ void ready.then(
317
+ (value) => { finish({ value }) },
318
+ (error: unknown) => {
319
+ finish({ error: error as Error })
320
+ },
321
+ )
322
+ })
323
+ }
packages/client/connection/src/client/index.ts ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Browser wire client: Remote transport and connection generations. */
2
+ import type { Context } from '@deepseek-ai/cordis'
3
+ import {
4
+ ConnectionController,
5
+ type ConnectionRecoveryConfig,
6
+ type ConnectionGeneration,
7
+ type ConnectionGenerationSource,
8
+ type ConnectionSinks,
9
+ type ConnectionState,
10
+ } from './connection.ts'
11
+ import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts'
12
+ import { isLoopbackHostname } from '../loopback-hostname.ts'
13
+ import type { ClientConnectionRpc } from '../rpc.ts'
14
+ import { resolveConnectionConfig } from '../recovery-config.ts'
15
+
16
+ declare module '@deepseek-ai/cordis' {
17
+ interface Events {
18
+ /**
19
+ * A connection generation was established. Wire-derived caches must
20
+ * repull; long-lived streams own their own resume and baseline lifecycle.
21
+ * @mode emit
22
+ */
23
+ 'connection/reset'(): void
24
+ }
25
+ }
26
+
27
+ // ---- Browser-safe protocol and shared value re-exports ----
28
+ export type {
29
+ MessageId,
30
+ RpcRequest, RpcResponse, RpcResult,
31
+ ClientRequest, ServerResponse, RpcMessage,
32
+ SessionId, SessionEvent, ContentBlock, StreamChunk,
33
+ } from './api.ts'
34
+ export {
35
+ RpcId,
36
+ transportError,
37
+ } from './api.ts'
38
+
39
+ // Connection loop types are public through ConnectionHandle.start; the
40
+ // controller remains package-internal.
41
+ export type {
42
+ ConnectionRecoveryConfig,
43
+ ConnectionGeneration,
44
+ ConnectionGenerationSource,
45
+ ConnectionHostInfo,
46
+ ConnectionSinks,
47
+ ConnectionState,
48
+ } from './connection.ts'
49
+ export type {
50
+ ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult,
51
+ } from '../rpc.ts'
52
+ export type { RpcFetch } from './rpc.ts'
53
+
54
+ /** Observable identity and Host facts for the active connection generation. */
55
+ export interface ConnectionGenerationState {
56
+ /** Active generation, or undefined before readiness and while reconnecting. */
57
+ getSnapshot(): ConnectionGeneration | undefined
58
+ /** Subscribe to generation establishment, replacement, and loss. */
59
+ subscribe(listener: () => void): () => void
60
+ }
61
+
62
+ /** Observable recovery lifecycle of the owned Connection loop. */
63
+ export interface ConnectionStateSource {
64
+ /** Current state, or undefined before the first connection outcome. */
65
+ getSnapshot(): ConnectionState | undefined
66
+ /** Subscribe to state changes. */
67
+ subscribe(listener: () => void): () => void
68
+ }
69
+
70
+ /** Required services (none — this is the wire root). */
71
+ export const inject: string[] = []
72
+
73
+ /**
74
+ * Physical carrier selected when the Connection service is installed. The
75
+ * served web app omits it and gets HTTP + WebSocket; a shell that owns a
76
+ * different transport (the worker preview's postMessage tunnel) provides both
77
+ * halves instead of forking this plugin.
78
+ */
79
+ export interface ClientTransportHooks {
80
+ /**
81
+ * Already decoded logical RPC carrier. When present it replaces the HTTP
82
+ * caller outright: no envelopes, no `fetch`, no `openStream` (an in-process
83
+ * Host such as a test mock plugs in here).
84
+ */
85
+ rpc?: ClientConnectionRpc
86
+ /** Transport for generic unary RPC channels (the Typert gateway); unused when `rpc` is present. */
87
+ fetch?: RpcFetch
88
+ /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket or `rpc` is present. */
89
+ openStream?: RpcStreamOpen
90
+ /**
91
+ * Bundle transport for the module system, present when the carrier also owns
92
+ * bundle bytes (the worker tunnel). Absent in the served web app, whose
93
+ * bundles load over HTTP.
94
+ */
95
+ loadBundle?(url: string): Promise<void>
96
+ /**
97
+ * The transport owner declares the page owns the Host outright: the Host
98
+ * runs inside a worker this page spawned, so no other party can reach it and
99
+ * the loopback stand-in for "the operator's own machine" is vacuous.
100
+ * `ctx.connection.isLoopback` then reports the privileged surface reachable
101
+ * regardless of the page authority. Only a shell that assembles its own
102
+ * transport can set this; served pages never carry the global at all.
103
+ */
104
+ ownsHost?: boolean
105
+ }
106
+
107
+ /** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */
108
+ interface ClientTransportGlobal {
109
+ __DSH_TRANSPORT__?: ClientTransportHooks
110
+ __DSH_CONNECTION_RECOVERY__?: unknown
111
+ }
112
+
113
+ /** Browser location fields used to classify loopback authority. */
114
+ export interface ConnectionLocation {
115
+ readonly hostname: string
116
+ }
117
+
118
+ /** Instance-local inputs for installing a Connection service. */
119
+ export interface ConnectionInstallOptions {
120
+ /** Explicit physical carrier; omit for the browser HTTP + WebSocket carrier. */
121
+ readonly transport?: ClientTransportHooks
122
+ /** Reconnect timing overrides; omitted fields use controller defaults. */
123
+ readonly recovery?: ConnectionRecoveryConfig
124
+ /** Page location; omit for a non-browser composition. */
125
+ readonly location?: ConnectionLocation
126
+ }
127
+
128
+ /**
129
+ * The ctx.connection service API. API Gateway supplies generation readiness
130
+ * and reset callbacks; Connection stays independent of downstream domain state.
131
+ */
132
+ export interface ConnectionHandle {
133
+ /**
134
+ * Whether the privileged surface is reachable: the page authority is
135
+ * loopback, the transport declares the page owns the Host
136
+ * ({@link ClientTransportHooks.ownsHost}), or the context is not a browser.
137
+ */
138
+ readonly isLoopback: boolean
139
+ /** Current Remote event generation and the Host facts carried by its opening frame. */
140
+ readonly generation: ConnectionGenerationState
141
+ /** Current recovery lifecycle for connection-specific consumers. */
142
+ readonly state: ConnectionStateSource
143
+ /** Generic logical RPC channels over the same Connection transport. */
144
+ readonly rpc: ClientConnectionRpc
145
+ /** Reset retry progression and replace the current attempt immediately. */
146
+ reconnect(): void
147
+ /**
148
+ * Register the sole source defining Host generations. The source reports
149
+ * ready only after its incremental listeners are attached.
150
+ * @param source - long-lived generation source owned by the push carrier.
151
+ * @returns disposer withdrawing the source and stopping an active loop.
152
+ */
153
+ registerGenerationSource(source: ConnectionGenerationSource): () => void
154
+ /**
155
+ * Start the connect/reconnect loop with the consumer's state callbacks.
156
+ * API Gateway owns the loop; a second call throws.
157
+ * @param sinks - connection-state callbacks.
158
+ * @param config - explicit timing overrides; omitted fields use Host bootstrap timing.
159
+ * @returns lifecycle controls for the loop.
160
+ */
161
+ start(sinks: ConnectionSinks, config?: ConnectionRecoveryConfig): ConnectionLoop
162
+ }
163
+
164
+ /** Controls retained by the sole owner of a running connection loop. */
165
+ export interface ConnectionLoop {
166
+ /** Stop the loop and withdraw its active generation. */
167
+ stop(): void
168
+ }
169
+
170
+ interface ConnectionOwner {
171
+ readonly token: object
172
+ readonly source: ConnectionGenerationSource
173
+ readonly controller: ConnectionController
174
+ readonly stopNetworkWatch: () => void
175
+ }
176
+
177
+ interface BrowserNetworkTarget {
178
+ readonly navigator?: { readonly onLine?: boolean }
179
+ addEventListener(type: 'online' | 'offline', listener: () => void): void
180
+ removeEventListener(type: 'online' | 'offline', listener: () => void): void
181
+ }
182
+
183
+ function watchBrowserNetwork(controller: ConnectionController): () => void {
184
+ const browser = (globalThis as { readonly window?: BrowserNetworkTarget }).window
185
+ const initiallyAvailable = browser?.navigator?.onLine
186
+ if (browser === undefined || initiallyAvailable === undefined) return () => {}
187
+ const online = (): void => { controller.setNetworkAvailable(true) }
188
+ const offline = (): void => { controller.setNetworkAvailable(false) }
189
+ controller.setNetworkAvailable(initiallyAvailable)
190
+ browser.addEventListener('online', online)
191
+ browser.addEventListener('offline', offline)
192
+ return () => {
193
+ browser.removeEventListener('online', online)
194
+ browser.removeEventListener('offline', offline)
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Install one Context-owned Connection service from explicit composition inputs.
200
+ * @param ctx - client Cordis context.
201
+ * @param options - physical carrier, reconnect timing, and page location.
202
+ */
203
+ export function installConnection(ctx: Context, options: ConnectionInstallOptions = {}): void {
204
+ const pageLocation = options.location
205
+ const transport = options.transport
206
+ const recovery = options.recovery ?? {}
207
+ const rpc = transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
208
+ let generationSource: ConnectionGenerationSource | undefined
209
+ let owner: ConnectionOwner | undefined
210
+ let generationId = 0
211
+ let generation: ConnectionGeneration | undefined
212
+ let state: ConnectionState | undefined
213
+ const generationListeners = new Set<() => void>()
214
+ const stateListeners = new Set<() => void>()
215
+ const publishGeneration = (next: ConnectionGeneration | undefined): void => {
216
+ if (Object.is(generation, next)) return
217
+ generation = next
218
+ for (const listener of [...generationListeners]) {
219
+ try {
220
+ listener()
221
+ } catch (error) {
222
+ console.error('[connection] generation listener threw:', error)
223
+ }
224
+ }
225
+ }
226
+ const publishState = (next: ConnectionState | undefined): void => {
227
+ if (state === next) return
228
+ state = next
229
+ for (const listener of [...stateListeners]) {
230
+ try {
231
+ listener()
232
+ } catch (error) {
233
+ console.error('[connection] state listener threw:', error)
234
+ }
235
+ }
236
+ }
237
+ const releaseOwner = (current: ConnectionOwner): void => {
238
+ if (owner !== current) return
239
+ owner = undefined
240
+ current.stopNetworkWatch()
241
+ current.controller.stop()
242
+ publishGeneration(undefined)
243
+ publishState(undefined)
244
+ }
245
+ const handle: ConnectionHandle = {
246
+ isLoopback: transport?.ownsHost === true || pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
247
+ generation: {
248
+ getSnapshot: () => generation,
249
+ subscribe: (listener) => {
250
+ generationListeners.add(listener)
251
+ return () => { generationListeners.delete(listener) }
252
+ },
253
+ },
254
+ state: {
255
+ getSnapshot: () => state,
256
+ subscribe: (listener) => {
257
+ stateListeners.add(listener)
258
+ return () => { stateListeners.delete(listener) }
259
+ },
260
+ },
261
+ rpc,
262
+ reconnect() {
263
+ owner?.controller.reconnect()
264
+ },
265
+ registerGenerationSource(source) {
266
+ if (generationSource !== undefined) {
267
+ throw new Error('connection: a generation source is already registered')
268
+ }
269
+ generationSource = source
270
+ return () => {
271
+ if (generationSource !== source) return
272
+ generationSource = undefined
273
+ const current = owner
274
+ if (current?.source === source) releaseOwner(current)
275
+ }
276
+ },
277
+ start(sinks, config) {
278
+ if (owner !== undefined) throw new Error('connection: the stream loop is already owned by another consumer')
279
+ const source = generationSource
280
+ if (source === undefined) throw new Error('connection: no generation source is registered')
281
+ const token = {}
282
+ const ownsGeneration = (): boolean => owner?.token === token
283
+ const controller = new ConnectionController(source, {
284
+ ...sinks,
285
+ onConnected: (host) => {
286
+ const nextGeneration = { id: ++generationId, host }
287
+ publishGeneration(nextGeneration)
288
+ if (!ownsGeneration() || !Object.is(generation, nextGeneration)) return
289
+ sinks.onConnected?.(host)
290
+ },
291
+ onStateChange: (state) => {
292
+ if (state !== 'connected') {
293
+ publishGeneration(undefined)
294
+ }
295
+ if (!ownsGeneration()) return
296
+ publishState(state)
297
+ sinks.onStateChange?.(state)
298
+ },
299
+ }, { ...recovery, ...config })
300
+ const current = { token, source, controller, stopNetworkWatch: watchBrowserNetwork(controller) }
301
+ owner = current
302
+ controller.start()
303
+ return {
304
+ stop: () => { releaseOwner(current) },
305
+ }
306
+ },
307
+ }
308
+ ctx.provide('connection', handle)
309
+ }
310
+
311
+ /**
312
+ * Client plugin body: read the page composition and install its Connection service.
313
+ * @param ctx - client Cordis context.
314
+ */
315
+ export function apply(ctx: Context): void {
316
+ const globals = globalThis as ClientTransportGlobal
317
+ const pageLocation = typeof location === 'undefined' ? undefined : location
318
+ const transport = globals.__DSH_TRANSPORT__
319
+ installConnection(ctx, {
320
+ ...(transport === undefined ? {} : { transport }),
321
+ recovery: resolveConnectionConfig(globals.__DSH_CONNECTION_RECOVERY__),
322
+ ...(pageLocation === undefined ? {} : { location: pageLocation }),
323
+ })
324
+ }
packages/client/connection/src/client/random-uuid.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Browser-safe UUID generation for client-side wire correlation. */
2
+
3
+ /**
4
+ * Generate an RFC 4122 version 4 UUID without requiring a secure context.
5
+ * @returns a UUID backed by `crypto.getRandomValues()`, which browsers expose on insecure origins.
6
+ */
7
+ export function randomUuid(): string {
8
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
9
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
10
+ view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40)
11
+ view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80)
12
+ const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
13
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
14
+ }
packages/client/connection/src/client/rpc.ts ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Browser caller for generic Connection unary RPC channels. */
2
+
3
+ import {
4
+ RpcId,
5
+ type ClientRequest,
6
+ type RpcId as RpcIdType,
7
+ } from '../rpc.ts'
8
+ import type { ClientConnectionRpc, ConnectionRpcResult } from '../rpc.ts'
9
+ import { randomUuid } from './random-uuid.ts'
10
+
11
+ const INTERNAL_BASE = 'http://dsh.internal'
12
+ const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
13
+ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
14
+
15
+ /** Transport this caller posts through; same signature as the global `fetch`. */
16
+ export type RpcFetch = (input: URL, init: RequestInit) => Promise<Response>
17
+
18
+ /** Worker-local opener for decoded Gateway Remote streams. */
19
+ export type RpcStreamOpen = (
20
+ endpoint: string,
21
+ payload: unknown,
22
+ signal: AbortSignal,
23
+ ) => AsyncIterable<unknown>
24
+
25
+ /**
26
+ * Create the browser-backed generic RPC caller.
27
+ * @param doFetch - transport override; defaults to the page's global fetch.
28
+ * @param openStream - optional worker-local Gateway stream carrier.
29
+ * @returns caller that owns request correlation and response-envelope validation.
30
+ */
31
+ export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStreamOpen): ClientConnectionRpc {
32
+ const send: RpcFetch = doFetch ?? ((input, init) => globalThis.fetch(input, init))
33
+ return {
34
+ async call(channel, endpoint, payload, signal) {
35
+ assertTarget(channel, endpoint)
36
+ const rpcId = RpcId(randomUuid())
37
+ const message: ClientRequest = {
38
+ type: 'client-request',
39
+ rpcId,
40
+ method: endpoint,
41
+ payload,
42
+ }
43
+ const response = await send(
44
+ new URL(`${channel}/${endpoint}`, resolveBase()),
45
+ {
46
+ method: 'POST',
47
+ headers: { 'content-type': 'application/json' },
48
+ body: JSON.stringify(message),
49
+ ...signal === undefined ? {} : { signal },
50
+ },
51
+ )
52
+ if (!response.ok) {
53
+ throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`)
54
+ }
55
+ const full = parseConnectionResponse(await response.json())
56
+ if (full.rpcId !== rpcId) {
57
+ throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`)
58
+ }
59
+ return full.result
60
+ },
61
+ ...openStream === undefined ? {} : {
62
+ open(channel, endpoint, payload, signal) {
63
+ assertTarget(channel, endpoint)
64
+ if (channel !== '/api') {
65
+ throw new Error(`connection: worker-local streams require the /api channel, got ${JSON.stringify(channel)}`)
66
+ }
67
+ return openStream(endpoint, payload, signal)
68
+ },
69
+ },
70
+ }
71
+ }
72
+
73
+ function parseConnectionResponse(value: unknown): {
74
+ readonly rpcId: RpcIdType
75
+ readonly result: ConnectionRpcResult<unknown>
76
+ } {
77
+ if (!isRecord(value) || value.type !== 'server-response' || typeof value.rpcId !== 'string') {
78
+ throw new TypeError('connection: invalid server-response envelope')
79
+ }
80
+ const result = value.result
81
+ if (!isRecord(result)) throw new TypeError('connection: invalid server-response result')
82
+ if (result.ok === true) {
83
+ return {
84
+ rpcId: RpcId(value.rpcId),
85
+ result: { ok: true, value: result.value },
86
+ }
87
+ }
88
+ if (result.ok !== false || !isRecord(result.error)) {
89
+ throw new TypeError('connection: invalid server-response result')
90
+ }
91
+ const error = result.error
92
+ if (typeof error.code !== 'string' || typeof error.message !== 'string' || !isRecord(error.details)) {
93
+ throw new TypeError('connection: invalid server-response failure')
94
+ }
95
+ return {
96
+ rpcId: RpcId(value.rpcId),
97
+ result: {
98
+ ok: false,
99
+ error: { code: error.code, message: error.message, details: error.details },
100
+ },
101
+ }
102
+ }
103
+
104
+ function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
105
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
106
+ }
107
+
108
+ function resolveBase(): string {
109
+ const location = (globalThis as { location?: { origin?: string } }).location
110
+ return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
111
+ }
112
+
113
+ function assertTarget(channel: string, endpoint: string): void {
114
+ const segments = endpoint.split('/')
115
+ if (!CHANNEL_PATTERN.test(channel)
116
+ || segments.some(segment =>
117
+ segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
118
+ throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`)
119
+ }
120
+ }
packages/client/connection/src/http-bridge.ts ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
3
+ * web carrier; the fetch-shaped handler itself is transport-agnostic).
4
+ */
5
+
6
+ import type { IncomingMessage, ServerResponse } from 'node:http'
7
+ import { Readable } from 'node:stream'
8
+ import type { ConnectionFetchHandler } from './rpc.ts'
9
+
10
+ /** Default carrier cap for all HTTP RPC bodies: sized for the default
11
+ * aggregate image limit (200 MiB) after base64 expansion plus envelope
12
+ * headroom (~267.7 MiB required), rounded up for slack. The bridge buffers
13
+ * each body in memory, so this cap is also the per-request resident bound. */
14
+ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024
15
+
16
+ /**
17
+ * Bridge one node:http request to the fetch-shaped handler (client close
18
+ * aborts; response bodies stream out chunk by chunk).
19
+ * @param req - incoming node:http request.
20
+ * @param res - node:http response the bridge writes and owns to completion.
21
+ * @param apiHandler - fetch-shaped API carrier the request is dispatched to.
22
+ * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route.
23
+ */
24
+ export async function bridge(
25
+ req: IncomingMessage,
26
+ res: ServerResponse,
27
+ apiHandler: ConnectionFetchHandler,
28
+ maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES,
29
+ ): Promise<void> {
30
+ const abort = new AbortController()
31
+ // Client-disconnect detection MUST hang off the response, not the request:
32
+ // since Node 16, IncomingMessage 'close' fires as soon as the request body is
33
+ // fully consumed (immediately for a bodyless GET), which would abort a
34
+ // streaming response right after open. ServerResponse 'close' fires on connection teardown;
35
+ // writableEnded distinguishes a normal end() from the client going away.
36
+ res.on('close', () => {
37
+ if (!res.writableEnded) abort.abort()
38
+ })
39
+ /* v8 ignore next 2 -- node:http always sets url/method on server requests. */
40
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
41
+ const method = req.method ?? 'GET'
42
+ const headers = Object.fromEntries(
43
+ Object.entries(req.headers).filter(([, value]) => typeof value === 'string') as [string, string][],
44
+ )
45
+ const bodyMode = apiHandler.requestBodyMode({ method, url })
46
+ let request: Request
47
+ if (bodyMode === 'buffered') {
48
+ const declaredLength = req.headers['content-length']
49
+ if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
50
+ res.writeHead(413, { connection: 'close' })
51
+ res.end()
52
+ req.destroy()
53
+ return
54
+ }
55
+ const chunks: Buffer[] = []
56
+ let received = 0
57
+ for await (const chunk of req) {
58
+ const buffer = chunk as Buffer
59
+ received += buffer.byteLength
60
+ if (received > maxRequestBodyBytes) {
61
+ res.writeHead(413, { connection: 'close' })
62
+ res.end()
63
+ req.destroy()
64
+ return
65
+ }
66
+ chunks.push(buffer)
67
+ }
68
+ request = new Request(url, {
69
+ method,
70
+ headers,
71
+ ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
72
+ signal: abort.signal,
73
+ })
74
+ } else {
75
+ request = new Request(url, {
76
+ method,
77
+ headers,
78
+ body: Readable.toWeb(req) as ReadableStream<Uint8Array>,
79
+ signal: abort.signal,
80
+ duplex: 'half',
81
+ } as RequestInit & { duplex: 'half' })
82
+ }
83
+ const response = await apiHandler.fetch(request)
84
+ const requestUnread = bodyMode === 'streaming' && !req.readableEnded
85
+ const responseHeaders = Object.fromEntries(response.headers.entries())
86
+ res.writeHead(response.status, requestUnread ? { ...responseHeaders, connection: 'close' } : responseHeaders)
87
+ if (response.body === null) {
88
+ res.end()
89
+ if (requestUnread) req.destroy()
90
+ return
91
+ }
92
+ for await (const chunk of response.body) {
93
+ // Backpressure: a false return means the socket buffer is full — wait for drain
94
+ // instead of buffering unboundedly (slow or suspended consumers). 'close' also
95
+ // resolves so a mid-wait disconnect can't park this loop forever; the close
96
+ // handler above aborts the handler stream, which then ends the iteration.
97
+ if (!res.write(chunk)) {
98
+ await new Promise<void>((resolve) => {
99
+ const done = (): void => {
100
+ res.off('drain', done)
101
+ res.off('close', done)
102
+ resolve()
103
+ }
104
+ res.once('drain', done)
105
+ res.once('close', done)
106
+ })
107
+ }
108
+ }
109
+ res.end()
110
+ if (requestUnread) req.destroy()
111
+ }
packages/client/connection/src/index.ts ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Host HTTP bridge for browser-client RPC. */
2
+ import type { Context } from '@deepseek-ai/cordis'
3
+ import z from '@deepseek-ai/schemastery'
4
+ import type {} from '@deepseek-ai/dsh-attachment'
5
+ import type {} from '@deepseek-ai/dsh-credentials'
6
+ // Activates the webServer Context merge used below.
7
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
8
+ import { API_PATH } from './api-path.ts'
9
+ import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts'
10
+ import { assertTrustedAuthority } from './api-request-trust.ts'
11
+ import { BrowserAuth } from './browser-auth.ts'
12
+ import { HostConnectionService } from './rpc-host.ts'
13
+ import { ConnectionRecoveryConfigSchema, resolveConnectionConfig, type ConnectionRecoveryConfig } from './recovery-config.ts'
14
+
15
+ export type {
16
+ ConnectionFetchMethod,
17
+ ConnectionFetchHandler,
18
+ ConnectionFetchRoute,
19
+ ConnectionIndexRequest,
20
+ ConnectionIndexResponse,
21
+ ConnectionRpcEndpointMatcher,
22
+ ConnectionRpcFailure,
23
+ ConnectionRpcHandler,
24
+ ConnectionRequestRejection,
25
+ ConnectionRpcResult,
26
+ ConnectionRequestBodyMode,
27
+ ConnectionTrustRequest,
28
+ ClientRequest,
29
+ HostConnectionHandle,
30
+ HostConnectionFetch,
31
+ HostConnectionRpc,
32
+ RpcMessage,
33
+ ServerResponse,
34
+ } from './rpc.ts'
35
+ export { RpcId, transportError } from './rpc.ts'
36
+ export {
37
+ clientRequestSchema,
38
+ rpcErrorSchema,
39
+ rpcIdSchema,
40
+ rpcMessageSchema,
41
+ rpcResultSchema,
42
+ serverResponseSchema,
43
+ } from './rpc-schema.ts'
44
+ export { HostConnectionService } from './rpc-host.ts'
45
+
46
+ export { API_PATH } from './api-path.ts'
47
+
48
+ /** Stable Cordis plugin name. */
49
+ export const name = 'client-connection'
50
+
51
+ /** Headroom for RPC JSON fields around aggregate base64 image payloads. */
52
+ const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
53
+
54
+ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void {
55
+ const attachments = ctx.get('attachments')
56
+ if (attachments === undefined) return
57
+ const requiredImageBodyBytes = Math.ceil(
58
+ attachments.imageLimits.maxMessageImageBytes * 4 / 3,
59
+ ) + REQUEST_ENVELOPE_HEADROOM_BYTES
60
+ if (maxRequestBodyBytes < requiredImageBodyBytes) {
61
+ throw new Error(
62
+ `client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `
63
+ + `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,
64
+ )
65
+ }
66
+ }
67
+
68
+ /** Services required before providing Connection. */
69
+ export const inject = ['credentials']
70
+
71
+ /** Browser authentication, request limits, and connection recovery configuration. */
72
+ export interface ConnectionConfig {
73
+ /** Browser recovery timing, injected into each served page. */
74
+ recovery?: ConnectionRecoveryConfig
75
+ /**
76
+ * Authorities this deployment serves beyond loopback: exact `host:port`, or
77
+ * port-less `host` matching any port. The /api trust fence refuses any
78
+ * request whose Host is neither loopback nor listed here, so a
79
+ * non-loopback (`0.0.0.0`) deployment must declare the names it is reached
80
+ * by; the Web runtime derives LAN IP literals from an active all-interface
81
+ * bind. An entry that is not a bare, canonical authority fails plugin load.
82
+ */
83
+ trustedHosts?: string[]
84
+ /** Absolute browser-session lifetime in days. Default: 30. */
85
+ cookieMaxAgeDays?: number
86
+ /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */
87
+ maxRequestBodyBytes?: number
88
+ }
89
+
90
+ export const Config: z<ConnectionConfig> = z.object({
91
+ recovery: ConnectionRecoveryConfigSchema.default({}),
92
+ trustedHosts: z.array(String).default([]),
93
+ cookieMaxAgeDays: z.natural().min(1).default(30),
94
+ maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
95
+ })
96
+
97
+ /**
98
+ * Provides carrier-neutral RPC and Fetch registries. When `webServer` is
99
+ * present, the plugin also mounts the `/api` browser transport with Host/Origin
100
+ * checks and persistent browser authentication.
101
+ * @param ctx - Host plugin context.
102
+ * @param config - resolved plugin config (schema defaults applied).
103
+ */
104
+ export async function apply(ctx: Context, config?: ConnectionConfig): Promise<void> {
105
+ const recovery = resolveConnectionConfig(config?.recovery)
106
+ // The Loader resolves schema defaults; hand-built test contexts may pass none.
107
+ const trustedHosts = config?.trustedHosts ?? []
108
+ const cookieMaxAgeDays = config?.cookieMaxAgeDays ?? 30
109
+ const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
110
+ // Config boundary: a malformed entry fails the load loudly here rather than
111
+ // silently authorizing its hostname prefix at request time.
112
+ for (const entry of trustedHosts) assertTrustedAuthority(entry)
113
+ assertImageBodyCapacity(ctx, maxRequestBodyBytes)
114
+ const connection = new HostConnectionService(
115
+ ctx,
116
+ trustedHosts,
117
+ await BrowserAuth.create(ctx.root, ctx.credentials, cookieMaxAgeDays),
118
+ )
119
+ ctx.inject(['webServer'], (webCtx) => {
120
+ assertImageBodyCapacity(webCtx, maxRequestBodyBytes)
121
+ webCtx.on('webserver/index-inject', (table) => {
122
+ table.push({ kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: recovery })
123
+ })
124
+ const fetchHandler = connection.createSharedFetchHandler(API_PATH)
125
+ const route: WebRoute = {
126
+ kind: 'prefix',
127
+ path: API_PATH,
128
+ handler: async (req, res) => {
129
+ const rejection = connection.requestRejection(req)
130
+ if (rejection !== undefined) {
131
+ res.writeHead(rejection)
132
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
133
+ return
134
+ }
135
+ await bridge(req, res, fetchHandler, maxRequestBodyBytes)
136
+ },
137
+ }
138
+ webCtx.effect(() => webCtx.webServer.register(route), 'client-connection: /api route')
139
+ })
140
+ ctx.inject(['attachments'], (attachmentCtx) => {
141
+ assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes)
142
+ })
143
+ }
packages/client/connection/src/loopback-hostname.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Browser-safe, zero-dependency loopback classification shared by the `/api`
3
+ * Host fence and the package's `ctx.connection` state. The predicate stays
4
+ * package-internal; client plugins consume the derived state through Cordis.
5
+ */
6
+
7
+ /**
8
+ * Whether a normalized URL hostname names the local loopback authority.
9
+ * @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
10
+ * @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
11
+ */
12
+ export function isLoopbackHostname(hostname: string): boolean {
13
+ if (hostname === 'localhost' || hostname === '[::1]') return true
14
+ const parts = hostname.split('.')
15
+ return parts.length === 4
16
+ && parts[0] === '127'
17
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
18
+ }
packages/client/connection/src/recovery-config.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Shared validation for Host-configured and browser-local connection recovery. */
2
+ import z from '@deepseek-ai/schemastery'
3
+
4
+ /** Timing for generation readiness and automatic reconnection. */
5
+ export interface ConnectionRecoveryConfig {
6
+ /** First-retry delay cap in ms; actual delay is 50–100% of the cap. Default: 500. */
7
+ backoffBaseMs?: number
8
+ /** Finite growth factor of at least 1 per failed attempt; 1 keeps a fixed cap. Default: 2. */
9
+ backoffFactor?: number
10
+ /** Maximum retry delay cap in ms; retries continue at this cap. Default: 10000. */
11
+ backoffMaxMs?: number
12
+ /**
13
+ * Delay before reporting a slow handshake, without cancelling it. Default: 3000.
14
+ * Omitted when readiness, failure, cancellation, or the hard deadline occurs first.
15
+ */
16
+ generationReadyWarnMs?: number
17
+ /** Deadline in ms for readiness, including physical connection setup. Default: 15000. */
18
+ generationReadyTimeoutMs?: number
19
+ }
20
+
21
+ // Browsers and Node share this maximum signed 32-bit timer delay.
22
+ const MAX_TIMER_MS = 2_147_483_647
23
+
24
+ /** Schema shared by the Host plugin and the Client's recovery input parser. */
25
+ export const ConnectionRecoveryConfigSchema: z<ConnectionRecoveryConfig> = z.object({
26
+ backoffBaseMs: z.natural().min(1).max(MAX_TIMER_MS).default(500),
27
+ backoffFactor: z.number().min(1).max(Number.MAX_VALUE).default(2),
28
+ backoffMaxMs: z.natural().min(1).max(MAX_TIMER_MS).default(10_000),
29
+ generationReadyWarnMs: z.natural().min(1).max(MAX_TIMER_MS).default(3_000),
30
+ generationReadyTimeoutMs: z.natural().min(1).max(MAX_TIMER_MS).default(15_000),
31
+ })
32
+
33
+ /**
34
+ * Validate recovery input and supply every timing default before starting work.
35
+ * @param config - Host configuration, page bootstrap data, or direct loop options.
36
+ * @returns validated, complete recovery timing.
37
+ */
38
+ export function resolveConnectionConfig(config: unknown = {}): Required<ConnectionRecoveryConfig> {
39
+ const resolved = ConnectionRecoveryConfigSchema(config as ConnectionRecoveryConfig) as Required<ConnectionRecoveryConfig>
40
+ if (!Number.isFinite(resolved.backoffFactor)) {
41
+ throw new RangeError('connection recovery backoffFactor must be finite')
42
+ }
43
+ return resolved
44
+ }
packages/client/connection/src/rpc-host.ts ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Host registry and HTTP adapter for generic Connection RPC channels. */
2
+
3
+ import { Context, Service } from '@deepseek-ai/cordis'
4
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
5
+ import {
6
+ RpcId,
7
+ type ClientRequest,
8
+ type RpcId as RpcIdType,
9
+ } from './rpc.ts'
10
+ import { clientRequestSchema } from './rpc-schema.ts'
11
+ import { bridge } from './http-bridge.ts'
12
+ import { isTrustedApiRequest } from './api-request-trust.ts'
13
+ import { API_PATH } from './api-path.ts'
14
+ import type { BrowserAuth } from './browser-auth.ts'
15
+ import type {
16
+ ConnectionIndexRequest,
17
+ ConnectionIndexResponse,
18
+ ConnectionFetchRoute,
19
+ ConnectionFetchHandler,
20
+ HostConnectionFetch,
21
+ ConnectionRpcEndpointMatcher,
22
+ ConnectionRpcFailure,
23
+ ConnectionRpcHandler,
24
+ ConnectionRpcResult,
25
+ ConnectionRequestRejection,
26
+ ConnectionTrustRequest,
27
+ HostConnectionHandle,
28
+ HostConnectionRpc,
29
+ } from './rpc.ts'
30
+
31
+ const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
32
+ const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
33
+ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
34
+
35
+ interface ConnectionRpcInterceptor {
36
+ readonly matches: ConnectionRpcEndpointMatcher
37
+ readonly fetchHandler: ConnectionFetchHandler
38
+ }
39
+
40
+ interface RegisteredFetchRoute {
41
+ readonly methods: ReadonlySet<string>
42
+ readonly requestBody: ConnectionFetchRoute['requestBody']
43
+ readonly fetch: ConnectionFetchRoute['fetch']
44
+ }
45
+
46
+ interface ConnectionServerResponse {
47
+ readonly type: 'server-response'
48
+ readonly rpcId: RpcIdType
49
+ readonly result: ConnectionRpcResult<unknown>
50
+ }
51
+
52
+ declare module '@deepseek-ai/cordis' {
53
+ interface Context {
54
+ /** Host Connection transport and RPC registrations. */
55
+ connection: HostConnectionHandle
56
+ }
57
+ }
58
+
59
+ /** Host Connection service whose channel registrations belong to the caller fiber. */
60
+ export class HostConnectionService extends Service implements HostConnectionHandle {
61
+ private readonly interceptors = new Map<string, ConnectionRpcInterceptor>()
62
+ private readonly fetchRoutes = new Map<string, RegisteredFetchRoute>()
63
+
64
+ /**
65
+ * Provide the Host half over the active HTTP server.
66
+ * @param ctx - owning Connection plugin context.
67
+ * @param trustedHosts - deployment authorities accepted by the Host/Origin fence.
68
+ * @param browserAuth - process token and persistent browser-session owner.
69
+ */
70
+ constructor(
71
+ ctx: Context,
72
+ private readonly trustedHosts: readonly string[],
73
+ private readonly browserAuth: BrowserAuth,
74
+ ) {
75
+ super(ctx, 'connection')
76
+ }
77
+
78
+ /** Generic channel registry scoped to the Context reading this service. */
79
+ get rpc(): HostConnectionRpc {
80
+ const owner = this.ctx
81
+ return {
82
+ handle: (channel, handler) => this.register(owner, channel, handler),
83
+ intercept: (channel, matches, handler) =>
84
+ this.registerInterceptor(owner, channel, matches, handler),
85
+ }
86
+ }
87
+
88
+ /** Exact Fetch-route registry scoped to the Context reading this service. */
89
+ get fetch(): HostConnectionFetch {
90
+ const owner = this.ctx
91
+ return {
92
+ register: route => this.registerFetchRoute(owner, route),
93
+ }
94
+ }
95
+
96
+ /** Apply the configured Host/Origin fence, then browser authentication. */
97
+ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection {
98
+ if (!isTrustedApiRequest(request, this.trustedHosts)) return 403
99
+ return this.browserAuth.isAuthenticated(request) ? undefined : 401
100
+ }
101
+
102
+ /** Authenticate an index request through the process-token exchange or cookie. */
103
+ authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean {
104
+ return this.browserAuth.authorizeIndex(request, response)
105
+ }
106
+
107
+ /** Add this process's launch token to the clean application URL. */
108
+ authenticatedUrl(baseUrl: string): string {
109
+ return this.browserAuth.authenticatedUrl(baseUrl)
110
+ }
111
+
112
+ /**
113
+ * Compose one shared-channel Fetch handler from exact routes and its interceptor.
114
+ * @param channel - shared channel mounted by Connection.
115
+ * @returns Fetch handler that selects one owner or returns 404.
116
+ */
117
+ createSharedFetchHandler(
118
+ channel: '/api',
119
+ ): ConnectionFetchHandler {
120
+ return {
121
+ requestBodyMode: ({ method, url }) => {
122
+ const route = this.fetchRoutes.get(url.pathname)
123
+ return route?.methods.has(method) === true ? route.requestBody : 'buffered'
124
+ },
125
+ fetch: (request) => {
126
+ const pathname = new URL(request.url).pathname
127
+ const route = this.fetchRoutes.get(pathname)
128
+ if (route?.methods.has(request.method) === true) return route.fetch(request)
129
+ const endpoint = endpointFromPath(channel, pathname)
130
+ const interceptor = this.interceptors.get(channel)
131
+ if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) {
132
+ return Promise.resolve(new Response('not found', { status: 404 }))
133
+ }
134
+ return interceptor.fetchHandler.fetch(request)
135
+ },
136
+ }
137
+ }
138
+
139
+ private registerFetchRoute(
140
+ owner: Context,
141
+ route: ConnectionFetchRoute,
142
+ ): () => Promise<void> {
143
+ assertFetchRoute(route)
144
+ const registered: RegisteredFetchRoute = {
145
+ methods: new Set(route.methods),
146
+ requestBody: route.requestBody,
147
+ fetch: route.fetch,
148
+ }
149
+ return owner.effect(() => {
150
+ if (this.fetchRoutes.has(route.path)) {
151
+ throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} is already registered`)
152
+ }
153
+ this.fetchRoutes.set(route.path, registered)
154
+ return () => { this.fetchRoutes.delete(route.path) }
155
+ }, `client-connection: ${route.path} Fetch route`)
156
+ }
157
+
158
+ private register(
159
+ owner: Context,
160
+ channel: string,
161
+ handler: ConnectionRpcHandler,
162
+ ): () => Promise<void> {
163
+ assertChannel(channel)
164
+ const fetchHandler = rpcFetchHandler(channel, handler)
165
+ const route: WebRoute = {
166
+ kind: 'prefix',
167
+ path: channel,
168
+ handler: async (req, res) => {
169
+ const rejection = this.requestRejection(req)
170
+ if (rejection !== undefined) {
171
+ res.writeHead(rejection)
172
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
173
+ return
174
+ }
175
+ await bridge(req, res, fetchHandler)
176
+ },
177
+ }
178
+ return owner.effect(
179
+ () => owner.webServer.register(route),
180
+ `client-connection: ${channel} rpc channel`,
181
+ )
182
+ }
183
+
184
+ private registerInterceptor(
185
+ owner: Context,
186
+ channel: string,
187
+ matches: ConnectionRpcEndpointMatcher,
188
+ handler: ConnectionRpcHandler,
189
+ ): () => Promise<void> {
190
+ if (channel !== API_PATH) {
191
+ throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`)
192
+ }
193
+ const interceptor: ConnectionRpcInterceptor = {
194
+ matches,
195
+ fetchHandler: rpcFetchHandler(channel, handler),
196
+ }
197
+ return owner.effect(() => {
198
+ if (this.interceptors.has(channel)) {
199
+ throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`)
200
+ }
201
+ this.interceptors.set(channel, interceptor)
202
+ return () => {
203
+ this.interceptors.delete(channel)
204
+ }
205
+ }, `client-connection: ${channel} rpc interceptor`)
206
+ }
207
+ }
208
+
209
+ function rpcFetchHandler(
210
+ channel: string,
211
+ handler: ConnectionRpcHandler,
212
+ ): ConnectionFetchHandler {
213
+ return {
214
+ requestBodyMode: () => 'buffered',
215
+ async fetch(request: Request): Promise<Response> {
216
+ const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
217
+ if (request.method !== 'POST' || endpoint === undefined) {
218
+ return new Response('not found', { status: 404 })
219
+ }
220
+
221
+ const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
222
+ if (mediaType !== 'application/json') {
223
+ return new Response('content type must be application/json', { status: 415 })
224
+ }
225
+
226
+ let body: unknown
227
+ try {
228
+ body = await request.json()
229
+ } catch {
230
+ return new Response('body is not JSON', { status: 400 })
231
+ }
232
+
233
+ const envelope = clientRequestSchema.safeParse(body)
234
+ if (!envelope.success) {
235
+ return invalidEnvelopeResponse(body, envelope.error.issues)
236
+ }
237
+ const message: ClientRequest = envelope.data
238
+ if (message.method !== endpoint) {
239
+ return errorResponse(message.rpcId, {
240
+ code: 'gateway/bad-request',
241
+ message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
242
+ details: { issues: [] },
243
+ })
244
+ }
245
+
246
+ try {
247
+ const result = await handler(endpoint, message.payload, request.signal)
248
+ return fullResponse(message.rpcId, result)
249
+ } catch (error) {
250
+ return new Response(`handler failure: ${String(error)}`, { status: 500 })
251
+ }
252
+ },
253
+ }
254
+ }
255
+
256
+ function invalidEnvelopeResponse(body: unknown, issues: readonly object[]): Response {
257
+ const rawId = (body as { rpcId?: unknown } | null)?.rpcId
258
+ const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
259
+ return errorResponse(rpcId, {
260
+ code: 'gateway/bad-request',
261
+ message: 'invalid client-request message',
262
+ details: { issues },
263
+ })
264
+ }
265
+
266
+ function endpointFromPath(channel: string, pathname: string): string | undefined {
267
+ if (!pathname.startsWith(`${channel}/`)) return undefined
268
+ const endpoint = pathname.slice(channel.length + 1)
269
+ const segments = endpoint.split('/')
270
+ if (segments.some(segment =>
271
+ segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
272
+ return undefined
273
+ }
274
+ return endpoint
275
+ }
276
+
277
+ function errorResponse(rpcId: RpcIdType, error: ConnectionRpcFailure): Response {
278
+ return fullResponse(rpcId, { ok: false, error })
279
+ }
280
+
281
+ function fullResponse(rpcId: RpcIdType, result: ConnectionRpcResult<unknown>): Response {
282
+ const body: ConnectionServerResponse = { type: 'server-response', rpcId, result }
283
+ return Response.json(body)
284
+ }
285
+
286
+ function assertChannel(channel: string): void {
287
+ if (!CHANNEL_PATTERN.test(channel) || channel === '/api') {
288
+ throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`)
289
+ }
290
+ }
291
+
292
+ function assertFetchRoute(route: ConnectionFetchRoute): void {
293
+ if (endpointFromPath(API_PATH, route.path) === undefined) {
294
+ throw new Error(`connection: invalid exact Fetch route ${JSON.stringify(route.path)}`)
295
+ }
296
+ if (route.methods.length === 0) {
297
+ throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} declares no methods`)
298
+ }
299
+ const methods = new Set(route.methods)
300
+ if (methods.size !== route.methods.length) {
301
+ throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`)
302
+ }
303
+ }
packages/client/connection/src/rpc-schema.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Runtime validation for Connection RPC envelopes. */
2
+
3
+ import { z } from 'zod'
4
+ import type { ClientRequest, RpcId, RpcMessage, ServerResponse } from './rpc.ts'
5
+
6
+ /** Correlation id after wire validation. */
7
+ export const rpcIdSchema = z.string() as unknown as z.ZodType<RpcId>
8
+
9
+ /** Generic endpoint failure carried in a response envelope. */
10
+ export const rpcErrorSchema = z.object({
11
+ code: z.string(),
12
+ message: z.string(),
13
+ details: z.record(z.string(), z.unknown()),
14
+ })
15
+
16
+ /**
17
+ * Build the result parser for one endpoint value parser.
18
+ * @param value - endpoint-owned success-value parser.
19
+ * @returns parser for either a success value or generic failure.
20
+ */
21
+ export function rpcResultSchema<T>(value: z.ZodType<T>): z.ZodType<{
22
+ readonly ok: true
23
+ readonly value: T
24
+ } | {
25
+ readonly ok: false
26
+ readonly error: z.infer<typeof rpcErrorSchema>
27
+ }> {
28
+ return z.union([
29
+ z.object({ ok: z.literal(true), value }),
30
+ z.object({ ok: z.literal(false), error: rpcErrorSchema }),
31
+ ])
32
+ }
33
+
34
+ /** Client request envelope; endpoint payload validation belongs to its owner. */
35
+ export const clientRequestSchema = z.object({
36
+ type: z.literal('client-request'),
37
+ rpcId: rpcIdSchema,
38
+ method: z.string(),
39
+ payload: z.unknown(),
40
+ }) as z.ZodType<ClientRequest>
41
+
42
+ /** Server response envelope; endpoint value validation belongs to its caller. */
43
+ export const serverResponseSchema = z.object({
44
+ type: z.literal('server-response'),
45
+ rpcId: rpcIdSchema,
46
+ result: rpcResultSchema(z.unknown().optional()),
47
+ }) as z.ZodType<ServerResponse>
48
+
49
+ /** Either Connection RPC envelope direction. */
50
+ export const rpcMessageSchema = z.discriminatedUnion('type', [
51
+ clientRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
52
+ serverResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
53
+ ]) as unknown as z.ZodType<RpcMessage>
packages/client/connection/src/rpc.ts ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */
2
+
3
+ import type { Branded } from '@deepseek-ai/dsh-brand'
4
+
5
+ /** Correlation id minted by a caller and echoed by the Connection response. */
6
+ export type RpcId = Branded<'rpc-id'>
7
+
8
+ /**
9
+ * Brand one validated string as a Connection correlation id.
10
+ * @param id - validated wire identity.
11
+ * @returns the same string with the correlation-id brand.
12
+ */
13
+ export function RpcId(id: string): RpcId {
14
+ return id as RpcId
15
+ }
16
+
17
+ /** Carrier-neutral failure returned by one logical RPC endpoint. */
18
+ export interface ConnectionRpcFailure {
19
+ readonly code: string
20
+ readonly message: string
21
+ readonly details: object
22
+ }
23
+
24
+ /** Carrier-neutral result returned by one logical RPC endpoint. */
25
+ export type ConnectionRpcResult<T> =
26
+ | { readonly ok: true; readonly value: T }
27
+ | { readonly ok: false; readonly error: ConnectionRpcFailure }
28
+
29
+ /** Historical short name for a generic Connection result. */
30
+ export type RpcResult<T> = ConnectionRpcResult<T>
31
+
32
+ /**
33
+ * Convert a rejected transport operation into a generic failure result.
34
+ * @param error - rejected transport value.
35
+ * @returns an `internal` failure preserving the available message.
36
+ */
37
+ export function transportError<T>(error: unknown): RpcResult<T> {
38
+ return {
39
+ ok: false,
40
+ error: {
41
+ code: 'gateway/internal',
42
+ message: error instanceof Error ? error.message : String(error),
43
+ details: {},
44
+ },
45
+ }
46
+ }
47
+
48
+ /** Narrow request form used by direct fixture adapters. */
49
+ export interface RpcRequest<P> {
50
+ readonly rpcId: RpcId
51
+ readonly payload: P
52
+ }
53
+
54
+ /** Narrow response form used by direct fixture adapters. */
55
+ export interface RpcResponse<T> {
56
+ readonly rpcId: RpcId
57
+ readonly result: RpcResult<T>
58
+ }
59
+
60
+ /** Full request envelope carried by Connection RPC transports. */
61
+ export interface ClientRequest {
62
+ readonly type: 'client-request'
63
+ readonly rpcId: RpcId
64
+ readonly method: string
65
+ readonly payload: unknown
66
+ }
67
+
68
+ /** Full response envelope carried by Connection RPC transports. */
69
+ export interface ServerResponse {
70
+ readonly type: 'server-response'
71
+ readonly rpcId: RpcId
72
+ readonly result: ConnectionRpcResult<unknown>
73
+ }
74
+
75
+ /** Complete Connection RPC envelope union. */
76
+ export type RpcMessage = ClientRequest | ServerResponse
77
+
78
+ /** HTTP request facts consumed by browser trust and authentication. */
79
+ export interface ConnectionTrustRequest {
80
+ /** Request headers supplied by either the Fetch or node:http representation. */
81
+ readonly headers: Headers | Readonly<Record<string, string | readonly string[] | undefined>>
82
+ }
83
+
84
+ /** HTTP status returned before dispatch, or undefined when the request may proceed. */
85
+ export type ConnectionRequestRejection = 401 | 403 | undefined
86
+
87
+ /** Root/index request facts used by the browser-token exchange. */
88
+ export interface ConnectionIndexRequest extends ConnectionTrustRequest {
89
+ readonly method?: string | undefined
90
+ readonly url?: string | undefined
91
+ }
92
+
93
+ /** Root/index response operations owned by the browser-token exchange. */
94
+ export interface ConnectionIndexResponse {
95
+ writeHead(status: number, headers?: Readonly<Record<string, string>>): unknown
96
+ end(body?: string): unknown
97
+ }
98
+
99
+ /** Handler invoked after Connection has decoded the transport envelope. */
100
+ export type ConnectionRpcHandler = (
101
+ endpoint: string,
102
+ payload: unknown,
103
+ signal: AbortSignal,
104
+ ) => Promise<ConnectionRpcResult<unknown>>
105
+
106
+ /** Synchronous ownership test for one endpoint on a shared RPC channel. */
107
+ export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean
108
+
109
+ /** HTTP methods supported by exact Fetch routes on the shared API channel. */
110
+ export type ConnectionFetchMethod = 'GET' | 'HEAD' | 'POST'
111
+
112
+ /** How the node:http bridge presents one request body to its Fetch route. */
113
+ export type ConnectionRequestBodyMode = 'buffered' | 'streaming'
114
+
115
+ /** One exact, transport-independent Fetch route owned by a Host feature. */
116
+ export interface ConnectionFetchRoute {
117
+ /** Absolute path below `/api`; query parameters remain available on the request URL. */
118
+ readonly path: string
119
+ /** Methods this route owns. Other methods continue through normal shared-channel dispatch. */
120
+ readonly methods: readonly ConnectionFetchMethod[]
121
+ /** Buffered requests obey the configured JSON cap; streaming requests arrive with backpressure and no aggregate cap. */
122
+ readonly requestBody: ConnectionRequestBodyMode
123
+ /** Handle one request after the physical carrier has applied its trust and authentication policy. */
124
+ readonly fetch: (request: Request) => Promise<Response>
125
+ }
126
+
127
+ /** Host registry for exact Fetch routes that cannot use JSON Remote invocation. */
128
+ export interface HostConnectionFetch {
129
+ /**
130
+ * Register one exact route on the shared API channel.
131
+ * @param route - path, methods, and Fetch-shaped implementation.
132
+ * @returns asynchronous disposer removing this exact contribution.
133
+ */
134
+ register(route: ConnectionFetchRoute): () => Promise<void>
135
+ }
136
+
137
+ /** Host registry for logical RPC channels carried by the current transport. */
138
+ export interface HostConnectionRpc {
139
+ /**
140
+ * Register one authenticated absolute channel prefix.
141
+ * @param channel - absolute logical channel such as `/rpc`.
142
+ * @param handler - decoded endpoint handler returning the existing RPC result shape.
143
+ * @returns asynchronous disposer removing the channel and its physical route.
144
+ */
145
+ handle(
146
+ channel: string,
147
+ handler: ConnectionRpcHandler,
148
+ ): () => Promise<void>
149
+
150
+ /**
151
+ * Intercept owned endpoints on the shared `/api` channel before its fallback.
152
+ * @param channel - reserved shared channel; currently `/api`.
153
+ * @param matches - synchronous endpoint ownership test.
154
+ * @param handler - decoded endpoint handler returning the existing RPC result shape.
155
+ * @returns asynchronous disposer removing the interceptor.
156
+ */
157
+ intercept(
158
+ channel: '/api',
159
+ matches: ConnectionRpcEndpointMatcher,
160
+ handler: ConnectionRpcHandler,
161
+ ): () => Promise<void>
162
+ }
163
+
164
+ /** Host `ctx.connection` shape consumed by transport-independent adapters. */
165
+ export interface HostConnectionHandle {
166
+ /** Generic RPC channel registry. */
167
+ readonly rpc: HostConnectionRpc
168
+ /** Exact Fetch routes for streaming or browser-native responses. */
169
+ readonly fetch: HostConnectionFetch
170
+
171
+ /**
172
+ * Compose exact Fetch routes and the shared-channel RPC interceptor.
173
+ * @param channel - shared channel mounted by Connection.
174
+ * @returns Fetch handler for trusted, authenticated requests.
175
+ */
176
+ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler
177
+
178
+ /**
179
+ * Apply Connection's Host/Origin checks and browser authentication to
180
+ * another Web route.
181
+ * @param request - request headers from the HTTP or upgrade request.
182
+ * @returns rejection status, or undefined when the route may accept the request.
183
+ */
184
+ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection
185
+
186
+ /**
187
+ * Authenticate one frontend index request, owning a token redirect or 401.
188
+ * @param request - root or configured-index HTTP request.
189
+ * @param response - response owned when the result is false.
190
+ * @returns true only when the frontend may serve index.html.
191
+ */
192
+ authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean
193
+
194
+ /**
195
+ * Add the fresh process token to an ordinary Web application URL.
196
+ * @param baseUrl - clean canonical browser origin.
197
+ * @returns root URL accepted by {@link authorizeIndex} for initial login.
198
+ */
199
+ authenticatedUrl(baseUrl: string): string
200
+ }
201
+
202
+ /** Transport-independent Fetch handler used by HTTP and worker carriers. */
203
+ export interface ConnectionFetchHandler {
204
+ /**
205
+ * Resolve body handling before the bridge reads any request bytes.
206
+ * @param request - request method and URL available from node:http headers.
207
+ * @returns the registered route's body handling mode.
208
+ */
209
+ requestBodyMode(request: { readonly method: string; readonly url: URL }): ConnectionRequestBodyMode
210
+
211
+ /**
212
+ * Dispatch one already-authenticated request.
213
+ * @param request - Fetch request below the shared channel.
214
+ * @returns the registered response or a 404 response.
215
+ */
216
+ fetch(request: Request): Promise<Response>
217
+ }
218
+
219
+ /** Client caller for logical RPC channels carried by the current transport. */
220
+ export interface ClientConnectionRpc {
221
+ /**
222
+ * Call one endpoint through an already registered logical channel.
223
+ * @param channel - absolute logical channel such as `/api`.
224
+ * @param endpoint - channel-relative endpoint such as `goals/create`.
225
+ * @param payload - channel-owned request payload.
226
+ * @param signal - optional caller cancellation.
227
+ * @returns the endpoint-owned success/error result; correlation stays inside Connection.
228
+ */
229
+ call(
230
+ channel: string,
231
+ endpoint: string,
232
+ payload: unknown,
233
+ signal?: AbortSignal,
234
+ ): Promise<ConnectionRpcResult<unknown>>
235
+
236
+ /**
237
+ * Open an in-process logical stream when the selected carrier supplies one.
238
+ * Browser transports omit this method; API Gateway owns their WebSocket mux.
239
+ * @param channel - absolute logical channel such as `/api`.
240
+ * @param endpoint - channel-relative endpoint such as `session/follow`.
241
+ * @param payload - channel-owned request payload.
242
+ * @param signal - caller cancellation for this logical stream.
243
+ * @returns decoded stream values from the in-process carrier.
244
+ */
245
+ readonly open?: (
246
+ channel: string,
247
+ endpoint: string,
248
+ payload: unknown,
249
+ signal: AbortSignal,
250
+ ) => AsyncIterable<unknown>
251
+ }
packages/client/connection/tests/api-helpers.client.spec.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Contract-layer helpers: transport-error folding and response unwrapping.
3
+ * (The assistant block classifier half of the legacy spec lives in
4
+ * runtime/tests — the classifier moved there.)
5
+ */
6
+
7
+ import { describe, expect, it } from 'vitest'
8
+ import { RpcId, resultOf, transportError } from '../src/client/api.ts'
9
+
10
+ describe('transportError', () => {
11
+ it('folds an Error to internal keeping the message, and stringifies non-Errors', () => {
12
+ expect(transportError(new Error('线断了'))).toEqual({ ok: false, error: { code: 'gateway/internal', message: '线断了', details: {} } })
13
+ expect(transportError('raw string')).toMatchObject({ ok: false, error: { message: 'raw string' } })
14
+ })
15
+ })
16
+
17
+ describe('resultOf', () => {
18
+ it('unwraps the result slot', () => {
19
+ expect(resultOf({ rpcId: RpcId('r'), result: { ok: true, value: 7 } })).toEqual({ ok: true, value: 7 })
20
+ })
21
+ })
packages/client/connection/tests/api-request-trust.host.spec.ts ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+ import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts'
5
+
6
+ function request(headers: Record<string, string | undefined>): { headers: Record<string, string | undefined> } {
7
+ return { headers }
8
+ }
9
+
10
+ describe('isTrustedApiRequest', () => {
11
+ it('holds markerless requests to the same Host fence — a plain-HTTP browser read carries no markers', () => {
12
+ // Over plain HTTP a browser attaches neither Origin nor Fetch-Metadata to
13
+ // reads (EventSource, images, navigations), so a rebound-origin GET is
14
+ // markerless and its response readable: no marker shortcut may exist.
15
+ expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true)
16
+ expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), ['192.168.1.5'])).toBe(true)
17
+ expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), [])).toBe(false)
18
+ expect(isTrustedApiRequest(request({ host: 'harness.example' }), [])).toBe(false)
19
+ expect(isTrustedApiRequest(request({}), [])).toBe(false)
20
+ })
21
+
22
+ it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => {
23
+ for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) {
24
+ expect(isTrustedApiRequest(request({ host, origin: `http://${host}` }), [])).toBe(true)
25
+ }
26
+ })
27
+
28
+ it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => {
29
+ expect(isTrustedApiRequest(request({
30
+ host: 'evil.example:3080',
31
+ origin: 'http://evil.example:3080',
32
+ 'sec-fetch-site': 'same-origin',
33
+ }), [])).toBe(false)
34
+ })
35
+
36
+ it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => {
37
+ const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }
38
+ expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true)
39
+ expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(true)
40
+ expect(isTrustedApiRequest(request(headers), ['harness.internal:9999'])).toBe(false)
41
+ expect(isTrustedApiRequest(request(headers), [])).toBe(false)
42
+ })
43
+
44
+ it('matches Host, Origin, and trusted entries through WHATWG normalization (case, default port)', () => {
45
+ expect(isTrustedApiRequest(request({ host: 'Harness.INTERNAL:3080', origin: 'http://harness.internal:3080' }), ['harness.internal:3080'])).toBe(true)
46
+ expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['HARNESS.internal:80'])).toBe(true)
47
+ // An unparsable entry never matches; it must not poison the rest of the list.
48
+ expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry', 'harness.internal'])).toBe(true)
49
+ expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry'])).toBe(false)
50
+ })
51
+
52
+ it('refuses cross-origin browser markers even on a loopback Host', () => {
53
+ // Origin present and different → cross-site request that survived preflight rules.
54
+ expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false)
55
+ // Explicit cross-site label → refused regardless of Origin.
56
+ expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', 'sec-fetch-site': 'cross-site' }), [])).toBe(false)
57
+ // Opaque origin (sandboxed iframe, file: page) parses to no authority.
58
+ expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false)
59
+ })
60
+
61
+ it('accepts a same-origin browser request, with or without an Origin header', () => {
62
+ expect(isTrustedApiRequest(request({
63
+ host: 'localhost:3080',
64
+ origin: 'http://localhost:3080',
65
+ 'sec-fetch-site': 'same-origin',
66
+ }), [])).toBe(true)
67
+ // Origin-less browser shapes (same-origin GETs) still carry sec-fetch-site.
68
+ expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true)
69
+ })
70
+
71
+ it('reads Fetch Headers while preserving absent browser markers', () => {
72
+ expect(isTrustedApiRequest({ headers: new Headers({ host: '127.0.0.1:3080' }) }, [])).toBe(true)
73
+ expect(isTrustedApiRequest({
74
+ headers: new Headers({ host: '127.0.0.1:3080', origin: 'http://evil.example' }),
75
+ }, [])).toBe(false)
76
+ })
77
+
78
+ it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => {
79
+ for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) {
80
+ expect(() => { assertTrustedAuthority(entry) }).not.toThrow()
81
+ }
82
+ // WHATWG parsing would quietly read a hostname out of each of these; the
83
+ // config boundary must refuse them instead of authorizing the prefix.
84
+ for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) {
85
+ expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
86
+ }
87
+ // WHATWG trimming would silently strip these; the entry must fail instead.
88
+ for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) {
89
+ expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
90
+ }
91
+ // WHATWG parsing would silently rewrite these — a dangling colon or
92
+ // zero-padded port would broaden an intended exact-port grant to every
93
+ // port, and non-canonical host spellings would not read back as written.
94
+ for (const entry of ['harness.internal:', '[::1]:', 'harness.internal:0080', '0x7f.0.0.1', '[0:0:0:0:0:0:0:1]']) {
95
+ expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
96
+ }
97
+ })
98
+
99
+ it('never lets stray whitespace broaden an exact-port entry to every port', () => {
100
+ // Defense in depth below the load-time assert: the explicit-port judgment
101
+ // reads the parsed URL, so a trimmed `host:port ` entry stays exact.
102
+ const trusted = ['harness.internal:3080 ']
103
+ expect(isTrustedApiRequest(request({ host: 'harness.internal:9999', origin: 'http://harness.internal:9999' }), trusted)).toBe(false)
104
+ expect(isTrustedApiRequest(request({ host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }), trusted)).toBe(true)
105
+ })
106
+
107
+ it('refuses malformed or untrusted authorities on browser requests', () => {
108
+ const markers = { 'sec-fetch-site': 'same-origin' }
109
+ expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false)
110
+ expect(isTrustedApiRequest(request({ ...markers, host: '' }), [])).toBe(false)
111
+ expect(isTrustedApiRequest(request({ ...markers, host: 'bad host' }), [])).toBe(false)
112
+ expect(isTrustedApiRequest(request({ ...markers, host: '127.0.0.999' }), [])).toBe(false)
113
+ expect(isTrustedApiRequest(request({ ...markers, host: '128.0.0.1' }), [])).toBe(false)
114
+ })
115
+ })
packages/client/connection/tests/browser-auth.host.spec.ts ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Browser launch-token and persistent-cookie behavior. */
2
+
3
+ import { createHmac } from 'node:crypto'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'
6
+ import { BrowserAuth } from '../src/browser-auth.ts'
7
+ import type { ConnectionIndexRequest, ConnectionIndexResponse } from '../src/rpc.ts'
8
+ import { RecordCredentials } from './browser-credentials.ts'
9
+
10
+ function signedCookie(store: RecordCredentials, name: string, payload: unknown): string {
11
+ const body = typeof payload === 'string'
12
+ ? Buffer.from(payload, 'utf8').toString('base64url')
13
+ : Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
14
+ return signedBodyCookie(store, name, body)
15
+ }
16
+
17
+ function signedBodyCookie(store: RecordCredentials, name: string, body: string): string {
18
+ const record = store.record
19
+ if (record?.kind !== 'grant' || typeof record.payload !== 'object' || record.payload === null) {
20
+ throw new Error('test credential store has no signing secret')
21
+ }
22
+ const secret: unknown = Reflect.get(record.payload, 'secret')
23
+ if (typeof secret !== 'string') throw new Error('test credential record has no string secret')
24
+ const signature = createHmac('sha256', Buffer.from(secret, 'base64url')).update(body).digest('base64url')
25
+ return `${name}=v1.${body}.${signature}`
26
+ }
27
+
28
+ interface ResponseState {
29
+ status?: number
30
+ headers?: Readonly<Record<string, string>>
31
+ body?: string
32
+ }
33
+
34
+ function response(): { value: ConnectionIndexResponse; state: ResponseState } {
35
+ const state: ResponseState = {}
36
+ return {
37
+ value: {
38
+ writeHead(status, headers) {
39
+ state.status = status
40
+ if (headers !== undefined) state.headers = headers
41
+ },
42
+ end(body) {
43
+ if (body !== undefined) state.body = body
44
+ },
45
+ },
46
+ state,
47
+ }
48
+ }
49
+
50
+ function credentials(store: RecordCredentials): CredentialProvider {
51
+ return store as unknown as CredentialProvider
52
+ }
53
+
54
+ function createAuth(
55
+ store: RecordCredentials,
56
+ maxAgeDays = 30,
57
+ processOwner: object = {},
58
+ ): Promise<BrowserAuth> {
59
+ return BrowserAuth.create(processOwner, credentials(store), maxAgeDays)
60
+ }
61
+
62
+ function request(url: string, authority = '127.0.0.1:3080', init?: {
63
+ cookie?: string
64
+ method?: string
65
+ }): ConnectionIndexRequest {
66
+ return {
67
+ method: init?.method ?? 'GET',
68
+ url,
69
+ headers: {
70
+ host: authority,
71
+ ...init?.cookie === undefined ? {} : { cookie: init.cookie },
72
+ },
73
+ }
74
+ }
75
+
76
+ function exchange(
77
+ auth: BrowserAuth,
78
+ authority = '127.0.0.1:3080',
79
+ ): { cookie: string; launchUrl: string; state: ResponseState } {
80
+ const launchUrl = auth.authenticatedUrl(`http://${authority}`)
81
+ const target = new URL(launchUrl)
82
+ const res = response()
83
+ expect(auth.authorizeIndex(request(`${target.pathname}${target.search}`, authority), res.value)).toBe(false)
84
+ const setCookie = res.state.headers?.['set-cookie']
85
+ if (setCookie === undefined) throw new Error('token exchange did not set a cookie')
86
+ return { cookie: setCookie.split(';', 1)[0]!, launchUrl, state: res.state }
87
+ }
88
+
89
+ afterEach(() => {
90
+ vi.useRealTimers()
91
+ })
92
+
93
+ describe('BrowserAuth', () => {
94
+ it('mints one process token and a persistent authority-bound cookie', async () => {
95
+ const store = new RecordCredentials()
96
+ const processOwner = {}
97
+ const first = await createAuth(store, 30, processOwner)
98
+ const login = exchange(first)
99
+
100
+ expect(login.state).toMatchObject({
101
+ status: 303,
102
+ headers: {
103
+ 'cache-control': 'no-store',
104
+ 'location': '/',
105
+ 'referrer-policy': 'no-referrer',
106
+ },
107
+ })
108
+ expect(login.state.headers?.['set-cookie']).toMatch(/; Max-Age=2592000; Path=\/; Expires=.*; HttpOnly; SameSite=Strict$/u)
109
+ expect(login.state.headers?.['set-cookie']).not.toContain('Secure')
110
+ expect(first.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
111
+ expect(first.isAuthenticated({
112
+ headers: new Headers({ host: '127.0.0.1:3080', cookie: login.cookie }),
113
+ })).toBe(true)
114
+ expect(first.isAuthenticated({ headers: new Headers() })).toBe(false)
115
+ expect(first.isAuthenticated(request('/', 'localhost:3080', { cookie: login.cookie }))).toBe(false)
116
+ expect(first.isAuthenticated(request('/', '127.0.0.1:3081', { cookie: login.cookie }))).toBe(false)
117
+
118
+ const reloaded = await createAuth(store, 30, processOwner)
119
+ expect(reloaded.authenticatedUrl('http://127.0.0.1:3080')).toBe(login.launchUrl)
120
+ expect(reloaded.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
121
+
122
+ const restarted = await createAuth(store)
123
+ expect(new URL(restarted.authenticatedUrl('http://127.0.0.1:3080')).searchParams.get('token'))
124
+ .not.toBe(new URL(login.launchUrl).searchParams.get('token'))
125
+ expect(restarted.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
126
+ const staleUrl = new URL(login.launchUrl)
127
+ const redirected = response()
128
+ expect(restarted.authorizeIndex(request(
129
+ `${staleUrl.pathname}${staleUrl.search}`,
130
+ '127.0.0.1:3080',
131
+ { cookie: login.cookie },
132
+ ), redirected.value)).toBe(false)
133
+ expect(redirected.state).toEqual({
134
+ status: 303,
135
+ headers: {
136
+ 'cache-control': 'no-store',
137
+ 'location': '/',
138
+ 'referrer-policy': 'no-referrer',
139
+ },
140
+ })
141
+ })
142
+
143
+ it('accepts the cookie for index serving and gives every unauthenticated request one response', async () => {
144
+ const auth = await createAuth(new RecordCredentials())
145
+ const { cookie } = exchange(auth)
146
+ const allowed = response()
147
+ expect(auth.authorizeIndex(request('/index.html', '127.0.0.1:3080', { cookie }), allowed.value)).toBe(true)
148
+ expect(allowed.state).toEqual({})
149
+
150
+ for (const candidate of [
151
+ request('/'),
152
+ request('/?token=wrong'),
153
+ request('/?token=wrong&token=again'),
154
+ request('/index.html?token=wrong'),
155
+ request(auth.authenticatedUrl('http://127.0.0.1:3080'), '127.0.0.1:3080', { method: 'HEAD' }),
156
+ ]) {
157
+ const denied = response()
158
+ expect(auth.authorizeIndex(candidate, denied.value)).toBe(false)
159
+ expect(denied.state.status).toBe(401)
160
+ expect(denied.state.headers).toEqual({
161
+ 'cache-control': 'no-store',
162
+ 'content-type': 'text/plain; charset=utf-8',
163
+ })
164
+ expect(denied.state.body).toBe(candidate.method === 'HEAD'
165
+ ? undefined
166
+ : 'dsh web authentication required; reopen the URL printed by dsh web.\n')
167
+ }
168
+ })
169
+
170
+ it('rejects tampering, expiry, future issuance, and a longer lifetime than configured', async () => {
171
+ vi.useFakeTimers()
172
+ vi.setSystemTime(new Date('2026-08-24T00:00:00.000Z'))
173
+ const store = new RecordCredentials()
174
+ const auth = await createAuth(store)
175
+ const { cookie } = exchange(auth)
176
+ const [name, value] = cookie.split('=') as [string, string]
177
+
178
+ expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=broken` }))).toBe(false)
179
+ expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=${value.slice(0, -1)}x` }))).toBe(false)
180
+ expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=%` }))).toBe(false)
181
+ expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', {
182
+ cookie: signedBodyCookie(store, name, 'a'),
183
+ }))).toBe(false)
184
+ expect(auth.isAuthenticated({ headers: {} })).toBe(false)
185
+ expect(auth.isAuthenticated({ headers: { host: 'bad host', cookie } })).toBe(false)
186
+ expect(auth.isAuthenticated({ headers: { host: '127.0.0.1:3080' } })).toBe(false)
187
+
188
+ const invalidPayloads: unknown[] = [
189
+ 'not json',
190
+ null,
191
+ { version: 2, authority: '127.0.0.1:3080', issuedAt: Date.now(), expiresAt: Date.now() + 1000 },
192
+ { version: 1, authority: 42, issuedAt: Date.now(), expiresAt: Date.now() + 1000 },
193
+ { version: 1, authority: '127.0.0.1:3080', issuedAt: 'now', expiresAt: Date.now() + 1000 },
194
+ { version: 1, authority: '127.0.0.1:3080', issuedAt: Date.now(), expiresAt: 'later' },
195
+ ]
196
+ for (const payload of invalidPayloads) {
197
+ expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', {
198
+ cookie: signedCookie(store, name, payload),
199
+ }))).toBe(false)
200
+ }
201
+
202
+ const shorter = await createAuth(store, 1)
203
+ expect(shorter.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
204
+ vi.setSystemTime(new Date('2026-09-24T00:00:00.000Z'))
205
+ expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
206
+ vi.setSystemTime(new Date('2026-08-23T00:00:00.000Z'))
207
+ expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
208
+ })
209
+
210
+ it('loads one secret per activation and replaces it after deletion on the next activation', async () => {
211
+ const store = new RecordCredentials()
212
+ const auth = await createAuth(store)
213
+ const first = exchange(auth)
214
+ expect(store).toMatchObject({ reads: 0, modifies: 1 })
215
+
216
+ await store.deleteRecord()
217
+ expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(true)
218
+ const sameActivation = exchange(auth)
219
+ expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: sameActivation.cookie }))).toBe(true)
220
+ expect(store).toMatchObject({ reads: 0, modifies: 1 })
221
+
222
+ const reactivated = await createAuth(store)
223
+ const second = exchange(reactivated)
224
+ expect(second.cookie).not.toBe(first.cookie)
225
+ expect(reactivated.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false)
226
+ expect(reactivated.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: second.cookie }))).toBe(true)
227
+ expect(store).toMatchObject({ reads: 0, modifies: 2 })
228
+ })
229
+
230
+ it('fails loud on an invalid owner record instead of replacing it', async () => {
231
+ const unsupported = new RecordCredentials()
232
+ unsupported.record = { kind: 'api-key', key: 'not-a-cookie-secret' }
233
+ await expect(createAuth(unsupported)).rejects.toThrow(/unsupported format/u)
234
+
235
+ const malformed = new RecordCredentials()
236
+ malformed.record = { kind: 'grant', payload: { version: 1, secret: 'short' } }
237
+ await expect(createAuth(malformed)).rejects.toThrow(/invalid secret/u)
238
+
239
+ const nonString = new RecordCredentials()
240
+ nonString.record = { kind: 'grant', payload: { version: 1, secret: 42 } }
241
+ await expect(createAuth(nonString)).rejects.toThrow(/invalid secret/u)
242
+
243
+ const discarded = new RecordCredentials()
244
+ discarded.discardWrites = true
245
+ await expect(createAuth(discarded)).rejects.toThrow(/was not created/u)
246
+
247
+ await expect(createAuth(new RecordCredentials(), Number.MAX_SAFE_INTEGER))
248
+ .rejects.toThrow(/safe timestamp range/u)
249
+ })
250
+ })
packages/client/connection/tests/browser-credentials.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials'
3
+
4
+ /** Mutable credential-record double for Connection authentication tests. */
5
+ export class RecordCredentials {
6
+ record: CredentialRecord | undefined
7
+ discardWrites = false
8
+ reads = 0
9
+ modifies = 0
10
+
11
+ readRecord(): Promise<CredentialRecord | undefined> {
12
+ this.reads += 1
13
+ return Promise.resolve(this.record)
14
+ }
15
+
16
+ async modifyRecord(
17
+ _key: unknown,
18
+ mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
19
+ ): Promise<CredentialRecord | undefined> {
20
+ this.modifies += 1
21
+ const next = await mutate(this.record)
22
+ if (this.discardWrites) return undefined
23
+ if (next !== undefined) this.record = next
24
+ return this.record
25
+ }
26
+
27
+ deleteRecord(): Promise<void> {
28
+ this.record = undefined
29
+ return Promise.resolve()
30
+ }
31
+ }
32
+
33
+ /** Provide the record operations Connection needs during authentication setup. */
34
+ export function provideBrowserCredentials(ctx: Context): void {
35
+ ctx.provide('credentials', new RecordCredentials() as unknown as CredentialProvider)
36
+ }
packages/client/connection/tests/client-apply.client.spec.ts ADDED
@@ -0,0 +1,620 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Connection plugin browser-half apply: ctx.connection handle mounting,
3
+ * explicit carrier selection, and single-consumer connection-loop ownership.
4
+ */
5
+ import { Context } from '@deepseek-ai/cordis'
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+ import {
8
+ apply,
9
+ type ClientConnectionRpc,
10
+ type ClientTransportHooks,
11
+ type ConnectionGenerationSource,
12
+ type RpcFetch,
13
+ type ConnectionHandle,
14
+ type ConnectionState,
15
+ } from '../src/client/index.ts'
16
+
17
+ type Win = {
18
+ location?: { hostname: string; origin?: string }
19
+ __DSH_TRANSPORT__?: ClientTransportHooks
20
+ }
21
+
22
+ afterEach(() => {
23
+ delete (globalThis as Win).location
24
+ delete (globalThis as Win).__DSH_TRANSPORT__
25
+ vi.unstubAllGlobals()
26
+ vi.useRealTimers()
27
+ })
28
+
29
+ class BrowserNetworkProbe extends EventTarget {
30
+ readonly navigator = { onLine: true }
31
+
32
+ setOnline(online: boolean): void {
33
+ this.navigator.onLine = online
34
+ this.dispatchEvent(new Event(online ? 'online' : 'offline'))
35
+ }
36
+ }
37
+
38
+ class GenerationProbe {
39
+ private readonly active = new Set<() => void>()
40
+
41
+ readonly source: ConnectionGenerationSource = (signal, ready) => new Promise<void>((resolve) => {
42
+ let settled = false
43
+ const finish = (): void => {
44
+ if (settled) return
45
+ settled = true
46
+ signal.removeEventListener('abort', finish)
47
+ this.active.delete(finish)
48
+ resolve()
49
+ }
50
+ this.active.add(finish)
51
+ signal.addEventListener('abort', finish, { once: true })
52
+ ready({ home: '/h' })
53
+ if (signal.aborted) finish()
54
+ })
55
+
56
+ end(): void {
57
+ for (const finish of [...this.active]) finish()
58
+ }
59
+ }
60
+
61
+ function installGeneration(handle: ConnectionHandle): GenerationProbe {
62
+ const probe = new GenerationProbe()
63
+ handle.registerGenerationSource(probe.source)
64
+ return probe
65
+ }
66
+
67
+ async function mount(): Promise<ConnectionHandle> {
68
+ const ctx = new Context()
69
+ await ctx.plugin({ apply, inject: [] })
70
+ const handle = ctx.get('connection') as ConnectionHandle | undefined
71
+ if (handle === undefined) throw new Error('ctx.connection not provided')
72
+ return handle
73
+ }
74
+
75
+ describe('connection client apply', () => {
76
+ it('uses Host bootstrap timing when Gateway starts without overrides', async () => {
77
+ vi.useFakeTimers()
78
+ vi.stubGlobal('__DSH_CONNECTION_RECOVERY__', {
79
+ backoffBaseMs: 10, backoffMaxMs: 10, generationReadyTimeoutMs: 20,
80
+ })
81
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
82
+ const handle = await mount()
83
+ const signals: AbortSignal[] = []
84
+ handle.registerGenerationSource(signal => new Promise<void>((resolve) => {
85
+ signals.push(signal)
86
+ signal.addEventListener('abort', () => { resolve() }, { once: true })
87
+ }))
88
+ const loop = handle.start({})
89
+ try {
90
+ await vi.advanceTimersByTimeAsync(20)
91
+ expect(signals[0]?.aborted).toBe(true)
92
+ expect(handle.state.getSnapshot()).toBe('connecting')
93
+ await vi.advanceTimersByTimeAsync(10)
94
+ expect(signals).toHaveLength(2)
95
+ } finally {
96
+ loop.stop()
97
+ await vi.advanceTimersByTimeAsync(0)
98
+ warnSpy.mockRestore()
99
+ }
100
+ })
101
+
102
+ it.each([{ generationReadyTimeoutMs: 0 }, { backoffFactor: NaN }])('rejects malformed bootstrap recovery before publishing the service: %j', (recovery) => {
103
+ vi.stubGlobal('__DSH_CONNECTION_RECOVERY__', recovery)
104
+ const ctx = new Context()
105
+ expect(() => { apply(ctx) }).toThrow()
106
+ expect(ctx.get('connection')).toBeUndefined()
107
+ })
108
+
109
+ it('rejects a NaN start override without acquiring the generation source', async () => {
110
+ const handle = await mount()
111
+ const source = vi.fn<ConnectionGenerationSource>()
112
+ const unregister = handle.registerGenerationSource(source)
113
+ try {
114
+ expect(() => handle.start({}, { backoffFactor: NaN })).toThrow(/backoffFactor.*finite/)
115
+ expect(source).not.toHaveBeenCalled()
116
+ } finally {
117
+ unregister()
118
+ }
119
+ })
120
+
121
+ it('treats a runtime without browser location as local', async () => {
122
+ delete (globalThis as Win).location
123
+ expect((await mount()).isLoopback).toBe(true)
124
+ })
125
+
126
+ it('mounts ctx.connection and identifies a loopback page', async () => {
127
+ ;(globalThis as Win).location = { hostname: 'localhost' }
128
+ const handle = await mount()
129
+ expect(handle.isLoopback).toBe(true)
130
+ })
131
+
132
+ it('reports non-loopback page authority through the connection handle', async () => {
133
+ ;(globalThis as Win).location = { hostname: '192.0.2.20' }
134
+ expect((await mount()).isLoopback).toBe(false)
135
+ })
136
+
137
+ it('requires one generation source and ignores a stale source disposer', async () => {
138
+ ;(globalThis as Win).location = { hostname: 'localhost' }
139
+ const handle = await mount()
140
+ const first = new GenerationProbe()
141
+ const second = new GenerationProbe()
142
+
143
+ expect(() => handle.start({})).toThrow('no generation source is registered')
144
+ const unregisterFirst = handle.registerGenerationSource(first.source)
145
+ expect(() => { handle.registerGenerationSource(second.source) })
146
+ .toThrow('a generation source is already registered')
147
+ unregisterFirst()
148
+ const unregisterSecond = handle.registerGenerationSource(second.source)
149
+ unregisterFirst()
150
+
151
+ const loop = handle.start({})
152
+ await vi.waitFor(() => {
153
+ expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
154
+ })
155
+ unregisterSecond()
156
+ expect(handle.generation.getSnapshot()).toBeUndefined()
157
+ loop.stop()
158
+ })
159
+
160
+ it('start() hands out one loop, rejects a second consumer, and stop() aborts the generation', async () => {
161
+ ;(globalThis as Win).location = { hostname: 'localhost' }
162
+ const handle = await mount()
163
+ installGeneration(handle)
164
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
165
+ const generations: Array<string | undefined> = []
166
+ const stopThrowing = handle.generation.subscribe(() => { throw new Error('subscriber bug') })
167
+ const stopGeneration = handle.generation.subscribe(() => {
168
+ generations.push(handle.generation.getSnapshot()?.host.home)
169
+ })
170
+ expect(handle.generation.getSnapshot()).toBeUndefined()
171
+ let connected = 0
172
+ const loop = handle.start({ onConnected: () => { connected++ } })
173
+ expect(() => handle.start({})).toThrow(/already owned by another consumer/)
174
+ await vi.waitFor(() => {
175
+ expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
176
+ })
177
+ loop.stop() // teardown must not throw; the fixture streams abort quietly
178
+ expect(handle.generation.getSnapshot()).toBeUndefined()
179
+ expect(generations).toEqual(['/h', undefined])
180
+ expect(connected).toBe(1)
181
+ expect(errorSpy).toHaveBeenCalledTimes(2)
182
+ stopThrowing()
183
+ stopGeneration()
184
+ errorSpy.mockRestore()
185
+ })
186
+
187
+ it('does not notify state subscribers when a pre-ready loop stops', async () => {
188
+ ;(globalThis as Win).location = { hostname: 'localhost' }
189
+ const handle = await mount()
190
+ handle.registerGenerationSource(signal => new Promise<void>((resolve) => {
191
+ signal.addEventListener('abort', () => { resolve() }, { once: true })
192
+ }))
193
+ const listener = vi.fn()
194
+ const unsubscribe = handle.state.subscribe(listener)
195
+ const loop = handle.start({})
196
+
197
+ loop.stop()
198
+
199
+ expect(handle.state.getSnapshot()).toBeUndefined()
200
+ expect(listener).not.toHaveBeenCalled()
201
+ unsubscribe()
202
+ })
203
+
204
+ it('allows a replacement owner and ignores the previous owner handle', async () => {
205
+ ;(globalThis as Win).location = { hostname: 'localhost' }
206
+ const handle = await mount()
207
+ const generation = installGeneration(handle)
208
+
209
+ const first = handle.start({})
210
+ await vi.waitFor(() => {
211
+ expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
212
+ })
213
+ first.stop()
214
+ expect(handle.generation.getSnapshot()).toBeUndefined()
215
+
216
+ const second = handle.start({})
217
+ await vi.waitFor(() => {
218
+ expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
219
+ })
220
+ first.stop()
221
+ expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
222
+
223
+ second.stop()
224
+ generation.end()
225
+ })
226
+
227
+ it('lets the connection service force only its current owner to reconnect', async () => {
228
+ ;(globalThis as Win).location = { hostname: 'localhost' }
229
+ const handle = await mount()
230
+ installGeneration(handle)
231
+ const requested = vi.fn()
232
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
233
+ const loop = handle.start({ onReconnectRequested: requested }, {
234
+ backoffBaseMs: 60_000,
235
+ backoffFactor: 2,
236
+ backoffMaxMs: 120_000,
237
+ generationReadyTimeoutMs: 500,
238
+ })
239
+ try {
240
+ await vi.waitFor(() => { expect(handle.generation.getSnapshot()?.id).toBe(1) })
241
+ handle.reconnect()
242
+ await vi.waitFor(() => { expect(handle.generation.getSnapshot()?.id).toBe(2) })
243
+ expect(requested).toHaveBeenCalledOnce()
244
+ loop.stop()
245
+ handle.reconnect()
246
+ expect(requested).toHaveBeenCalledOnce()
247
+ } finally {
248
+ loop.stop()
249
+ warnSpy.mockRestore()
250
+ }
251
+ })
252
+
253
+ it('ignores a non-browser window shim without navigator state', async () => {
254
+ vi.stubGlobal('window', new EventTarget())
255
+ ;(globalThis as Win).location = { hostname: 'localhost' }
256
+ const handle = await mount()
257
+ installGeneration(handle)
258
+ const loop = handle.start({})
259
+ try {
260
+ await vi.waitFor(() => { expect(handle.state.getSnapshot()).toBe('connected') })
261
+ } finally {
262
+ loop.stop()
263
+ }
264
+ })
265
+
266
+ it('feeds browser offline and online events into the owned retry loop', async () => {
267
+ vi.useFakeTimers()
268
+ const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
269
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
270
+ const browser = new BrowserNetworkProbe()
271
+ vi.stubGlobal('window', browser)
272
+ ;(globalThis as Win).location = { hostname: 'localhost' }
273
+ const handle = await mount()
274
+ let calls = 0
275
+ const source: ConnectionGenerationSource = (signal, ready) => new Promise<void>((resolve) => {
276
+ calls++
277
+ ready({ home: '/h' })
278
+ signal.addEventListener('abort', () => { resolve() }, { once: true })
279
+ })
280
+ handle.registerGenerationSource(source)
281
+ const states: Array<ConnectionState | undefined> = []
282
+ const unsubscribe = handle.state.subscribe(() => { states.push(handle.state.getSnapshot()) })
283
+ const loop = handle.start({}, {
284
+ backoffBaseMs: 100,
285
+ backoffFactor: 2,
286
+ backoffMaxMs: 1_000,
287
+ generationReadyTimeoutMs: 500,
288
+ })
289
+ try {
290
+ await vi.advanceTimersByTimeAsync(0)
291
+ expect(handle.state.getSnapshot()).toBe('connected')
292
+ expect(calls).toBe(1)
293
+
294
+ browser.setOnline(false)
295
+ expect(handle.state.getSnapshot()).toBe('disconnected')
296
+ await vi.advanceTimersByTimeAsync(10_000)
297
+ expect(calls).toBe(1)
298
+
299
+ browser.setOnline(true)
300
+ expect(handle.state.getSnapshot()).toBe('connecting')
301
+ await vi.advanceTimersByTimeAsync(49)
302
+ expect(calls).toBe(1)
303
+ await vi.advanceTimersByTimeAsync(1)
304
+ expect(calls).toBe(2)
305
+ expect(handle.state.getSnapshot()).toBe('connected')
306
+ expect(states).toEqual(['connected', 'disconnected', 'connecting', 'connected'])
307
+ } finally {
308
+ unsubscribe()
309
+ loop.stop()
310
+ randomSpy.mockRestore()
311
+ warnSpy.mockRestore()
312
+ }
313
+ })
314
+
315
+ it('does not announce a generation synchronously stopped by a generation subscriber', async () => {
316
+ ;(globalThis as Win).location = { hostname: 'localhost' }
317
+ const handle = await mount()
318
+ installGeneration(handle)
319
+ const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
320
+ let sawGeneration = false
321
+ const stopGeneration = handle.generation.subscribe(() => {
322
+ if (handle.generation.getSnapshot() === undefined) return
323
+ sawGeneration = true
324
+ owner.loop?.stop()
325
+ })
326
+ const connected = vi.fn()
327
+ const loop = handle.start({ onConnected: connected })
328
+ owner.loop = loop
329
+ try {
330
+ await vi.waitFor(() => { expect(sawGeneration).toBe(true) })
331
+ expect(handle.generation.getSnapshot()).toBeUndefined()
332
+ expect(connected).not.toHaveBeenCalled()
333
+ } finally {
334
+ stopGeneration()
335
+ loop.stop()
336
+ }
337
+ })
338
+
339
+ it('retracts the generation while connecting and publishes the next generation', async () => {
340
+ ;(globalThis as Win).location = { hostname: 'localhost' }
341
+ const handle = await mount()
342
+ const generation = installGeneration(handle)
343
+ const generations: Array<string | undefined> = []
344
+ const reconnectSnapshots: Array<string | undefined> = []
345
+ const stopGeneration = handle.generation.subscribe(() => {
346
+ generations.push(handle.generation.getSnapshot()?.host.home)
347
+ })
348
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
349
+ const loop = handle.start({
350
+ onStateChange: (state) => {
351
+ if (state === 'connecting') {
352
+ reconnectSnapshots.push(handle.generation.getSnapshot()?.host.home)
353
+ }
354
+ },
355
+ }, { backoffBaseMs: 10, backoffFactor: 2, backoffMaxMs: 80, generationReadyTimeoutMs: 500 })
356
+ try {
357
+ await vi.waitFor(() => {
358
+ expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
359
+ })
360
+ generation.end()
361
+
362
+ await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) })
363
+ await vi.waitFor(() => { expect(generations).toEqual(['/h', undefined, '/h']) })
364
+ expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
365
+ } finally {
366
+ stopGeneration()
367
+ loop.stop()
368
+ warnSpy.mockRestore()
369
+ }
370
+ })
371
+
372
+ it('publishes connection state directly on the service and isolates subscribers', async () => {
373
+ ;(globalThis as Win).location = { hostname: 'localhost' }
374
+ const handle = await mount()
375
+ const generation = installGeneration(handle)
376
+ const snapshots: Array<ConnectionState | undefined> = []
377
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
378
+ const unsubscribe = handle.state.subscribe(() => { snapshots.push(handle.state.getSnapshot()) })
379
+ const stopThrowing = handle.state.subscribe(() => { throw new Error('state subscriber failed') })
380
+ expect(handle.state.getSnapshot()).toBeUndefined()
381
+
382
+ const loop = handle.start({}, {
383
+ backoffBaseMs: 10,
384
+ backoffFactor: 2,
385
+ backoffMaxMs: 80,
386
+ generationReadyTimeoutMs: 500,
387
+ })
388
+ try {
389
+ await vi.waitFor(() => { expect(handle.state.getSnapshot()).toBe('connected') })
390
+ const connected = handle.state.getSnapshot()
391
+ expect(handle.state.getSnapshot()).toBe(connected)
392
+ generation.end()
393
+ await vi.waitFor(() => {
394
+ expect(snapshots).toEqual([
395
+ 'connected',
396
+ 'connecting',
397
+ 'connected',
398
+ ])
399
+ })
400
+ expect(errorSpy).toHaveBeenCalledWith('[connection] state listener threw:', expect.any(Error))
401
+ } finally {
402
+ unsubscribe()
403
+ stopThrowing()
404
+ loop.stop()
405
+ errorSpy.mockRestore()
406
+ }
407
+ expect(handle.state.getSnapshot()).toBeUndefined()
408
+ })
409
+
410
+ it('does not announce disconnection after a generation subscriber stops the loop', async () => {
411
+ ;(globalThis as Win).location = { hostname: 'localhost' }
412
+ const handle = await mount()
413
+ const generation = installGeneration(handle)
414
+ const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
415
+ let stoppedOnRetraction = false
416
+ const stopGeneration = handle.generation.subscribe(() => {
417
+ if (handle.generation.getSnapshot() !== undefined || owner.loop === undefined) return
418
+ stoppedOnRetraction = true
419
+ owner.loop.stop()
420
+ })
421
+ const states: ConnectionState[] = []
422
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
423
+ const loop = handle.start({
424
+ onStateChange: (state) => { states.push(state) },
425
+ }, { backoffBaseMs: 10, backoffFactor: 2, backoffMaxMs: 80, generationReadyTimeoutMs: 500 })
426
+ owner.loop = loop
427
+ try {
428
+ await vi.waitFor(() => {
429
+ expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
430
+ })
431
+ generation.end()
432
+
433
+ await vi.waitFor(() => { expect(stoppedOnRetraction).toBe(true) })
434
+ expect(handle.generation.getSnapshot()).toBeUndefined()
435
+ expect(states).toEqual(['connected'])
436
+ } finally {
437
+ stopGeneration()
438
+ loop.stop()
439
+ warnSpy.mockRestore()
440
+ }
441
+ })
442
+
443
+ it('carries RPC calls without requiring secure-context randomUUID', async () => {
444
+ ;(globalThis as Win).location = { hostname: 'localhost' }
445
+ vi.stubGlobal('crypto', {
446
+ getRandomValues(bytes: Uint8Array) {
447
+ return bytes.fill(0)
448
+ },
449
+ })
450
+ const handle = await mount()
451
+ const original = globalThis.fetch
452
+ const seen: { url: string; body: unknown }[] = []
453
+ globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => {
454
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
455
+ if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body')
456
+ const body = JSON.parse(init.body) as { rpcId: string }
457
+ seen.push({ url, body })
458
+ return Response.json({
459
+ type: 'server-response',
460
+ rpcId: body.rpcId,
461
+ result: { ok: true, value: { ref: 'goal-1' } },
462
+ })
463
+ }
464
+ try {
465
+ await expect(handle.rpc.call('/api', 'goals/create', { args: { agentId: 'agent-1' } }))
466
+ .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
467
+ } finally {
468
+ globalThis.fetch = original
469
+ vi.unstubAllGlobals()
470
+ }
471
+ expect(seen).toHaveLength(1)
472
+ expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create')
473
+ expect(seen[0]?.body).toMatchObject({
474
+ type: 'client-request',
475
+ rpcId: '00000000-0000-4000-8000-000000000000',
476
+ method: 'goals/create',
477
+ payload: { args: { agentId: 'agent-1' } },
478
+ })
479
+ })
480
+
481
+ it('uses an already decoded rpc carrier from the transport hooks instead of the HTTP caller', async () => {
482
+ ;(globalThis as Win).location = { hostname: 'preview.example' }
483
+ const rpc: ClientConnectionRpc = {
484
+ call: vi.fn(async (_channel: string, endpoint: string, payload: unknown) => ({ ok: true as const, value: { endpoint, payload } })),
485
+ open: vi.fn((_channel: string, endpoint: string) => (async function *(): AsyncGenerator { yield endpoint })()),
486
+ }
487
+ ;(globalThis as Win).__DSH_TRANSPORT__ = { rpc }
488
+ const handle = await mount()
489
+ expect(handle.rpc).toBe(rpc)
490
+ await expect(handle.rpc.call('/api', 'session/list', { args: [] })).resolves.toEqual({
491
+ ok: true, value: { endpoint: 'session/list', payload: { args: [] } },
492
+ })
493
+ })
494
+
495
+ it('exposes a worker-local Gateway stream through connection.rpc.open', async () => {
496
+ ;(globalThis as Win).location = { hostname: 'preview.example' }
497
+ const openStream = vi.fn<NonNullable<ClientTransportHooks['openStream']>>(
498
+ (endpoint, payload, signal) => (async function *(): AsyncGenerator {
499
+ signal.throwIfAborted()
500
+ yield { endpoint, payload }
501
+ })(),
502
+ )
503
+ ;(globalThis as Win).__DSH_TRANSPORT__ = {
504
+ fetch: vi.fn<RpcFetch>(),
505
+ openStream,
506
+ ownsHost: true,
507
+ }
508
+ const handle = await mount()
509
+ const abort = new AbortController()
510
+ const open = handle.rpc.open
511
+ if (open === undefined) throw new Error('worker-local stream carrier was not installed')
512
+
513
+ const values = []
514
+ for await (const value of open('/api', 'session/follow', { args: { sessionId: 'session-1' } }, abort.signal)) {
515
+ values.push(value)
516
+ }
517
+ expect(values).toEqual([{
518
+ endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } },
519
+ }])
520
+ expect(openStream).toHaveBeenCalledWith(
521
+ 'session/follow',
522
+ { args: { sessionId: 'session-1' } },
523
+ abort.signal,
524
+ )
525
+ expect(handle.isLoopback).toBe(true)
526
+ expect(() => open('/rpc', 'session/follow', {}, abort.signal))
527
+ .toThrow('worker-local streams require the /api channel')
528
+ expect(() => open('/api/path', 'session/follow', {}, abort.signal))
529
+ .toThrow('invalid RPC target')
530
+ })
531
+
532
+ it('validates generic RPC transport failures, correlation, and targets', async () => {
533
+ ;(globalThis as Win).location = {
534
+ hostname: 'harness.example', origin: 'https://harness.example',
535
+ }
536
+ const handle = await mount()
537
+ const original = globalThis.fetch
538
+ const abort = new AbortController()
539
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 }))
540
+ try {
541
+ await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal))
542
+ .rejects.toThrow('HTTP 503')
543
+ expect(globalThis.fetch).toHaveBeenCalledWith(
544
+ new URL('https://harness.example/api/goals/create'),
545
+ expect.objectContaining({ signal: abort.signal }),
546
+ )
547
+
548
+ ;(globalThis as Win).location = { hostname: 'localhost', origin: 'null' }
549
+ globalThis.fetch = vi.fn().mockResolvedValue(Response.json({
550
+ type: 'server-response',
551
+ rpcId: 'different-rpc',
552
+ result: { ok: true, value: null },
553
+ }))
554
+ await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch')
555
+ const fetch = vi.mocked(globalThis.fetch)
556
+ expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create'))
557
+ expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal')
558
+
559
+ const respond = (result: unknown): void => {
560
+ globalThis.fetch = async (_input: URL | RequestInfo, init?: RequestInit) => {
561
+ if (typeof init?.body !== 'string') throw new TypeError('expected a JSON request body')
562
+ const request = JSON.parse(init.body) as { rpcId: string }
563
+ return Response.json({ type: 'server-response', rpcId: request.rpcId, result })
564
+ }
565
+ }
566
+ for (const envelope of [
567
+ null,
568
+ { type: 'other', rpcId: 'rpc', result: { ok: true } },
569
+ { type: 'server-response', rpcId: 1, result: { ok: true } },
570
+ ]) {
571
+ globalThis.fetch = vi.fn().mockResolvedValue(Response.json(envelope))
572
+ await expect(handle.rpc.call('/api', 'goals/create', {}))
573
+ .rejects.toThrow('invalid server-response envelope')
574
+ }
575
+
576
+ respond(null)
577
+ await expect(handle.rpc.call('/api', 'goals/create', {}))
578
+ .rejects.toThrow('invalid server-response result')
579
+ respond({ ok: 'yes' })
580
+ await expect(handle.rpc.call('/api', 'goals/create', {}))
581
+ .rejects.toThrow('invalid server-response result')
582
+ respond({ ok: false, error: null })
583
+ await expect(handle.rpc.call('/api', 'goals/create', {}))
584
+ .rejects.toThrow('invalid server-response result')
585
+
586
+ for (const error of [
587
+ { code: 1, message: 'failed', details: {} },
588
+ { code: 'failed', message: 1, details: {} },
589
+ { code: 'failed', message: 'failed', details: [] },
590
+ ]) {
591
+ respond({ ok: false, error })
592
+ await expect(handle.rpc.call('/api', 'goals/create', {}))
593
+ .rejects.toThrow('invalid server-response failure')
594
+ }
595
+ respond({
596
+ ok: false,
597
+ error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } },
598
+ })
599
+ await expect(handle.rpc.call('/api', 'goals/create', {})).resolves.toEqual({
600
+ ok: false,
601
+ error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } },
602
+ })
603
+ } finally {
604
+ globalThis.fetch = original
605
+ }
606
+
607
+ for (const [channel, endpoint] of [
608
+ ['api2', 'goals/create'],
609
+ ['/api/path', 'goals/create'],
610
+ ['/api', ''],
611
+ ['/api', '.'],
612
+ ['/api', '..'],
613
+ ['/api', 'goals//create'],
614
+ ['/api', 'goals/create?unsafe'],
615
+ ] as const) {
616
+ await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target')
617
+ }
618
+ })
619
+
620
+ })
packages/client/connection/tests/connection.client.spec.ts ADDED
@@ -0,0 +1,800 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Connection generation readiness, loss, retry, and sink isolation. */
2
+
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import type { ConnectionGenerationSource, ConnectionState } from '../src/client/connection.ts'
5
+ import { ConnectionController } from '../src/client/connection.ts'
6
+ import { FakeGenerationSource } from './fake-generation.client.ts'
7
+
8
+ const FAST = { backoffBaseMs: 10, backoffFactor: 2, backoffMaxMs: 80, generationReadyTimeoutMs: 500 }
9
+
10
+ describe('connection lifecycle', () => {
11
+ it('announces connected with the Host facts from generation readiness', async () => {
12
+ const source = new FakeGenerationSource()
13
+ const homes: string[] = []
14
+ const controller = new ConnectionController(source.source, {
15
+ onConnected: (host) => { homes.push(host.home) },
16
+ }, FAST)
17
+ controller.start()
18
+ try {
19
+ await vi.waitFor(() => { expect(homes).toEqual(['/h']) })
20
+ } finally {
21
+ controller.stop()
22
+ }
23
+ })
24
+
25
+ it('reconnects with a fresh generation when its source fails, and stop() ends the loop', async () => {
26
+ const source = new FakeGenerationSource()
27
+ let connected = 0
28
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
29
+ const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST)
30
+ controller.start()
31
+ try {
32
+ await vi.waitFor(() => { expect(connected).toBe(1) })
33
+ source.fail(new Error('stream torn'))
34
+ await vi.waitFor(() => { expect(connected).toBe(2) })
35
+ expect(source.activeCount).toBe(1)
36
+ } finally {
37
+ controller.stop()
38
+ warnSpy.mockRestore()
39
+ }
40
+ await vi.waitFor(() => { expect(source.activeCount).toBe(0) })
41
+ await new Promise(resolve => setTimeout(resolve, 40))
42
+ expect(source.activeCount).toBe(0)
43
+ })
44
+
45
+ it('keeps retrying at the jittered cap and recovers after a prolonged outage', async () => {
46
+ vi.useFakeTimers()
47
+ const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
48
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
49
+ const reconnectRequested = vi.fn()
50
+ let calls = 0
51
+ let available = false
52
+ const states: ConnectionState[] = []
53
+ const source: ConnectionGenerationSource = (signal, ready) => {
54
+ calls++
55
+ if (!available) return Promise.reject(new Error('offline'))
56
+ ready({ home: '/h' })
57
+ return new Promise<void>((resolve) => {
58
+ signal.addEventListener('abort', () => { resolve() }, { once: true })
59
+ })
60
+ }
61
+ const controller = new ConnectionController(source, {
62
+ onReconnectRequested: reconnectRequested,
63
+ onStateChange: state => states.push(state),
64
+ })
65
+ controller.start()
66
+ try {
67
+ await vi.advanceTimersByTimeAsync(0)
68
+ expect(calls).toBe(1)
69
+ expect(states).toEqual(['connecting'])
70
+
71
+ for (const [attempt, delay] of [250, 500, 1_000, 2_000, 4_000, 5_000].entries()) {
72
+ await vi.advanceTimersByTimeAsync(delay)
73
+ expect(calls).toBe(attempt + 2)
74
+ }
75
+
76
+ expect(reconnectRequested).toHaveBeenCalledTimes(6)
77
+ expect(warnSpy).toHaveBeenCalledTimes(6)
78
+ expect(warnSpy).toHaveBeenLastCalledWith('[connection] connection lost, retry #6')
79
+ expect(states).toEqual(['connecting'])
80
+ await vi.advanceTimersByTimeAsync(60_000)
81
+ expect(calls).toBe(19)
82
+ available = true
83
+ await vi.advanceTimersByTimeAsync(5_000)
84
+ expect(calls).toBe(20)
85
+ expect(states).toEqual(['connecting', 'connected'])
86
+ expect(reconnectRequested).toHaveBeenCalledTimes(19)
87
+ } finally {
88
+ controller.stop()
89
+ randomSpy.mockRestore()
90
+ warnSpy.mockRestore()
91
+ vi.useRealTimers()
92
+ }
93
+ })
94
+
95
+ it('continues retrying at a fixed cap when the growth factor is one', async () => {
96
+ vi.useFakeTimers()
97
+ const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
98
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
99
+ const states: ConnectionState[] = []
100
+ let calls = 0
101
+ const controller = new ConnectionController(() => {
102
+ calls++
103
+ return Promise.reject(new Error('offline'))
104
+ }, {
105
+ onStateChange: state => states.push(state),
106
+ }, {
107
+ backoffBaseMs: 10,
108
+ backoffFactor: 1,
109
+ backoffMaxMs: 80,
110
+ generationReadyTimeoutMs: 500,
111
+ })
112
+ controller.start()
113
+ try {
114
+ await vi.advanceTimersByTimeAsync(5)
115
+ expect(calls).toBe(2)
116
+ expect(states).toEqual(['connecting'])
117
+ await vi.advanceTimersByTimeAsync(1_000)
118
+ expect(calls).toBe(202)
119
+ } finally {
120
+ controller.stop()
121
+ randomSpy.mockRestore()
122
+ warnSpy.mockRestore()
123
+ vi.useRealTimers()
124
+ }
125
+ })
126
+
127
+ it('interrupts the retry delay when a reconnect is requested', async () => {
128
+ vi.useFakeTimers()
129
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
130
+ const reconnectRequested = vi.fn()
131
+ let calls = 0
132
+ const source: ConnectionGenerationSource = (signal, ready) => {
133
+ calls++
134
+ if (calls === 1) return Promise.reject(new Error('offline'))
135
+ ready({ home: '/h' })
136
+ return new Promise<void>((resolve) => {
137
+ signal.addEventListener('abort', () => { resolve() }, { once: true })
138
+ })
139
+ }
140
+ const controller = new ConnectionController(source, { onReconnectRequested: reconnectRequested })
141
+ controller.start()
142
+ try {
143
+ await vi.advanceTimersByTimeAsync(0)
144
+ expect(calls).toBe(1)
145
+ controller.reconnect()
146
+ await vi.advanceTimersByTimeAsync(0)
147
+ expect(calls).toBe(2)
148
+ expect(reconnectRequested).toHaveBeenCalledOnce()
149
+ } finally {
150
+ controller.stop()
151
+ controller.reconnect()
152
+ warnSpy.mockRestore()
153
+ vi.useRealTimers()
154
+ }
155
+ })
156
+
157
+ it('pauses retries while offline and restarts the base delay after each recovery', async () => {
158
+ vi.useFakeTimers()
159
+ const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
160
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
161
+ const states: ConnectionState[] = []
162
+ let calls = 0
163
+ let active = 0
164
+ let maxActive = 0
165
+ const source: ConnectionGenerationSource = (signal, ready) => new Promise<void>((resolve) => {
166
+ calls++
167
+ active++
168
+ maxActive = Math.max(maxActive, active)
169
+ ready({ home: '/h' })
170
+ signal.addEventListener('abort', () => {
171
+ active--
172
+ resolve()
173
+ }, { once: true })
174
+ })
175
+ const controller = new ConnectionController(source, {
176
+ onStateChange: state => states.push(state),
177
+ })
178
+ controller.start()
179
+ try {
180
+ await vi.advanceTimersByTimeAsync(0)
181
+ expect(calls).toBe(1)
182
+ expect(states).toEqual(['connected'])
183
+
184
+ controller.setNetworkAvailable(false)
185
+ controller.setNetworkAvailable(false)
186
+ expect(states.at(-1)).toBe('disconnected')
187
+ await vi.advanceTimersByTimeAsync(60_000)
188
+ expect(calls).toBe(1)
189
+ expect(active).toBe(0)
190
+
191
+ controller.setNetworkAvailable(true)
192
+ controller.setNetworkAvailable(true)
193
+ expect(states.at(-1)).toBe('connecting')
194
+ await vi.advanceTimersByTimeAsync(125)
195
+ controller.setNetworkAvailable(false)
196
+ await vi.advanceTimersByTimeAsync(60_000)
197
+ expect(calls).toBe(1)
198
+
199
+ controller.setNetworkAvailable(true)
200
+ await vi.advanceTimersByTimeAsync(249)
201
+ expect(calls).toBe(1)
202
+ await vi.advanceTimersByTimeAsync(1)
203
+ expect(calls).toBe(2)
204
+ expect(active).toBe(1)
205
+ expect(maxActive).toBe(1)
206
+ expect(states).toEqual([
207
+ 'connected',
208
+ 'disconnected',
209
+ 'connecting',
210
+ 'disconnected',
211
+ 'connecting',
212
+ 'connected',
213
+ ])
214
+ expect(warnSpy).toHaveBeenCalledOnce()
215
+ expect(warnSpy).toHaveBeenCalledWith('[connection] connection lost, retry #1')
216
+ } finally {
217
+ controller.stop()
218
+ randomSpy.mockRestore()
219
+ warnSpy.mockRestore()
220
+ vi.useRealTimers()
221
+ }
222
+ })
223
+
224
+ it('stops an offline wait when its state sink stops the controller synchronously', async () => {
225
+ const source = vi.fn<ConnectionGenerationSource>(() => Promise.resolve())
226
+ const controller = new ConnectionController(source, {
227
+ onStateChange: () => { controller.stop() },
228
+ })
229
+ controller.setNetworkAvailable(false)
230
+ controller.start()
231
+ await Promise.resolve()
232
+ expect(source).not.toHaveBeenCalled()
233
+ })
234
+
235
+ it('allows one manual attempt while offline without starting automatic retries', async () => {
236
+ vi.useFakeTimers()
237
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
238
+ const states: ConnectionState[] = []
239
+ let calls = 0
240
+ const controller = new ConnectionController(() => {
241
+ calls++
242
+ return Promise.reject(new Error('offline'))
243
+ }, {
244
+ onStateChange: state => states.push(state),
245
+ })
246
+ controller.setNetworkAvailable(false)
247
+ controller.start()
248
+ try {
249
+ await vi.advanceTimersByTimeAsync(0)
250
+ expect(states).toEqual(['disconnected'])
251
+ expect(calls).toBe(0)
252
+
253
+ controller.reconnect()
254
+ expect(states.at(-1)).toBe('connecting')
255
+ await vi.advanceTimersByTimeAsync(0)
256
+ expect(calls).toBe(1)
257
+ expect(states.at(-1)).toBe('disconnected')
258
+ await vi.advanceTimersByTimeAsync(60_000)
259
+ expect(calls).toBe(1)
260
+ } finally {
261
+ controller.stop()
262
+ warnSpy.mockRestore()
263
+ vi.useRealTimers()
264
+ }
265
+ })
266
+
267
+ it('does not delay a reconnect requested synchronously from the connecting state sink', async () => {
268
+ vi.useFakeTimers()
269
+ const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
270
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
271
+ let calls = 0
272
+ let restart = true
273
+ const controller = new ConnectionController(() => {
274
+ calls++
275
+ return Promise.reject(new Error('offline'))
276
+ }, {
277
+ onStateChange: (state) => {
278
+ if (state !== 'connecting' || !restart) return
279
+ restart = false
280
+ controller.reconnect()
281
+ },
282
+ }, {
283
+ backoffBaseMs: 10,
284
+ backoffFactor: 2,
285
+ backoffMaxMs: 10,
286
+ generationReadyTimeoutMs: 500,
287
+ })
288
+ controller.start()
289
+ try {
290
+ await vi.advanceTimersByTimeAsync(0)
291
+ expect(calls).toBe(2)
292
+ expect(warnSpy.mock.calls.map(([message]) => String(message))).toEqual([
293
+ '[connection] connection lost, retry #1',
294
+ ])
295
+ } finally {
296
+ controller.stop()
297
+ randomSpy.mockRestore()
298
+ warnSpy.mockRestore()
299
+ vi.useRealTimers()
300
+ }
301
+ })
302
+
303
+ it.each([
304
+ {
305
+ label: 'manual reconnect',
306
+ stopState: 'connecting' as const,
307
+ interrupt: (controller: ConnectionController) => { controller.reconnect() },
308
+ },
309
+ {
310
+ label: 'browser going offline',
311
+ stopState: 'disconnected' as const,
312
+ interrupt: (controller: ConnectionController) => { controller.setNetworkAvailable(false) },
313
+ },
314
+ ])('honors a synchronous stop from the $label state sink', async ({ stopState, interrupt }) => {
315
+ const source = new FakeGenerationSource()
316
+ const controller = new ConnectionController(source.source, {
317
+ onStateChange: (state) => {
318
+ if (state === stopState) controller.stop()
319
+ },
320
+ }, FAST)
321
+ controller.start()
322
+ await vi.waitFor(() => { expect(source.activeCount).toBe(1) })
323
+ interrupt(controller)
324
+ await vi.waitFor(() => { expect(source.activeCount).toBe(0) })
325
+ })
326
+
327
+ it('stops when the physical-reconnect sink disposes the controller', async () => {
328
+ vi.useFakeTimers()
329
+ const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
330
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
331
+ let calls = 0
332
+ const reconnectRequested = vi.fn()
333
+ const controller = new ConnectionController(() => {
334
+ calls++
335
+ return Promise.reject(new Error('offline'))
336
+ }, {
337
+ onReconnectRequested: () => {
338
+ reconnectRequested()
339
+ controller.stop()
340
+ },
341
+ }, FAST)
342
+ controller.start()
343
+ try {
344
+ await vi.advanceTimersByTimeAsync(5)
345
+ expect(calls).toBe(1)
346
+ expect(reconnectRequested).toHaveBeenCalledOnce()
347
+ } finally {
348
+ controller.stop()
349
+ randomSpy.mockRestore()
350
+ warnSpy.mockRestore()
351
+ vi.useRealTimers()
352
+ }
353
+ })
354
+
355
+ it('stops before opening a retry when the connecting state sink disposes the controller', async () => {
356
+ vi.useFakeTimers()
357
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
358
+ let calls = 0
359
+ const controller = new ConnectionController(() => {
360
+ calls++
361
+ return Promise.reject(new Error('offline'))
362
+ }, {
363
+ onStateChange: (state) => {
364
+ if (state === 'connecting') controller.stop()
365
+ },
366
+ })
367
+ controller.start()
368
+ try {
369
+ await vi.advanceTimersByTimeAsync(60_000)
370
+ expect(calls).toBe(1)
371
+ expect(warnSpy).not.toHaveBeenCalled()
372
+ } finally {
373
+ controller.stop()
374
+ warnSpy.mockRestore()
375
+ vi.useRealTimers()
376
+ }
377
+ })
378
+
379
+ it('restarts an active retry immediately and resets its attempt number', async () => {
380
+ vi.useFakeTimers()
381
+ const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
382
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
383
+ const reconnectRequested = vi.fn()
384
+ const states: ConnectionState[] = []
385
+ let calls = 0
386
+ const source: ConnectionGenerationSource = (signal) => {
387
+ calls++
388
+ if (calls <= 2) return Promise.reject(new Error('offline'))
389
+ return new Promise<void>((resolve) => {
390
+ signal.addEventListener('abort', () => { resolve() }, { once: true })
391
+ })
392
+ }
393
+ const controller = new ConnectionController(source, {
394
+ onReconnectRequested: reconnectRequested,
395
+ onStateChange: state => states.push(state),
396
+ }, FAST)
397
+ controller.start()
398
+ try {
399
+ await vi.advanceTimersByTimeAsync(20)
400
+ expect(calls).toBe(3)
401
+ expect(states.at(-1)).toBe('connecting')
402
+
403
+ controller.reconnect()
404
+ await vi.advanceTimersByTimeAsync(0)
405
+ expect(calls).toBe(4)
406
+ expect(states.at(-1)).toBe('connecting')
407
+ expect(reconnectRequested).toHaveBeenCalledTimes(3)
408
+ expect(warnSpy.mock.calls.map(([message]) => String(message))).toEqual([
409
+ '[connection] connection lost, retry #1',
410
+ '[connection] connection lost, retry #2',
411
+ '[connection] connection lost, retry #1',
412
+ ])
413
+ } finally {
414
+ controller.stop()
415
+ randomSpy.mockRestore()
416
+ warnSpy.mockRestore()
417
+ vi.useRealTimers()
418
+ }
419
+ })
420
+
421
+ it('stops while an automatic retry delay is pending', async () => {
422
+ vi.useFakeTimers()
423
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
424
+ let calls = 0
425
+ const controller = new ConnectionController(() => {
426
+ calls++
427
+ return Promise.reject(new Error('offline'))
428
+ })
429
+ controller.start()
430
+ try {
431
+ await vi.advanceTimersByTimeAsync(0)
432
+ expect(calls).toBe(1)
433
+ controller.stop()
434
+ await vi.advanceTimersByTimeAsync(2_000)
435
+ expect(calls).toBe(1)
436
+ } finally {
437
+ controller.stop()
438
+ warnSpy.mockRestore()
439
+ vi.useRealTimers()
440
+ }
441
+ })
442
+
443
+ it('replaces an active generation immediately when reconnect is requested', async () => {
444
+ const source = new FakeGenerationSource()
445
+ const reconnectRequested = vi.fn()
446
+ let connected = 0
447
+ const controller = new ConnectionController(source.source, {
448
+ onConnected: () => { connected++ },
449
+ onReconnectRequested: reconnectRequested,
450
+ }, { backoffBaseMs: 60_000, backoffFactor: 2, backoffMaxMs: 120_000, generationReadyTimeoutMs: 500 })
451
+ controller.start()
452
+ try {
453
+ await vi.waitFor(() => { expect(connected).toBe(1) })
454
+ controller.reconnect()
455
+ await vi.waitFor(() => { expect(connected).toBe(2) })
456
+ expect(reconnectRequested).toHaveBeenCalledOnce()
457
+ expect(source.activeCount).toBe(1)
458
+ } finally {
459
+ controller.stop()
460
+ }
461
+ })
462
+
463
+ it('isolates a connected sink exception from the generation', async () => {
464
+ const source = new FakeGenerationSource()
465
+ let connected = 0
466
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
467
+ const controller = new ConnectionController(source.source, {
468
+ onConnected: () => {
469
+ connected++
470
+ throw new Error('business layer bug')
471
+ },
472
+ }, FAST)
473
+ controller.start()
474
+ try {
475
+ await vi.waitFor(() => { expect(connected).toBe(1) })
476
+ expect(source.activeCount).toBe(1)
477
+ expect(errorSpy).toHaveBeenCalledWith('[connection] connection sink threw:', expect.any(Error))
478
+ } finally {
479
+ controller.stop()
480
+ errorSpy.mockRestore()
481
+ }
482
+ })
483
+
484
+ it('holds onConnected until the incremental source reports ready', async () => {
485
+ const source = new FakeGenerationSource()
486
+ source.holdReady = true
487
+ let connected = 0
488
+ const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST)
489
+ controller.start()
490
+ try {
491
+ await vi.waitFor(() => { expect(source.activeCount).toBe(1) })
492
+ await new Promise(resolve => setTimeout(resolve, 30))
493
+ expect(connected).toBe(0)
494
+ source.releaseReady()
495
+ await vi.waitFor(() => { expect(connected).toBe(1) })
496
+ } finally {
497
+ controller.stop()
498
+ }
499
+ })
500
+
501
+ it('accepts only the first readiness report from one generation', async () => {
502
+ const homes: string[] = []
503
+ const source: ConnectionGenerationSource = (signal, ready) => {
504
+ ready({ home: '/first' })
505
+ ready({ home: '/duplicate' })
506
+ return new Promise<void>((resolve) => {
507
+ signal.addEventListener('abort', () => { resolve() }, { once: true })
508
+ })
509
+ }
510
+ const controller = new ConnectionController(source, {
511
+ onConnected: (host) => { homes.push(host.home) },
512
+ }, FAST)
513
+ controller.start()
514
+ try {
515
+ await vi.waitFor(() => { expect(homes).toEqual(['/first']) })
516
+ } finally {
517
+ controller.stop()
518
+ }
519
+ })
520
+
521
+ it('does not announce readiness after a stop queued from the ready callback', async () => {
522
+ const owner: { controller?: ConnectionController } = {}
523
+ let sourceCalls = 0
524
+ const connected = vi.fn()
525
+ const source: ConnectionGenerationSource = (signal, ready) => new Promise<void>((resolve) => {
526
+ sourceCalls++
527
+ ready({ home: '/h' })
528
+ queueMicrotask(() => { owner.controller?.stop() })
529
+ signal.addEventListener('abort', () => { resolve() }, { once: true })
530
+ })
531
+ const controller = new ConnectionController(source, { onConnected: connected }, FAST)
532
+ owner.controller = controller
533
+ controller.start()
534
+ await vi.waitFor(() => { expect(sourceCalls).toBe(1) })
535
+ expect(connected).not.toHaveBeenCalled()
536
+ })
537
+
538
+ it('rejects a generation whose source ends during readiness and retries', async () => {
539
+ const source = new FakeGenerationSource()
540
+ source.holdReady = true
541
+ const states: ConnectionState[] = []
542
+ let connected = 0
543
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
544
+ const controller = new ConnectionController(source.source, {
545
+ onConnected: () => { connected++ },
546
+ onStateChange: state => states.push(state),
547
+ }, FAST)
548
+ controller.start()
549
+ try {
550
+ await vi.waitFor(() => { expect(source.activeCount).toBe(1) })
551
+ source.holdReady = false
552
+ source.end()
553
+ await vi.waitFor(() => { expect(connected).toBe(1) })
554
+ expect(states).toEqual(['connecting', 'connected'])
555
+ } finally {
556
+ controller.stop()
557
+ warnSpy.mockRestore()
558
+ }
559
+ })
560
+
561
+ it.each([
562
+ { label: 'ends normally', fail: () => Promise.resolve() },
563
+ {
564
+ label: 'rejects with a non-Error reason',
565
+ // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test
566
+ fail: () => Promise.reject('fixture offline'),
567
+ },
568
+ ])('retries when the generation source $label before reporting ready', async ({ fail }) => {
569
+ let sourceCalls = 0
570
+ let connected = 0
571
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
572
+ const source: ConnectionGenerationSource = (signal, ready) => {
573
+ sourceCalls++
574
+ if (sourceCalls === 1) return fail()
575
+ ready({ home: '/h' })
576
+ return new Promise<void>((resolve) => {
577
+ signal.addEventListener('abort', () => { resolve() }, { once: true })
578
+ })
579
+ }
580
+ const controller = new ConnectionController(source, { onConnected: () => { connected++ } }, FAST)
581
+ controller.start()
582
+ try {
583
+ await vi.waitFor(() => { expect(sourceCalls).toBe(2) })
584
+ await vi.waitFor(() => { expect(connected).toBe(1) })
585
+ } finally {
586
+ controller.stop()
587
+ warnSpy.mockRestore()
588
+ }
589
+ })
590
+
591
+ it('accepts a slow Host after the warning and clears both readiness timers', async () => {
592
+ vi.useFakeTimers()
593
+ const source = new FakeGenerationSource()
594
+ source.holdReady = true
595
+ let connected = 0
596
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
597
+ const controller = new ConnectionController(
598
+ source.source,
599
+ { onConnected: () => { connected++ } },
600
+ { ...FAST, generationReadyWarnMs: 20, generationReadyTimeoutMs: 100 },
601
+ )
602
+ controller.start()
603
+ try {
604
+ await vi.advanceTimersByTimeAsync(0)
605
+ expect(source.activeCount).toBe(1)
606
+ await vi.advanceTimersByTimeAsync(20)
607
+ expect(connected).toBe(0)
608
+ expect(source.activeCount).toBe(1)
609
+ expect(warnSpy).toHaveBeenCalledWith('[connection] generation is still not ready after 20ms')
610
+ source.releaseReady()
611
+ await vi.advanceTimersByTimeAsync(100)
612
+ expect(connected).toBe(1)
613
+ expect(source.activeCount).toBe(1)
614
+ expect(vi.getTimerCount()).toBe(0)
615
+ expect(warnSpy).toHaveBeenCalledTimes(1)
616
+ } finally {
617
+ controller.stop()
618
+ warnSpy.mockRestore()
619
+ vi.useRealTimers()
620
+ }
621
+ })
622
+
623
+ it.each([20, 100, 200])('cancels an unready generation with warn=%i ms, waits for cleanup, and ignores late ready', async (generationReadyWarnMs) => {
624
+ vi.useFakeTimers()
625
+ const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
626
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
627
+ const cleanup = Promise.withResolvers<undefined>()
628
+ const signals: AbortSignal[] = []
629
+ const report: Array<(host: { home: string }) => void> = []
630
+ const connected = vi.fn()
631
+ const states: ConnectionState[] = []
632
+ const source: ConnectionGenerationSource = async (signal, ready) => {
633
+ signals.push(signal)
634
+ report.push(ready)
635
+ await new Promise<void>((resolve) => {
636
+ signal.addEventListener('abort', () => { resolve() }, { once: true })
637
+ })
638
+ await cleanup.promise
639
+ }
640
+ const controller = new ConnectionController(source, {
641
+ onConnected: connected,
642
+ onStateChange: state => states.push(state),
643
+ }, { ...FAST, generationReadyWarnMs, generationReadyTimeoutMs: 100 })
644
+ controller.start()
645
+ try {
646
+ await vi.advanceTimersByTimeAsync(100)
647
+ expect(signals[0]?.aborted).toBe(true)
648
+ expect(signals[0]?.reason).toMatchObject({ message: 'connection generation was not ready within 100ms' })
649
+ const warnings = generationReadyWarnMs <= 100
650
+ ? [[`[connection] generation is still not ready after ${String(generationReadyWarnMs)}ms`]]
651
+ : []
652
+ warnings.push(['[connection] connection generation was not ready within 100ms; cancelling generation'])
653
+ expect(warnSpy.mock.calls).toEqual(warnings)
654
+ report[0]!({ home: '/stale' })
655
+ await vi.advanceTimersByTimeAsync(1_000)
656
+ expect(signals).toHaveLength(1)
657
+ expect(connected).not.toHaveBeenCalled()
658
+ expect(warnSpy.mock.calls).toEqual(warnings)
659
+ cleanup.resolve(undefined)
660
+ await vi.advanceTimersByTimeAsync(5)
661
+ expect(signals).toHaveLength(2)
662
+ report[1]!({ home: '/fresh' })
663
+ await vi.advanceTimersByTimeAsync(0)
664
+ expect(connected).toHaveBeenCalledExactlyOnceWith({ home: '/fresh' })
665
+ expect(states).toEqual(['connecting', 'connected'])
666
+ expect(vi.getTimerCount()).toBe(0)
667
+ } finally {
668
+ controller.stop()
669
+ cleanup.resolve(undefined)
670
+ await vi.advanceTimersByTimeAsync(0)
671
+ randomSpy.mockRestore()
672
+ warnSpy.mockRestore()
673
+ vi.useRealTimers()
674
+ }
675
+ })
676
+
677
+ it.each(['stop', 'reconnect', 'failure'] as const)('clears a pending handshake deadline on %s', async (action) => {
678
+ vi.useFakeTimers()
679
+ const source = new FakeGenerationSource()
680
+ source.holdReady = true
681
+ const connected = vi.fn()
682
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
683
+ const controller = new ConnectionController(source.source, { onConnected: connected }, {
684
+ ...FAST, generationReadyWarnMs: 20, generationReadyTimeoutMs: 100,
685
+ })
686
+ controller.start()
687
+ try {
688
+ await vi.advanceTimersByTimeAsync(10)
689
+ source.holdReady = false
690
+ if (action === 'failure') source.fail(new Error('carrier failed'))
691
+ else controller[action]()
692
+ await vi.advanceTimersByTimeAsync(0)
693
+ source.releaseReady()
694
+ await vi.advanceTimersByTimeAsync(200)
695
+ expect(source.activeCount).toBe(action === 'stop' ? 0 : 1)
696
+ expect(connected).toHaveBeenCalledTimes(action === 'stop' ? 0 : 1)
697
+ expect(vi.getTimerCount()).toBe(0)
698
+ expect(warnSpy.mock.calls.some(([message]) => String(message).includes('still not ready'))).toBe(false)
699
+ expect(warnSpy.mock.calls.some(([message]) => String(message).includes('cancelling generation'))).toBe(false)
700
+ } finally {
701
+ controller.stop()
702
+ await vi.advanceTimersByTimeAsync(0)
703
+ warnSpy.mockRestore()
704
+ vi.useRealTimers()
705
+ }
706
+ })
707
+
708
+ it('emits the disconnected, retry-attempt, and connected transitions', async () => {
709
+ const source = new FakeGenerationSource()
710
+ const states: ConnectionState[] = []
711
+ let connected = 0
712
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
713
+ const controller = new ConnectionController(source.source, {
714
+ onConnected: () => { connected++ },
715
+ onStateChange: state => states.push(state),
716
+ }, FAST)
717
+ controller.start()
718
+ try {
719
+ await vi.waitFor(() => { expect(connected).toBe(1) })
720
+ expect(states).toEqual(['connected'])
721
+ source.fail(new Error('torn'))
722
+ await vi.waitFor(() => { expect(connected).toBe(2) })
723
+ expect(states).toEqual(['connected', 'connecting', 'connected'])
724
+ } finally {
725
+ controller.stop()
726
+ warnSpy.mockRestore()
727
+ }
728
+ })
729
+
730
+ it('does not announce a generation stopped synchronously by its connected state sink', async () => {
731
+ const source = new FakeGenerationSource()
732
+ const states: ConnectionState[] = []
733
+ let connected = 0
734
+ const controller = new ConnectionController(source.source, {
735
+ onConnected: () => { connected++ },
736
+ onStateChange: (state) => {
737
+ states.push(state)
738
+ if (state === 'connected') controller.stop()
739
+ },
740
+ }, FAST)
741
+
742
+ controller.start()
743
+ await vi.waitFor(() => { expect(states).toEqual(['connected']) })
744
+ await vi.waitFor(() => { expect(source.activeCount).toBe(0) })
745
+ expect(connected).toBe(0)
746
+ })
747
+
748
+ it('keeps one connecting state across consecutive retry attempts', async () => {
749
+ let sourceCalls = 0
750
+ const states: ConnectionState[] = []
751
+ let connected = 0
752
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
753
+ const source: ConnectionGenerationSource = (signal, ready) => {
754
+ sourceCalls++
755
+ if (sourceCalls <= 2) return Promise.reject(new Error('down'))
756
+ ready({ home: '/h' })
757
+ return new Promise<void>((resolve) => {
758
+ signal.addEventListener('abort', () => { resolve() }, { once: true })
759
+ })
760
+ }
761
+ const controller = new ConnectionController(source, {
762
+ onConnected: () => { connected++ },
763
+ onStateChange: state => states.push(state),
764
+ }, FAST)
765
+ controller.start()
766
+ try {
767
+ await vi.waitFor(() => { expect(sourceCalls).toBe(3) })
768
+ await vi.waitFor(() => { expect(connected).toBe(1) })
769
+ expect(states).toEqual(['connecting', 'connected'])
770
+ } finally {
771
+ controller.stop()
772
+ warnSpy.mockRestore()
773
+ }
774
+ })
775
+
776
+ it('runs with no sinks at all', async () => {
777
+ const source = new FakeGenerationSource()
778
+ const controller = new ConnectionController(source.source, {}, FAST)
779
+ controller.start()
780
+ try {
781
+ await vi.waitFor(() => { expect(source.activeCount).toBe(1) })
782
+ } finally {
783
+ controller.stop()
784
+ }
785
+ })
786
+
787
+ it('start() is idempotent', async () => {
788
+ const source = new FakeGenerationSource()
789
+ let connected = 0
790
+ const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST)
791
+ controller.start()
792
+ controller.start()
793
+ try {
794
+ await vi.waitFor(() => { expect(connected).toBe(1) })
795
+ expect(source.activeCount).toBe(1)
796
+ } finally {
797
+ controller.stop()
798
+ }
799
+ })
800
+ })
packages/client/connection/tests/fake-generation.client.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Test-local programmable Connection generation source. */
2
+ import type { ConnectionGenerationSource } from '../src/client/connection.ts'
3
+
4
+ type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown }
5
+
6
+ interface StreamConnection {
7
+ feed(item: StreamItem): void
8
+ }
9
+
10
+ /** Hand-pumped generation source for Connection lifecycle tests. */
11
+ export class FakeGenerationSource {
12
+ private readonly connections: StreamConnection[] = []
13
+
14
+ /** When true, the source never reports ready. */
15
+ suppressReady = false
16
+
17
+ /** When true, ready callbacks remain parked until the test releases them. */
18
+ holdReady = false
19
+
20
+ private heldReady: Array<() => void> = []
21
+
22
+ /** Open one generation. */
23
+ readonly source: ConnectionGenerationSource = (signal, ready) => this.open(signal, ready)
24
+
25
+ /** Release every generation currently parked before readiness. */
26
+ releaseReady(): void {
27
+ const held = this.heldReady
28
+ this.heldReady = []
29
+ for (const fire of held) fire()
30
+ }
31
+
32
+ /** End every active generation normally. */
33
+ end(): void {
34
+ for (const connection of [...this.connections]) connection.feed({ kind: 'end' })
35
+ }
36
+
37
+ /** Fail every active generation. */
38
+ fail(error: unknown): void {
39
+ for (const connection of [...this.connections]) connection.feed({ kind: 'fail', error })
40
+ }
41
+
42
+ /** Number of currently active generations. */
43
+ get activeCount(): number {
44
+ return this.connections.length
45
+ }
46
+
47
+ private async open(
48
+ signal: AbortSignal,
49
+ onReady: (host: { readonly home: string }) => void,
50
+ ): Promise<void> {
51
+ const inbox: StreamItem[] = []
52
+ let wake: (() => void) | null = null
53
+ const connection: StreamConnection = {
54
+ feed: (item) => {
55
+ inbox.push(item)
56
+ wake?.()
57
+ },
58
+ }
59
+ this.connections.push(connection)
60
+ const ready = (): void => { onReady({ home: '/h' }) }
61
+ if (this.holdReady) this.heldReady.push(ready)
62
+ else if (!this.suppressReady) ready()
63
+ try {
64
+ while (!signal.aborted) {
65
+ while (inbox.length > 0) {
66
+ const item = inbox.shift() as StreamItem
67
+ if (item.kind === 'end') return
68
+ if (item.kind === 'fail') throw item.error
69
+ }
70
+ await new Promise<void>((resolve) => {
71
+ wake = resolve
72
+ signal.addEventListener('abort', () => { resolve() }, { once: true })
73
+ })
74
+ wake = null
75
+ }
76
+ } finally {
77
+ this.connections.splice(this.connections.indexOf(connection), 1)
78
+ }
79
+ }
80
+ }
packages/client/connection/tests/fetch-routes.host.spec.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Context } from '@deepseek-ai/cordis'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import type { BrowserAuth } from '../src/browser-auth.ts'
4
+ import { HostConnectionService } from '../src/rpc-host.ts'
5
+
6
+ async function mounted(): Promise<{
7
+ readonly connection: HostConnectionService
8
+ readonly dispose: () => Promise<void>
9
+ }> {
10
+ const ctx = new Context()
11
+ const fiber = ctx.plugin((pluginCtx) => {
12
+ new HostConnectionService(pluginCtx, [], {} as BrowserAuth)
13
+ })
14
+ await fiber.await()
15
+ return {
16
+ connection: ctx.get('connection') as HostConnectionService,
17
+ dispose: () => fiber.dispose(),
18
+ }
19
+ }
20
+
21
+ describe('Connection exact Fetch routes', () => {
22
+ it('dispatches owned methods and returns 404 for unclaimed requests', async () => {
23
+ const { connection, dispose: disposeFiber } = await mounted()
24
+ const route = vi.fn(async (request: Request) =>
25
+ Response.json({ query: new URL(request.url).searchParams.get('sessionId') }))
26
+ const dispose = connection.fetch.register({
27
+ path: '/api/session.export',
28
+ methods: ['GET', 'HEAD', 'POST'],
29
+ requestBody: 'streaming',
30
+ fetch: route,
31
+ })
32
+ const shared = connection.createSharedFetchHandler('/api')
33
+
34
+ const response = await shared.fetch(new Request(
35
+ 'http://host/api/session.export?sessionId=session-1',
36
+ ))
37
+ expect(shared.requestBodyMode({
38
+ method: 'POST', url: new URL('http://host/api/session.export'),
39
+ })).toBe('streaming')
40
+ expect(shared.requestBodyMode({
41
+ method: 'DELETE', url: new URL('http://host/api/session.export'),
42
+ })).toBe('buffered')
43
+ expect(response.status).toBe(200)
44
+ expect(await response.json()).toEqual({ query: 'session-1' })
45
+ expect(route).toHaveBeenCalledOnce()
46
+ const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' }))
47
+ expect(post.status).toBe(200)
48
+ expect(route).toHaveBeenCalledTimes(2)
49
+
50
+ await dispose()
51
+ const withdrawn = await shared.fetch(new Request('http://host/api/session.export'))
52
+ expect(withdrawn.status).toBe(404)
53
+ await disposeFiber()
54
+ })
55
+
56
+ it('rejects invalid and duplicate registrations', async () => {
57
+ const { connection, dispose: disposeFiber } = await mounted()
58
+ const fetch = async (): Promise<Response> => new Response()
59
+
60
+ expect(() => connection.fetch.register({ path: '/outside', methods: ['GET'], requestBody: 'buffered', fetch }))
61
+ .toThrow('invalid exact Fetch route')
62
+ expect(() => connection.fetch.register({ path: '/api/session.export', methods: [], requestBody: 'buffered', fetch }))
63
+ .toThrow('declares no methods')
64
+ expect(() => connection.fetch.register({
65
+ path: '/api/session.export', methods: ['GET', 'GET'], fetch,
66
+ requestBody: 'buffered',
67
+ })).toThrow('repeats a method')
68
+ const dispose = connection.fetch.register({
69
+ path: '/api/session.export', methods: ['GET'], fetch,
70
+ requestBody: 'buffered',
71
+ })
72
+ expect(() => connection.fetch.register({
73
+ path: '/api/session.export', methods: ['HEAD'], fetch,
74
+ requestBody: 'buffered',
75
+ })).toThrow('already registered')
76
+ await dispose()
77
+ expect(() => connection.fetch.register({
78
+ path: '/api/session.export', methods: ['HEAD'], fetch,
79
+ requestBody: 'buffered',
80
+ })).not.toThrow()
81
+ await disposeFiber()
82
+ })
83
+ })
packages/client/connection/tests/generation.client.spec.ts ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Context } from '@deepseek-ai/cordis'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import {
4
+ apply,
5
+ type ConnectionGenerationSource,
6
+ type ConnectionHandle,
7
+ } from '../src/client/index.ts'
8
+
9
+ type BrowserGlobal = {
10
+ location?: { hostname: string }
11
+ }
12
+
13
+ const contexts = new Set<Context>()
14
+
15
+ afterEach(async () => {
16
+ vi.restoreAllMocks()
17
+ delete (globalThis as BrowserGlobal).location
18
+ await Promise.all([...contexts].map(async ctx => ctx.fiber.dispose()))
19
+ contexts.clear()
20
+ })
21
+
22
+ async function mount(): Promise<ConnectionHandle> {
23
+ ;(globalThis as BrowserGlobal).location = { hostname: 'localhost' }
24
+ const ctx = new Context()
25
+ contexts.add(ctx)
26
+ await ctx.plugin({ apply, inject: [] })
27
+ const connection = ctx.get('connection') as ConnectionHandle | undefined
28
+ if (connection === undefined) throw new Error('plugin did not provide Connection')
29
+ return connection
30
+ }
31
+
32
+ describe('Connection generation facts', () => {
33
+ it('publishes ready-frame Host facts and retracts them when the loop stops', async () => {
34
+ const connection = await mount()
35
+ const source: ConnectionGenerationSource = (signal, ready) => {
36
+ ready({ home: '/home/from-ready' })
37
+ return new Promise<void>((resolve) => {
38
+ if (signal.aborted) resolve()
39
+ else signal.addEventListener('abort', () => { resolve() }, { once: true })
40
+ })
41
+ }
42
+ connection.registerGenerationSource(source)
43
+ const seen: Array<string | undefined> = []
44
+ const stopListening = connection.generation.subscribe(() => {
45
+ seen.push(connection.generation.getSnapshot()?.host.home)
46
+ })
47
+ const loop = connection.start({}, {
48
+ backoffBaseMs: 1,
49
+ backoffFactor: 2,
50
+ backoffMaxMs: 8,
51
+ generationReadyTimeoutMs: 100,
52
+ })
53
+
54
+ await vi.waitFor(() => {
55
+ expect(connection.generation.getSnapshot()).toEqual({
56
+ id: 1,
57
+ host: { home: '/home/from-ready' },
58
+ })
59
+ })
60
+ loop.stop()
61
+ expect(connection.generation.getSnapshot()).toBeUndefined()
62
+ expect(seen).toEqual(['/home/from-ready', undefined])
63
+ stopListening()
64
+ })
65
+ })
packages/client/connection/tests/http-bridge.host.spec.ts ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { EventEmitter } from 'node:events'
2
+ import { Readable } from 'node:stream'
3
+ import type { IncomingMessage, ServerResponse } from 'node:http'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { bridge } from '../src/http-bridge.ts'
6
+
7
+ describe('HTTP bridge abort', () => {
8
+ it('destroys a declared-oversize request instead of draining it', async () => {
9
+ const destroyed: true[] = []
10
+ const request = Readable.from([]) as unknown as IncomingMessage
11
+ Object.assign(request, {
12
+ url: '/api/session.prompt',
13
+ method: 'POST',
14
+ headers: { 'content-type': 'application/json', 'content-length': '999999' },
15
+ destroy: () => { destroyed.push(true) },
16
+ })
17
+ let status: number | undefined
18
+ let headers: unknown
19
+ const response = Object.assign(new EventEmitter(), {
20
+ writableEnded: false,
21
+ writeHead(code: number, values?: unknown) { status = code; headers = values; return this },
22
+ write() { return true },
23
+ end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
24
+ }) as unknown as ServerResponse
25
+
26
+ await bridge(request, response, {
27
+ requestBodyMode: () => 'buffered',
28
+ fetch: () => { throw new Error('a rejected request must never reach the handler') },
29
+ }, 1000)
30
+ // The socket must not stay parked draining a body the client can trickle
31
+ // at will after the rejection — same discipline as the chunked overrun.
32
+ expect(status).toBe(413)
33
+ expect(headers).toMatchObject({ connection: 'close' })
34
+ expect(destroyed).toHaveLength(1)
35
+ })
36
+
37
+ it('aborts a pending native picker request when the browser disconnects', async () => {
38
+ const body = JSON.stringify({
39
+ type: 'client-request', rpcId: 'picker-1', method: 'directoryPicker/pick', payload: { args: {} },
40
+ })
41
+ const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
42
+ Object.assign(request, {
43
+ url: '/api/directoryPicker/pick',
44
+ method: 'POST',
45
+ headers: { 'content-type': 'application/json' },
46
+ })
47
+
48
+ const response = Object.assign(new EventEmitter(), {
49
+ writableEnded: false,
50
+ writeHead() { return this },
51
+ write() { return true },
52
+ end() { this.writableEnded = true; return this },
53
+ }) as unknown as ServerResponse
54
+
55
+ let resolveStarted!: () => void
56
+ const started = new Promise<void>((resolve) => { resolveStarted = resolve })
57
+ let carrierSignal: AbortSignal | undefined
58
+ const pending = bridge(request, response, {
59
+ requestBodyMode: () => 'buffered',
60
+ fetch: async (input) => {
61
+ const fetchRequest = input
62
+ carrierSignal = fetchRequest.signal
63
+ resolveStarted()
64
+ if (!fetchRequest.signal.aborted) {
65
+ await new Promise<void>((resolve) => {
66
+ fetchRequest.signal.addEventListener('abort', () => { resolve() }, { once: true })
67
+ })
68
+ }
69
+ return Response.json({ aborted: fetchRequest.signal.aborted })
70
+ },
71
+ }, Number.MAX_SAFE_INTEGER)
72
+ await started
73
+ response.emit('close')
74
+ await pending
75
+ expect(carrierSignal?.aborted).toBe(true)
76
+ })
77
+
78
+ it('streams a declared 2.19 GiB request before the body ends and bypasses the JSON buffer cap', async () => {
79
+ const request = new Readable({ read() {} }) as unknown as IncomingMessage
80
+ Object.assign(request, {
81
+ url: '/api/session/uploadFileBinary?sessionId=s1',
82
+ method: 'POST',
83
+ headers: {
84
+ 'content-type': 'application/octet-stream',
85
+ 'content-length': String(Math.ceil(2.19 * 1024 ** 3)),
86
+ },
87
+ })
88
+ let status: number | undefined
89
+ const responseBytes: Uint8Array[] = []
90
+ const response = Object.assign(new EventEmitter(), {
91
+ writableEnded: false,
92
+ writeHead(code: number) { status = code; return this },
93
+ write(chunk: Uint8Array) { responseBytes.push(chunk); return true },
94
+ end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
95
+ }) as unknown as ServerResponse
96
+
97
+ let resolveStarted!: () => void
98
+ const started = new Promise<void>((resolve) => { resolveStarted = resolve })
99
+ const received: Uint8Array[] = []
100
+ const pending = bridge(request, response, {
101
+ requestBodyMode: () => 'streaming',
102
+ fetch: async (input) => {
103
+ resolveStarted()
104
+ if (input.body === null) throw new Error('streaming request lost its body')
105
+ for await (const chunk of input.body) received.push(chunk)
106
+ return new Response('stored')
107
+ },
108
+ }, 1)
109
+
110
+ await started
111
+ expect(received).toEqual([])
112
+ request.push(Buffer.from([1, 2]))
113
+ request.push(Buffer.from([3, 4]))
114
+ request.push(null)
115
+ await pending
116
+ expect(status).toBe(200)
117
+ expect(received).toEqual([Uint8Array.of(1, 2), Uint8Array.of(3, 4)])
118
+ expect(Buffer.concat(responseBytes).toString()).toBe('stored')
119
+ })
120
+
121
+ it('closes an unread streaming request after returning an early validation response', async () => {
122
+ const destroyed: true[] = []
123
+ const request = new Readable({ read() {} }) as unknown as IncomingMessage
124
+ Object.assign(request, {
125
+ url: '/api/session/uploadFileBinary',
126
+ method: 'POST',
127
+ headers: { 'content-type': 'application/json' },
128
+ destroy: () => { destroyed.push(true) },
129
+ })
130
+ let status: number | undefined
131
+ let headers: unknown
132
+ const response = Object.assign(new EventEmitter(), {
133
+ writableEnded: false,
134
+ writeHead(code: number, values?: unknown) { status = code; headers = values; return this },
135
+ write() { return true },
136
+ end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
137
+ }) as unknown as ServerResponse
138
+
139
+ await bridge(request, response, {
140
+ requestBodyMode: () => 'streaming',
141
+ fetch: () => Promise.resolve(new Response(null, { status: 415 })),
142
+ }, 1)
143
+ expect(status).toBe(415)
144
+ expect(headers).toMatchObject({ connection: 'close' })
145
+ expect(destroyed).toEqual([true])
146
+ })
147
+ })
packages/client/connection/tests/loopback-hostname.client.spec.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Shared loopback-hostname semantics for the Host fence and browser UI. */
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+ import { isLoopbackHostname } from '../src/loopback-hostname.ts'
5
+
6
+ describe('isLoopbackHostname', () => {
7
+ it('accepts localhost, IPv6 loopback, and the whole IPv4 127/8 block', () => {
8
+ for (const hostname of ['localhost', '[::1]', '127.0.0.1', '127.8.9.10', '127.255.255.255']) {
9
+ expect(isLoopbackHostname(hostname)).toBe(true)
10
+ }
11
+ })
12
+
13
+ it('refuses malformed and non-loopback hostnames', () => {
14
+ for (const hostname of ['remote.localhost', '::1', '128.0.0.1', '127.0.0', '127.0.0.256', '127.0.0.-1']) {
15
+ expect(isLoopbackHostname(hostname)).toBe(false)
16
+ }
17
+ })
18
+ })
packages/client/connection/tests/node-half.host.spec.ts ADDED
@@ -0,0 +1,565 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Node half: registers the /api prefix route bridging to the api gateway. */
2
+ import { EventEmitter } from 'node:events'
3
+ import { createServer, request as httpRequest } from 'node:http'
4
+ import { Readable } from 'node:stream'
5
+ import { Context } from '@deepseek-ai/cordis'
6
+ import { describe, expect, it } from 'vitest'
7
+ import type { AddressInfo } from 'node:net'
8
+ import type { IncomingMessage, ServerResponse } from 'node:http'
9
+ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
10
+ import type { IndexInjection, WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
11
+ import { API_PATH, RpcId, apply, inject, type ClientRequest, type ConnectionConfig, type HostConnectionHandle } from '../src/index.ts'
12
+ import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts'
13
+ import { provideBrowserCredentials } from './browser-credentials.ts'
14
+
15
+ /** Structural webServer fake recording both route registries. */
16
+ function fakeHttpServer(
17
+ routes: WebRoute[],
18
+ upgrades: WebUpgradeRoute[],
19
+ ): Pick<WebServer, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
20
+ return {
21
+ register(route) {
22
+ if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
23
+ throw new Error(`duplicate route ${route.path}`)
24
+ }
25
+ routes.push(route)
26
+ return () => { routes.splice(routes.indexOf(route), 1) }
27
+ },
28
+ registerUpgrade(route) {
29
+ upgrades.push(route)
30
+ return () => { upgrades.splice(upgrades.indexOf(route), 1) }
31
+ },
32
+ tapIndex: () => () => {},
33
+ port: 0,
34
+ }
35
+ }
36
+
37
+ /** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
38
+ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session.list`): IncomingMessage {
39
+ const request = Readable.from([]) as unknown as IncomingMessage
40
+ Object.assign(request, { url, method: 'GET', headers })
41
+ return request
42
+ }
43
+
44
+ /** JSON POST carrying a complete client-request envelope. */
45
+ function fakePost(headers: Record<string, string>, url: string, body: unknown): IncomingMessage {
46
+ const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage
47
+ Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } })
48
+ return request
49
+ }
50
+
51
+ /** Raw POST for malformed-body and media-type boundary cases. */
52
+ function fakeRawPost(headers: Record<string, string>, url: string, body: string): IncomingMessage {
53
+ const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
54
+ Object.assign(request, { url, method: 'POST', headers })
55
+ return request
56
+ }
57
+
58
+ /** Response recorder compatible with both the fence's short-circuit and the bridge. */
59
+ function fakeResponse(): {
60
+ response: ServerResponse
61
+ state: { status?: number; headers?: Record<string, string>; body?: unknown }
62
+ } {
63
+ const state: { status?: number; headers?: Record<string, string>; body?: unknown } = {}
64
+ const chunks: Buffer[] = []
65
+ const response = Object.assign(new EventEmitter(), {
66
+ writableEnded: false,
67
+ writeHead(value: number, headers?: Record<string, string>) {
68
+ state.status = value
69
+ if (headers !== undefined) state.headers = headers
70
+ return this
71
+ },
72
+ write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true },
73
+ end(this: { writableEnded: boolean }, value?: unknown) {
74
+ if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value))
75
+ else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies')
76
+ if (chunks.length > 0) state.body = Buffer.concat(chunks).toString()
77
+ this.writableEnded = true
78
+ return this
79
+ },
80
+ }) as unknown as ServerResponse
81
+ return { response, state }
82
+ }
83
+
84
+ async function mounted(config?: ConnectionConfig): Promise<{
85
+ ctx: Context
86
+ routes: WebRoute[]
87
+ upgrades: WebUpgradeRoute[]
88
+ connection: HostConnectionHandle
89
+ dispose: () => Promise<void>
90
+ }> {
91
+ const ctx = new Context()
92
+ const routes: WebRoute[] = []
93
+ const upgrades: WebUpgradeRoute[] = []
94
+ provideBrowserCredentials(ctx)
95
+ ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer)
96
+ const fiber = ctx.plugin({ inject: [...inject], apply }, config)
97
+ await fiber.await()
98
+ return {
99
+ ctx,
100
+ routes,
101
+ upgrades,
102
+ connection: ctx.get('connection') as HostConnectionHandle,
103
+ dispose: () => fiber.dispose(),
104
+ }
105
+ }
106
+
107
+ /** Exchange a service's process token for one authority-bound Cookie header. */
108
+ function browserCookie(connection: HostConnectionHandle, authority: string): string {
109
+ const url = new URL(connection.authenticatedUrl(`http://${authority}`))
110
+ const exchanged = fakeResponse()
111
+ connection.authorizeIndex(
112
+ fakeRequest({ host: authority }, `${url.pathname}${url.search}`),
113
+ exchanged.response,
114
+ )
115
+ const setCookie = exchanged.state.headers?.['set-cookie']
116
+ if (setCookie === undefined) throw new Error('browser token exchange did not set a cookie')
117
+ return setCookie.split(';', 1)[0]!
118
+ }
119
+
120
+ describe('connection node half', () => {
121
+ it('provides the carrier-neutral service without a Web server', async () => {
122
+ const ctx = new Context()
123
+ provideBrowserCredentials(ctx)
124
+ const fiber = ctx.plugin({ inject: [...inject], apply })
125
+ await fiber.await()
126
+ expect(ctx.get('connection')).toBeInstanceOf(Object)
127
+ await fiber.dispose()
128
+ })
129
+
130
+ it('injects validated browser recovery timing and withdraws it on disposal', async () => {
131
+ const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } })
132
+ try {
133
+ const rows: IndexInjection[] = []
134
+ ctx.emit('webserver/index-inject', rows)
135
+ expect(rows).toEqual([{
136
+ kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: {
137
+ backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000,
138
+ generationReadyWarnMs: 3_000, generationReadyTimeoutMs: 25_000,
139
+ },
140
+ }])
141
+ await dispose()
142
+ const after: IndexInjection[] = []
143
+ ctx.emit('webserver/index-inject', after)
144
+ expect(after).toEqual([])
145
+ } finally {
146
+ await dispose()
147
+ }
148
+ })
149
+
150
+ it.each([
151
+ { recovery: { backoffBaseMs: 0 }, error: /backoffBaseMs/ },
152
+ { recovery: { backoffFactor: NaN }, error: /backoffFactor.*finite/ },
153
+ ])('rejects invalid recovery timing before acquiring Host resources: $recovery', async ({ recovery, error }) => {
154
+ const ctx = new Context()
155
+ await expect(apply(ctx, { recovery })).rejects.toThrow(error)
156
+ expect(ctx.get('connection')).toBeUndefined()
157
+ })
158
+
159
+ it('reserves enough default carrier capacity for the 200 MiB image batch', () => {
160
+ expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBe(300 * 1024 * 1024)
161
+ expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBeGreaterThan(Math.ceil(200 * 1024 * 1024 * 4 / 3) + 1024 * 1024)
162
+ })
163
+
164
+ it('fails loud when the carrier cap cannot hold the configured image batch', async () => {
165
+ const ctx = new Context()
166
+ const routes: WebRoute[] = []
167
+ ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
168
+ ctx.provide('attachments', {
169
+ imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 },
170
+ } as AttachmentStore)
171
+ await expect(apply(ctx, { maxRequestBodyBytes: 1024 }))
172
+ .rejects.toThrow(/must be at least .* aggregate image limit/)
173
+ expect(routes).toHaveLength(0)
174
+ })
175
+
176
+ it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
177
+ const routes: WebRoute[] = []
178
+ const upgrades: WebUpgradeRoute[] = []
179
+ const ctx = new Context()
180
+ provideBrowserCredentials(ctx)
181
+ ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer)
182
+ const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
183
+ await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
184
+ expect(routes).toHaveLength(0)
185
+ expect(upgrades).toHaveLength(0)
186
+ })
187
+
188
+ it('registers only the HTTP route and removes it with the fiber', async () => {
189
+ const { routes, upgrades, dispose } = await mounted()
190
+ expect(routes).toHaveLength(1)
191
+ expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
192
+ expect(upgrades).toHaveLength(0)
193
+ await dispose()
194
+ expect(routes).toHaveLength(0)
195
+ expect(upgrades).toHaveLength(0)
196
+ })
197
+
198
+ it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
199
+ const { routes, dispose } = await mounted()
200
+ const { response, state } = fakeResponse()
201
+ await routes[0]!.handler(fakeRequest({
202
+ host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
203
+ }), response)
204
+ expect(state.status).toBe(403)
205
+ expect(state.body).toBe('forbidden')
206
+ await dispose()
207
+ })
208
+
209
+ it('requires the same browser session for every method on every trusted authority', async () => {
210
+ const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] })
211
+ const methods = [
212
+ 'session/openWorkspacePath',
213
+ 'llm/discoverModels', 'skills/list', 'settings/openAgentPresetDirectory',
214
+ ]
215
+ for (const method of methods) {
216
+ const denied = fakeResponse()
217
+ await routes[0]!.handler(fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`), denied.response)
218
+ expect([method, denied.state.status, denied.state.body]).toEqual([method, 401, 'unauthorized'])
219
+ }
220
+
221
+ const cookie = browserCookie(connection, 'harness.example')
222
+ for (const method of methods) {
223
+ const allowed = fakeResponse()
224
+ await routes[0]!.handler(
225
+ fakeRequest({ host: 'harness.example', cookie }, `${API_PATH}/${method}`),
226
+ allowed.response,
227
+ )
228
+ expect([method, allowed.state.status]).toEqual([method, 404])
229
+ }
230
+
231
+ const forged = fakeResponse()
232
+ await routes[0]!.handler(fakeRequest({ host: 'localhost:3080' }), forged.response)
233
+ expect(forged.state).toMatchObject({ status: 401, body: 'unauthorized' })
234
+ await dispose()
235
+ })
236
+
237
+ it('passes loopback and declared-authority requests through to the bridge', async () => {
238
+ const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
239
+ // Loopback, no browser markers (curl shape): the fence passes; the carrier
240
+ // answers 404 for a GET unary path — proof the bridge ran.
241
+ const loopback = fakeResponse()
242
+ await routes[0]!.handler(fakeRequest({
243
+ host: '127.0.0.1:3080',
244
+ cookie: browserCookie(connection, '127.0.0.1:3080'),
245
+ }), loopback.response)
246
+ expect(loopback.state.status).toBe(404)
247
+ // An all-interfaces composition derives port-less LAN IP literals, which
248
+ // pass markerless curl on any port.
249
+ const lan = fakeResponse()
250
+ await routes[0]!.handler(fakeRequest({
251
+ host: '192.168.1.5:3080',
252
+ cookie: browserCookie(connection, '192.168.1.5:3080'),
253
+ }), lan.response)
254
+ expect(lan.state.status).toBe(404)
255
+ // Declared public authority, same-origin browser shape.
256
+ const declared = fakeResponse()
257
+ await routes[0]!.handler(fakeRequest({
258
+ host: 'harness.example:3080',
259
+ origin: 'http://harness.example:3080',
260
+ 'sec-fetch-site': 'same-origin',
261
+ cookie: browserCookie(connection, 'harness.example:3080'),
262
+ }), declared.response)
263
+ expect(declared.state.status).toBe(404)
264
+ await dispose()
265
+ })
266
+
267
+ it('shares its configured trust and authentication policy with sibling routes', async () => {
268
+ const { connection, dispose } = await mounted({ trustedHosts: ['harness.example'] })
269
+ const loopback = fakeRequest({ host: '127.0.0.1:3080' })
270
+ const declared = fakeRequest({ host: 'harness.example' })
271
+
272
+ expect(connection.requestRejection(loopback)).toBe(401)
273
+ expect(connection.requestRejection(declared)).toBe(401)
274
+ expect(connection.requestRejection(fakeRequest({
275
+ host: 'harness.example',
276
+ cookie: browserCookie(connection, 'harness.example'),
277
+ }))).toBeUndefined()
278
+ await dispose()
279
+ })
280
+
281
+ it('provides a disposable dedicated RPC channel', async () => {
282
+ const ctx = new Context()
283
+ const routes: WebRoute[] = []
284
+ provideBrowserCredentials(ctx)
285
+ ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
286
+ const fiber = ctx.plugin({ inject: [...inject], apply })
287
+ await fiber.await()
288
+ expect(routes).toHaveLength(1)
289
+ expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
290
+
291
+ const connection = ctx.get('connection') as HostConnectionHandle
292
+ const calls: unknown[] = []
293
+ const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => {
294
+ calls.push({ endpoint, payload })
295
+ return { ok: true, value: { accepted: true } }
296
+ })
297
+ const route = routes.find(candidate => candidate.path === '/rpc')
298
+ expect(route).toBeDefined()
299
+
300
+ const request: ClientRequest = {
301
+ type: 'client-request',
302
+ rpcId: RpcId('rpc-dedicated'),
303
+ method: 'goals/create',
304
+ payload: { args: { agentId: 'agent-1' } },
305
+ }
306
+ const result = fakeResponse()
307
+ await route!.handler(fakePost({
308
+ host: '127.0.0.1:3080',
309
+ cookie: browserCookie(connection, '127.0.0.1:3080'),
310
+ }, '/rpc/goals/create', request), result.response)
311
+ expect(result.state.status).toBe(200)
312
+ expect(JSON.parse(String(result.state.body))).toEqual({
313
+ type: 'server-response',
314
+ rpcId: 'rpc-dedicated',
315
+ result: { ok: true, value: { accepted: true } },
316
+ })
317
+ expect(calls).toEqual([{
318
+ endpoint: 'goals/create',
319
+ payload: { args: { agentId: 'agent-1' } },
320
+ }])
321
+
322
+ expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null })))
323
+ .toThrow(/duplicate route/)
324
+ await remove()
325
+ expect(routes.map(candidate => candidate.path)).toEqual([API_PATH])
326
+ await fiber.dispose()
327
+ expect(routes).toHaveLength(0)
328
+ })
329
+
330
+ it('dispatches claimed /api endpoints and withdraws the claim', async () => {
331
+ const ctx = new Context()
332
+ const routes: WebRoute[] = []
333
+ provideBrowserCredentials(ctx)
334
+ ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
335
+ const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
336
+ await fiber.await()
337
+ const connection = ctx.get('connection') as HostConnectionHandle
338
+ const calls: unknown[] = []
339
+ const remove = connection.rpc.intercept(
340
+ '/api',
341
+ endpoint => endpoint === 'goals/create',
342
+ async (endpoint, payload) => {
343
+ calls.push({ endpoint, payload })
344
+ return { ok: true, value: { accepted: true } }
345
+ },
346
+ )
347
+ expect(() => connection.rpc.intercept(
348
+ '/api',
349
+ () => true,
350
+ async () => ({ ok: true, value: null }),
351
+ )).toThrow('already has an interceptor')
352
+ expect(() => connection.rpc.intercept(
353
+ '/rpc' as '/api',
354
+ () => true,
355
+ async () => ({ ok: true, value: null }),
356
+ )).toThrow('invalid shared RPC channel')
357
+ const route = routes.find(candidate => candidate.path === API_PATH)!
358
+ const request: ClientRequest = {
359
+ type: 'client-request',
360
+ rpcId: RpcId('rpc-shared'),
361
+ method: 'goals/create',
362
+ payload: { args: { agentId: 'agent-1' } },
363
+ }
364
+
365
+ const claimed = fakeResponse()
366
+ const loopbackCookie = browserCookie(connection, '127.0.0.1:3080')
367
+ await route.handler(fakePost({
368
+ host: '127.0.0.1:3080', cookie: loopbackCookie,
369
+ }, '/api/goals/create', request), claimed.response)
370
+ expect(JSON.parse(String(claimed.state.body))).toEqual({
371
+ type: 'server-response',
372
+ rpcId: 'rpc-shared',
373
+ result: { ok: true, value: { accepted: true } },
374
+ })
375
+ expect(calls).toEqual([{
376
+ endpoint: 'goals/create',
377
+ payload: { args: { agentId: 'agent-1' } },
378
+ }])
379
+
380
+ const denied = fakeResponse()
381
+ await route.handler(fakePost({ host: 'other.example' }, '/api/goals/create', request), denied.response)
382
+ expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
383
+ expect(calls).toHaveLength(1)
384
+
385
+ const unclaimed = fakeResponse()
386
+ await route.handler(fakeRequest({
387
+ host: '127.0.0.1:3080', cookie: loopbackCookie,
388
+ }, '/api/session.list'), unclaimed.response)
389
+ expect(unclaimed.state.status).toBe(404)
390
+
391
+ await remove()
392
+ const withdrawn = fakeResponse()
393
+ await route.handler(fakePost({
394
+ host: '127.0.0.1:3080', cookie: loopbackCookie,
395
+ }, '/api/goals/create', request), withdrawn.response)
396
+ expect(withdrawn.state.status).toBe(404)
397
+ expect(calls).toHaveLength(1)
398
+
399
+ const removeAuthenticated = connection.rpc.intercept(
400
+ '/api',
401
+ endpoint => endpoint === 'goals/create',
402
+ async () => ({ ok: true, value: null }),
403
+ )
404
+ const declared = fakeResponse()
405
+ await route.handler(fakePost({
406
+ host: 'harness.example',
407
+ cookie: browserCookie(connection, 'harness.example'),
408
+ }, '/api/goals/create', request), declared.response)
409
+ expect(declared.state.status).toBe(200)
410
+ await removeAuthenticated()
411
+ await fiber.dispose()
412
+ })
413
+
414
+ it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
415
+ const ctx = new Context()
416
+ const routes: WebRoute[] = []
417
+ provideBrowserCredentials(ctx)
418
+ ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
419
+ const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
420
+ await fiber.await()
421
+ const connection = ctx.get('connection') as HostConnectionHandle
422
+ const remove = connection.rpc.handle('/rpc', async (endpoint) => {
423
+ if (endpoint === 'fail') throw new Error('handler broke')
424
+ return { ok: true, value: null }
425
+ })
426
+ const route = routes.find(candidate => candidate.path === '/rpc')!
427
+ const harnessHeaders = {
428
+ host: 'harness.example',
429
+ cookie: browserCookie(connection, 'harness.example'),
430
+ }
431
+
432
+ const denied = fakeResponse()
433
+ await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response)
434
+ expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
435
+
436
+ const unauthenticated = fakeResponse()
437
+ await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {}), unauthenticated.response)
438
+ expect(unauthenticated.state).toMatchObject({ status: 401, body: 'unauthorized' })
439
+
440
+ const methodMismatch = fakeResponse()
441
+ await route.handler(fakePost(harnessHeaders, '/rpc/goals/create', {
442
+ type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
443
+ }), methodMismatch.response)
444
+ expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({
445
+ rpcId: 'rpc-bad',
446
+ result: { ok: false, error: { code: 'gateway/bad-request' } },
447
+ })
448
+
449
+ for (const [request, status] of [
450
+ [fakeRequest(harnessHeaders, '/rpc/goals/create'), 404],
451
+ [fakePost(harnessHeaders, '/outside/goals/create', {}), 404],
452
+ [fakePost(harnessHeaders, '/rpc/goals//create', {}), 404],
453
+ [fakeRawPost(harnessHeaders, '/rpc/goals/create', '{}'), 415],
454
+ [fakeRawPost({ ...harnessHeaders, 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415],
455
+ [fakeRawPost({ ...harnessHeaders, 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400],
456
+ ] as const) {
457
+ const response = fakeResponse()
458
+ await route.handler(request, response.response)
459
+ expect(response.state.status).toBe(status)
460
+ }
461
+
462
+ for (const [body, rpcId] of [
463
+ [{ rpcId: 'retained-id' }, 'retained-id'],
464
+ [{ rpcId: 42 }, 'invalid-request'],
465
+ [null, 'invalid-request'],
466
+ ] as const) {
467
+ const response = fakeResponse()
468
+ await route.handler(fakePost(harnessHeaders, '/rpc/goals/create', body), response.response)
469
+ expect(JSON.parse(String(response.state.body))).toMatchObject({
470
+ rpcId,
471
+ result: { ok: false, error: { code: 'gateway/bad-request' } },
472
+ })
473
+ }
474
+
475
+ const failed = fakeResponse()
476
+ await route.handler(fakePost(harnessHeaders, '/rpc/fail', {
477
+ type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {},
478
+ }), failed.response)
479
+ expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' })
480
+
481
+ expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null })))
482
+ .toThrow('invalid or reserved RPC channel')
483
+ expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null })))
484
+ .toThrow('invalid or reserved RPC channel')
485
+ await remove()
486
+ await fiber.dispose()
487
+ })
488
+ })
489
+
490
+ describe('connection node half over a real HTTP server', () => {
491
+ /** Serve the registered prefix route from a real server and return its port. */
492
+ async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
493
+ const server = createServer((request, response) => {
494
+ void routes[0]!.handler(request, response)
495
+ })
496
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
497
+ const address = server.address() as AddressInfo
498
+ return {
499
+ port: address.port,
500
+ close: () => new Promise<void>((resolve, reject) => {
501
+ server.close((error) => {
502
+ if (error === undefined || error === null) resolve()
503
+ else reject(error)
504
+ })
505
+ }),
506
+ }
507
+ }
508
+
509
+ /** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */
510
+ function call(port: number, method: string, host: string, cookie?: string): Promise<number> {
511
+ return new Promise((resolve, reject) => {
512
+ const request = httpRequest(
513
+ {
514
+ host: '127.0.0.1',
515
+ port,
516
+ path: `${API_PATH}/${method}`,
517
+ method: 'GET',
518
+ headers: { host, ...cookie === undefined ? {} : { cookie } },
519
+ },
520
+ (response) => {
521
+ response.resume()
522
+ response.on('end', () => { resolve(response.statusCode ?? 0) })
523
+ },
524
+ )
525
+ request.on('error', reject)
526
+ request.end()
527
+ })
528
+ }
529
+
530
+ it('requires authentication uniformly over a real HTTP request', async () => {
531
+ // A real IncomingMessage pins the exploit boundary: a client-controlled
532
+ // Host naming loopback passes the rebinding fence but never authenticates.
533
+ const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] })
534
+ const { port, close } = await serve(routes)
535
+ try {
536
+ const methods = [
537
+ 'settings/openSettingsDocument',
538
+ 'session/openWorkspacePath',
539
+ 'llm/discoverModels', 'skills/list',
540
+ 'settings/openAgentPresetDirectory',
541
+ 'llm/listProviders', 'session/modelCatalog',
542
+ ]
543
+ for (const method of methods) {
544
+ expect([method, await call(port, method, 'localhost')]).toEqual([method, 401])
545
+ expect([method, await call(port, method, 'harness.example')]).toEqual([method, 401])
546
+ }
547
+ expect(await call(port, 'settings/openSettingsDocument', 'other.example')).toBe(403)
548
+
549
+ const declaredCookie = browserCookie(connection, 'harness.example')
550
+ for (const method of methods) {
551
+ expect([method, await call(port, method, 'harness.example', declaredCookie)]).toEqual([method, 404])
552
+ }
553
+ const loopbackAuthority = `127.0.0.1:${String(port)}`
554
+ expect(await call(
555
+ port,
556
+ 'settings/openSettingsDocument',
557
+ loopbackAuthority,
558
+ browserCookie(connection, loopbackAuthority),
559
+ )).toBe(404)
560
+ } finally {
561
+ await close()
562
+ await dispose()
563
+ }
564
+ })
565
+ })
packages/client/connection/tests/recovery-config.client.spec.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Recovery timing validation before a loop or Host bootstrap is published. */
2
+ import { describe, expect, it } from 'vitest'
3
+ import { resolveConnectionConfig } from '../src/recovery-config.ts'
4
+
5
+ describe('connection recovery configuration', () => {
6
+ it('separates the slow-Host warning from the hard handshake deadline', () => {
7
+ expect(resolveConnectionConfig()).toEqual({
8
+ backoffBaseMs: 500,
9
+ backoffFactor: 2,
10
+ backoffMaxMs: 10_000,
11
+ generationReadyWarnMs: 3_000,
12
+ generationReadyTimeoutMs: 15_000,
13
+ })
14
+ })
15
+
16
+ it.each([
17
+ { backoffBaseMs: 0 },
18
+ { backoffFactor: 0.5 },
19
+ { backoffFactor: Infinity },
20
+ { backoffFactor: NaN },
21
+ { backoffMaxMs: -1 },
22
+ { generationReadyWarnMs: NaN },
23
+ { generationReadyTimeoutMs: 2_147_483_648 },
24
+ { generationReadyTimeoutMs: '15000' },
25
+ ])('rejects timing that could disable recovery or overflow a timer: %j', (config) => {
26
+ expect(() => resolveConnectionConfig(config)).toThrow()
27
+ })
28
+
29
+ it('preserves a finite fractional growth factor', () => {
30
+ expect(resolveConnectionConfig({ backoffFactor: 1.5 }).backoffFactor).toBe(1.5)
31
+ })
32
+ })
packages/client/connection/tests/rpc-schema.host.spec.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest'
2
+ import { RpcId, transportError } from '../src/rpc.ts'
3
+ import {
4
+ clientRequestSchema,
5
+ rpcErrorSchema,
6
+ rpcIdSchema,
7
+ rpcMessageSchema,
8
+ rpcResultSchema,
9
+ serverResponseSchema,
10
+ } from '../src/rpc-schema.ts'
11
+ import { z } from 'zod'
12
+
13
+ describe('Connection RPC schema', () => {
14
+ it('brands any validated string correlation id', () => {
15
+ expect(RpcId('abc')).toBe('abc')
16
+ expect(rpcIdSchema.parse('')).toBe('')
17
+ expect(() => rpcIdSchema.parse(42)).toThrow()
18
+ })
19
+
20
+ it('folds transport exceptions into an internal failure', () => {
21
+ expect(transportError(new Error('wire down'))).toEqual({
22
+ ok: false,
23
+ error: { code: 'gateway/internal', message: 'wire down', details: {} },
24
+ })
25
+ expect(transportError('raw')).toMatchObject({
26
+ ok: false,
27
+ error: { code: 'gateway/internal', message: 'raw' },
28
+ })
29
+ })
30
+
31
+ it('validates generic failures and both result branches', () => {
32
+ expect(rpcErrorSchema.parse({ code: 'domain-failure', message: 'failed', details: { id: 'x' } }))
33
+ .toEqual({ code: 'domain-failure', message: 'failed', details: { id: 'x' } })
34
+ expect(() => rpcErrorSchema.parse({ code: 1, message: 'failed', details: {} })).toThrow()
35
+ expect(() => rpcErrorSchema.parse({ code: 'failed', message: 'failed', details: [] })).toThrow()
36
+
37
+ const schema = rpcResultSchema(z.object({ n: z.number() }))
38
+ expect(schema.parse({ ok: true, value: { n: 1 } })).toEqual({ ok: true, value: { n: 1 } })
39
+ expect(schema.parse({ ok: false, error: { code: 'failed', message: 'x', details: {} } }))
40
+ .toMatchObject({ ok: false })
41
+ expect(() => schema.parse({ ok: true, error: {} })).toThrow()
42
+ })
43
+
44
+ it('validates both envelope directions and valueless success', () => {
45
+ const request = { type: 'client-request', rpcId: 'r1', method: 'settings/describe', payload: { args: {} } }
46
+ const response = { type: 'server-response', rpcId: 'r1', result: { ok: true, value: 1 } }
47
+ expect(clientRequestSchema.parse(request).method).toBe('settings/describe')
48
+ expect(serverResponseSchema.parse(response).rpcId).toBe('r1')
49
+ for (const message of [request, response]) expect(rpcMessageSchema.parse(message)).toBeTruthy()
50
+ expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow()
51
+ expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow()
52
+ expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow()
53
+ expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow()
54
+ expect(serverResponseSchema.parse({
55
+ type: 'server-response', rpcId: 'r1', result: { ok: true },
56
+ }).rpcId).toBe('r1')
57
+ })
58
+ })
packages/client/connection/tsconfig.client.json ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "../../../tsconfig.base.client.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "lib/types",
6
+ "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
7
+ },
8
+ "files": [
9
+ "src/api-path.ts",
10
+ "src/client/api.ts",
11
+ "src/client/connection.ts",
12
+ "src/client/index.ts",
13
+ "src/client/random-uuid.ts",
14
+ "src/client/rpc.ts",
15
+ "src/loopback-hostname.ts",
16
+ "src/recovery-config.ts",
17
+ "src/rpc.ts"
18
+ ],
19
+ "references": [
20
+ {
21
+ "path": "../../../vendor/cordis"
22
+ },
23
+ {
24
+ "path": "../../../vendor/schemastery"
25
+ },
26
+ {
27
+ "path": "../../attachment/attachment"
28
+ },
29
+ {
30
+ "path": "../../core/session"
31
+ },
32
+ {
33
+ "path": "../../credentials/credentials"
34
+ },
35
+ {
36
+ "path": "../../llm/llm"
37
+ },
38
+ {
39
+ "path": "../../util/brand"
40
+ }
41
+ ]
42
+ }
packages/client/connection/tsconfig.host.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "lib/types",
6
+ "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
7
+ },
8
+ "files": [
9
+ "src/api-path.ts",
10
+ "src/api-request-trust.ts",
11
+ "src/browser-auth.ts",
12
+ "src/http-bridge.ts",
13
+ "src/index.ts",
14
+ "src/loopback-hostname.ts",
15
+ "src/recovery-config.ts",
16
+ "src/rpc-host.ts",
17
+ "src/rpc-schema.ts",
18
+ "src/rpc.ts"
19
+ ],
20
+ "references": [
21
+ {
22
+ "path": "../../../vendor/cordis"
23
+ },
24
+ {
25
+ "path": "../../../vendor/schemastery"
26
+ },
27
+ {
28
+ "path": "../../attachment/attachment"
29
+ },
30
+ {
31
+ "path": "../../credentials/credentials"
32
+ },
33
+ {
34
+ "path": "../../core/session"
35
+ },
36
+ {
37
+ "path": "../../host/webserver"
38
+ },
39
+ {
40
+ "path": "../../util/brand"
41
+ }
42
+ ]
43
+ }
packages/client/connection/tsconfig.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ {
5
+ "path": "./tsconfig.host.json"
6
+ },
7
+ {
8
+ "path": "./tsconfig.client.json"
9
+ }
10
+ ]
11
+ }
packages/client/connection/tsdown.config.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ import { clientBundle } from '../tsdown.client.ts'
2
+
3
+ export default clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js'])
packages/client/file-upload/src/client/contract.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
2
+ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
3
+ import type { FileUploadValue } from '../types.ts'
4
+
5
+ /** Browser request body accepted by the background file-upload service. */
6
+ export type FileUploadBody = Blob | ReadableStream<Uint8Array>
7
+
8
+ /** Monotone byte progress reported while a browser body is consumed. */
9
+ export interface FileUploadProgress {
10
+ readonly loaded: number
11
+ readonly total?: number
12
+ }
13
+
14
+ /** Browser upload service addressed by one Session identity. */
15
+ export interface FileUploadService {
16
+ /**
17
+ * Store one file for a Session. Blob and stream bodies use
18
+ * the background carrier; exact bytes use Remote.
19
+ * @param sessionId - Session that owns the staged receipt.
20
+ * @param data - browser Blob, exact bytes, or a one-shot byte stream.
21
+ * @param name - optional display name.
22
+ * @param signal - optional cancellation for the active upload.
23
+ * @param onProgress - optional byte-progress observer for background bodies.
24
+ * @returns the staged receipt and durable file reference, or a business error.
25
+ */
26
+ upload(
27
+ sessionId: SessionId,
28
+ data: Blob | Uint8Array | ReadableStream<Uint8Array>,
29
+ name?: string,
30
+ signal?: AbortSignal,
31
+ onProgress?: (progress: FileUploadProgress) => void,
32
+ ): Promise<RemoteResult<FileUploadValue>>
33
+ }
packages/client/file-upload/src/client/index.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Browser background-upload Cordis service. */
2
+
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import { FileUploadRuntime } from './runtime.ts'
5
+ import type { FileUploadService } from './contract.ts'
6
+
7
+ export type { FileUploadProgress, FileUploadService } from './contract.ts'
8
+ export type { FileUploadReceiptId, FileUploadValue } from '../types.ts'
9
+
10
+ declare module '@deepseek-ai/cordis' {
11
+ interface Context {
12
+ /** Session-addressed browser service for staged file uploads. */
13
+ fileUpload: FileUploadService
14
+ }
15
+ }
16
+
17
+ /** The upload service uses the generated Remote fallback. */
18
+ export const inject = ['remote']
19
+
20
+ /**
21
+ * Provide the browser background-upload service.
22
+ * @param ctx - Client plugin context.
23
+ */
24
+ export function apply(ctx: Context): void {
25
+ ctx.plugin(FileUploadRuntime)
26
+ }
packages/client/file-upload/src/client/runtime.ts ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Background browser upload implementation for Blob and byte-stream bodies. */
2
+
3
+ import { Service, type Context } from '@deepseek-ai/cordis'
4
+ import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto'
5
+ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
6
+ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
7
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
8
+ import { FILE_UPLOAD_PATH } from '../protocol.ts'
9
+ import type {
10
+ ClientFileUploadHooks, EncodedFileUploadRequest, FileUploadFetch, FileUploadValue,
11
+ } from '../types.ts'
12
+ import type { FileUploadBody, FileUploadService } from './contract.ts'
13
+
14
+ interface FileUploadRequest {
15
+ readonly path: string
16
+ readonly body: FileUploadBody
17
+ readonly headers?: Readonly<Record<string, string>>
18
+ readonly signal?: AbortSignal
19
+ readonly onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void
20
+ }
21
+
22
+ interface FileUploadResponse {
23
+ readonly status: number
24
+ readonly body: string
25
+ }
26
+
27
+ interface FileUploadRemoteContext extends Context {
28
+ readonly remote: {
29
+ readonly fileUploads: {
30
+ upload(
31
+ sessionId: SessionId,
32
+ request: EncodedFileUploadRequest,
33
+ signal?: AbortSignal,
34
+ ): Promise<RemoteResult<FileUploadValue>>
35
+ }
36
+ }
37
+ }
38
+
39
+ interface UploadWorkerStart {
40
+ readonly url: string
41
+ readonly body: FileUploadBody
42
+ readonly headers: Readonly<Record<string, string>>
43
+ }
44
+
45
+ type UploadWorkerOutput =
46
+ | { readonly kind: 'progress'; readonly loaded: number; readonly total?: number }
47
+ | { readonly kind: 'complete'; readonly status: number; readonly body: string }
48
+ | { readonly kind: 'error'; readonly message: string }
49
+
50
+ interface UploadWorkerScope {
51
+ onmessage: ((event: MessageEvent<UploadWorkerStart>) => void) | null
52
+ postMessage(message: UploadWorkerOutput): void
53
+ }
54
+
55
+ interface UploadXhr {
56
+ readonly upload: { onprogress: ((event: ProgressEvent) => void) | null }
57
+ status: number
58
+ responseText: string
59
+ withCredentials: boolean
60
+ onload: ((event: ProgressEvent) => void) | null
61
+ onerror: ((event: ProgressEvent) => void) | null
62
+ open(method: string, url: string): void
63
+ setRequestHeader(name: string, value: string): void
64
+ send(body: Blob): void
65
+ }
66
+
67
+ type UploadWorkerFetch = (
68
+ input: string,
69
+ init: RequestInit & { readonly duplex: 'half' },
70
+ ) => Promise<Response>
71
+
72
+ /**
73
+ * Self-contained Worker body; its string form becomes the Blob Worker source.
74
+ * @param scope - Worker global used for requests and progress messages.
75
+ * @param createXhr - XMLHttpRequest factory used for Blob progress.
76
+ * @param doFetch - Fetch carrier used for one-shot ReadableStream bodies.
77
+ */
78
+ export function fileUploadWorker(
79
+ scope: UploadWorkerScope = self,
80
+ createXhr: () => UploadXhr = () => new XMLHttpRequest(),
81
+ doFetch: UploadWorkerFetch = (input, init) => fetch(input, init),
82
+ ): void {
83
+ scope.onmessage = (event: MessageEvent<UploadWorkerStart>) => {
84
+ const request = event.data
85
+ if (request.body instanceof Blob) {
86
+ const xhr = createXhr()
87
+ xhr.open('POST', request.url)
88
+ xhr.withCredentials = true
89
+ for (const [name, value] of Object.entries(request.headers)) xhr.setRequestHeader(name, value)
90
+ xhr.upload.onprogress = (progress) => {
91
+ scope.postMessage({
92
+ kind: 'progress',
93
+ loaded: progress.loaded,
94
+ ...(progress.lengthComputable ? { total: progress.total } : {}),
95
+ } satisfies UploadWorkerOutput)
96
+ }
97
+ xhr.onload = () => {
98
+ scope.postMessage({ kind: 'complete', status: xhr.status, body: xhr.responseText } satisfies UploadWorkerOutput)
99
+ }
100
+ xhr.onerror = () => {
101
+ scope.postMessage({ kind: 'error', message: 'background upload transport failed' } satisfies UploadWorkerOutput)
102
+ }
103
+ xhr.send(request.body)
104
+ return
105
+ }
106
+ if (!(request.body instanceof ReadableStream)) {
107
+ scope.postMessage({ kind: 'error', message: 'background upload worker received an invalid body' })
108
+ return
109
+ }
110
+ const source = request.body
111
+ void (async () => {
112
+ const reader = source.getReader()
113
+ let loaded = 0
114
+ const body = new ReadableStream<Uint8Array>({
115
+ async pull(controller) {
116
+ const item = await reader.read()
117
+ if (item.done) {
118
+ controller.close()
119
+ return
120
+ }
121
+ if (!(item.value instanceof Uint8Array)) {
122
+ throw new TypeError('background upload stream produced a non-Uint8Array chunk')
123
+ }
124
+ loaded += item.value.byteLength
125
+ scope.postMessage({ kind: 'progress', loaded })
126
+ controller.enqueue(item.value)
127
+ },
128
+ async cancel(reason) {
129
+ await reader.cancel(reason)
130
+ },
131
+ })
132
+ const response = await doFetch(request.url, {
133
+ method: 'POST',
134
+ headers: request.headers,
135
+ credentials: 'include',
136
+ body,
137
+ duplex: 'half',
138
+ })
139
+ scope.postMessage({
140
+ kind: 'complete',
141
+ status: response.status,
142
+ body: await response.text(),
143
+ })
144
+ })().catch((error: unknown) => {
145
+ scope.postMessage({
146
+ kind: 'error',
147
+ message: error instanceof Error ? error.message : String(error),
148
+ })
149
+ })
150
+ }
151
+ }
152
+
153
+ interface ClientFileUploadGlobal {
154
+ __DSH_FILE_UPLOAD__?: ClientFileUploadHooks
155
+ }
156
+
157
+ interface FileUploadTransport {
158
+ post(request: FileUploadRequest): Promise<FileUploadResponse>
159
+ }
160
+
161
+ /** Cordis service that owns one background carrier per upload operation. */
162
+ export class FileUploadRuntime extends Service implements FileUploadService {
163
+ private readonly transport: FileUploadTransport
164
+
165
+ /** @param ctx - providing Client context. */
166
+ constructor(ctx: Context) {
167
+ super(ctx, 'fileUpload')
168
+ const hook = (globalThis as ClientFileUploadGlobal).__DSH_FILE_UPLOAD__
169
+ this.transport = hook === undefined ? workerTransport() : customTransport(hook.fetch)
170
+ }
171
+
172
+ /**
173
+ * Post one body with the carrier selected before Cordis boot.
174
+ * @param request - target, body, cancellation, and progress observer.
175
+ * @returns the response status and text body.
176
+ */
177
+ post(request: FileUploadRequest): Promise<FileUploadResponse> {
178
+ return this.transport.post(request)
179
+ }
180
+
181
+ /**
182
+ * Store one file for a Session.
183
+ * @param sessionId - Session that owns the staged receipt.
184
+ * @param data - browser Blob, exact bytes, or a one-shot byte stream.
185
+ * @param name - optional display name.
186
+ * @param signal - optional cancellation for the active upload.
187
+ * @param onProgress - optional byte-progress observer for background bodies.
188
+ * @returns the staged receipt and durable file reference, or a business error.
189
+ */
190
+ async upload(
191
+ sessionId: SessionId,
192
+ data: Blob | Uint8Array | ReadableStream<Uint8Array>,
193
+ name?: string,
194
+ signal?: AbortSignal,
195
+ onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void,
196
+ ): Promise<RemoteResult<FileUploadValue>> {
197
+ if (!(data instanceof Uint8Array)) {
198
+ const query = new URLSearchParams({ sessionId })
199
+ if (name !== undefined) query.set('name', name)
200
+ const response = await this.post({
201
+ path: `${FILE_UPLOAD_PATH}?${query.toString()}`,
202
+ body: data,
203
+ headers: { 'content-type': 'application/octet-stream' },
204
+ ...(signal === undefined ? {} : { signal }),
205
+ ...(onProgress === undefined ? {} : { onProgress }),
206
+ })
207
+ if (response.status !== 200) {
208
+ throw new Error(`file upload transport failed with HTTP ${String(response.status)}`)
209
+ }
210
+ return parseFileUploadResult(response.body)
211
+ }
212
+ return (this.ctx as FileUploadRemoteContext).remote.fileUploads.upload(
213
+ sessionId,
214
+ {
215
+ data: bytesToBase64(data),
216
+ ...(name === undefined ? {} : { name }),
217
+ },
218
+ signal,
219
+ )
220
+ }
221
+ }
222
+
223
+ function customTransport(customFetch: FileUploadFetch): FileUploadTransport {
224
+ return {
225
+ async post(request) {
226
+ const init: RequestInit & { duplex?: 'half' } = {
227
+ method: 'POST',
228
+ ...(request.headers === undefined ? {} : { headers: request.headers }),
229
+ body: request.body,
230
+ ...(request.body instanceof ReadableStream ? { duplex: 'half' as const } : {}),
231
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
232
+ }
233
+ const response = await customFetch(resolveUrl(request.path), init)
234
+ return { status: response.status, body: await response.text() }
235
+ },
236
+ }
237
+ }
238
+
239
+ function workerTransport(): FileUploadTransport {
240
+ return {
241
+ post(request) {
242
+ if (typeof Worker !== 'function') {
243
+ return Promise.reject(new Error('background upload requires Web Worker support'))
244
+ }
245
+ const workerUrl = URL.createObjectURL(new Blob([
246
+ `(${fileUploadWorker.toString()})()`,
247
+ ], { type: 'text/javascript' }))
248
+ const worker = new Worker(workerUrl, { name: 'dsh-file-upload' })
249
+ URL.revokeObjectURL(workerUrl)
250
+ return new Promise((resolve, reject) => {
251
+ let settled = false
252
+ const abort = (): void => {
253
+ settled = true
254
+ worker.terminate()
255
+ request.signal?.removeEventListener('abort', abort)
256
+ reject(new DOMException('The operation was aborted.', 'AbortError'))
257
+ }
258
+ const finish = (settle: () => void): void => {
259
+ if (settled) return
260
+ settled = true
261
+ request.signal?.removeEventListener('abort', abort)
262
+ worker.terminate()
263
+ settle()
264
+ }
265
+ worker.onmessage = (event: MessageEvent<UploadWorkerOutput>) => {
266
+ const output = event.data
267
+ if (output.kind === 'progress') {
268
+ request.onProgress?.({
269
+ loaded: output.loaded,
270
+ ...(output.total === undefined ? {} : { total: output.total }),
271
+ })
272
+ } else if (output.kind === 'complete') {
273
+ finish(() => { resolve({ status: output.status, body: output.body }) })
274
+ } else {
275
+ finish(() => { reject(new Error(output.message)) })
276
+ }
277
+ }
278
+ worker.onerror = (event) => {
279
+ finish(() => { reject(new Error(event.message || 'background upload worker failed')) })
280
+ }
281
+ if (request.signal?.aborted === true) {
282
+ abort()
283
+ return
284
+ }
285
+ request.signal?.addEventListener('abort', abort, { once: true })
286
+ const message: UploadWorkerStart = {
287
+ url: resolveUrl(request.path).href,
288
+ body: request.body,
289
+ headers: request.headers ?? {},
290
+ }
291
+ if (request.body instanceof ReadableStream) worker.postMessage(message, [request.body])
292
+ else worker.postMessage(message)
293
+ })
294
+ },
295
+ }
296
+ }
297
+
298
+ function resolveUrl(path: string): URL {
299
+ const pageLocation = Reflect.get(globalThis, 'location') as unknown
300
+ const origin = typeof pageLocation === 'object' && pageLocation !== null
301
+ && 'origin' in pageLocation && typeof pageLocation.origin === 'string'
302
+ ? pageLocation.origin
303
+ : undefined
304
+ return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin)
305
+ }
306
+
307
+ function parseFileUploadResult(body: string): RemoteResult<FileUploadValue> {
308
+ const value = JSON.parse(body) as unknown
309
+ if (!isRecord(value) || typeof value.ok !== 'boolean') {
310
+ throw new TypeError('file upload transport returned an invalid result')
311
+ }
312
+ if (!value.ok) {
313
+ const error = value.error
314
+ if (!isRecord(error) || typeof error.code !== 'string'
315
+ || typeof error.message !== 'string' || !isRecord(error.details)) {
316
+ throw new TypeError('file upload transport returned an invalid failure')
317
+ }
318
+ return {
319
+ ok: false,
320
+ error: new RemoteError(error.code as never, error.message, error.details as never),
321
+ }
322
+ }
323
+ const result = value.value
324
+ const file = isRecord(result) ? result.file : undefined
325
+ if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(file)
326
+ || typeof file.attachmentId !== 'string' || typeof file.name !== 'string'
327
+ || typeof file.bytes !== 'number' || !Number.isSafeInteger(file.bytes) || file.bytes < 0) {
328
+ throw new TypeError('file upload transport returned an invalid receipt')
329
+ }
330
+ return {
331
+ ok: true,
332
+ value: {
333
+ receiptId: result.receiptId as FileUploadValue['receiptId'],
334
+ file: {
335
+ attachmentId: file.attachmentId as FileUploadValue['file']['attachmentId'],
336
+ name: file.name,
337
+ bytes: file.bytes,
338
+ },
339
+ },
340
+ }
341
+ }
342
+
343
+ function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
344
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
345
+ }
packages/client/file-upload/src/http-route.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Authenticated raw-byte upload route registered on the Connection fetch registry. */
2
+
3
+ import { brandString } from '@deepseek-ai/dsh-brand'
4
+ import type { SessionId } from '@deepseek-ai/dsh-session'
5
+ import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
6
+ import type { FileUploads } from './index.ts'
7
+ import type { FileUploadValue } from './types.ts'
8
+
9
+ type FileUploadHttpResult =
10
+ | { readonly ok: true; readonly value: FileUploadValue }
11
+ | {
12
+ readonly ok: false
13
+ readonly error: { readonly code: string; readonly message: string; readonly details: object }
14
+ }
15
+
16
+ /**
17
+ * Handle one authenticated raw-byte upload.
18
+ * @param service - Host upload service receiving streamed bytes.
19
+ * @param request - authenticated HTTP request from Connection.
20
+ * @returns JSON result using HTTP status 200 after request validation.
21
+ */
22
+ export async function handleFileUploadHttp(service: FileUploads, request: Request): Promise<Response> {
23
+ if (request.method !== 'POST') {
24
+ return new Response(null, { status: 405, headers: { allow: 'POST' } })
25
+ }
26
+ const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
27
+ if (mediaType !== 'application/octet-stream') {
28
+ return new Response('content type must be application/octet-stream', { status: 415 })
29
+ }
30
+ const url = new URL(request.url)
31
+ const sessionId = url.searchParams.get('sessionId')
32
+ if (sessionId === null || sessionId === '') {
33
+ return new Response('sessionId is required', { status: 400 })
34
+ }
35
+ const name = url.searchParams.get('name') ?? undefined
36
+ let result: FileUploadHttpResult
37
+ try {
38
+ result = {
39
+ ok: true,
40
+ value: await service.uploadStream({
41
+ sessionId: brandString<SessionId>(sessionId),
42
+ data: requestBodyChunks(request.body),
43
+ signal: request.signal,
44
+ ...(name === undefined ? {} : { name }),
45
+ }),
46
+ }
47
+ } catch (error) {
48
+ const failure = remoteErrorOf(error)
49
+ result = {
50
+ ok: false,
51
+ error: failure !== undefined
52
+ ? { code: failure.code, message: failure.message, details: failure.details }
53
+ : {
54
+ code: 'gateway/internal',
55
+ message: error instanceof Error ? error.message : String(error),
56
+ details: {},
57
+ },
58
+ }
59
+ }
60
+ return new Response(JSON.stringify(result), {
61
+ status: 200,
62
+ headers: {
63
+ 'content-type': 'application/json; charset=utf-8',
64
+ 'cache-control': 'no-store',
65
+ },
66
+ })
67
+ }
68
+
69
+ async function* requestBodyChunks(body: ReadableStream<Uint8Array> | null): AsyncIterable<Uint8Array> {
70
+ if (body === null) return
71
+ const reader = body.getReader()
72
+ try {
73
+ while (true) {
74
+ const chunk = await reader.read()
75
+ if (chunk.done) return
76
+ yield chunk.value
77
+ }
78
+ } finally {
79
+ reader.releaseLock()
80
+ }
81
+ }
packages/client/file-upload/src/index.ts ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Host file-upload service: streamed intake and Agent-scoped staged receipts. */
2
+
3
+ import { randomUUID } from 'node:crypto'
4
+ import type { Context } from '@deepseek-ai/cordis'
5
+ import type { Agent } from '@deepseek-ai/dsh-agent'
6
+ import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
7
+ import type {} from '@deepseek-ai/dsh-client-connection'
8
+ import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands'
9
+ import { scopeOf } from '@deepseek-ai/dsh-scope'
10
+ import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
11
+ import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
12
+ import { handleFileUploadHttp } from './http-route.ts'
13
+ import { FILE_UPLOAD_PATH } from './protocol.ts'
14
+ import type { EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue } from './types.ts'
15
+
16
+ export type * from './types.ts'
17
+
18
+ declare module '@deepseek-ai/cordis' {
19
+ interface Context {
20
+ /** Host storage and staged-receipt service for browser file uploads. */
21
+ fileUploads: FileUploads
22
+ }
23
+ }
24
+
25
+ interface StagedFileUpload {
26
+ readonly file: FileAttachmentRef
27
+ /** Prompt that accepted this receipt; absent until successful admission. */
28
+ requestId?: string
29
+ }
30
+
31
+ /** Resolve or resume the ordinary Agent that owns one Session identity. */
32
+ export type AgentResolver = (sessionId: SessionId) => Promise<Agent>
33
+
34
+ /** Prompt receipt binding that restores its previous owners unless delivery commits it. */
35
+ export interface PromptFileBinding extends Disposable {
36
+ /** Keep the receipt bindings until queue or history observation retires them. */
37
+ commit(): void
38
+ }
39
+
40
+ class PromptFileBindingGuard implements PromptFileBinding {
41
+ private settled = false
42
+
43
+ constructor(private readonly rollback: () => void) {}
44
+
45
+ commit(): void {
46
+ this.settled = true
47
+ }
48
+
49
+ [Symbol.dispose](): void {
50
+ if (this.settled) return
51
+ this.settled = true
52
+ this.rollback()
53
+ }
54
+ }
55
+
56
+ /** Host service owning upload storage and Agent-scoped staged receipts. */
57
+ export class FileUploads extends TypertRemoteService {
58
+ static inject = ['agents', 'attachments', 'commands', 'connection']
59
+
60
+ private readonly stagedFiles = new WeakMap<Session, Map<FileUploadReceiptId, StagedFileUpload>>()
61
+ private agentResolver: AgentResolver | undefined
62
+
63
+ /** @param ctx - Host context carrying Agent, attachment, command, and Connection services. */
64
+ constructor(ctx: Context) {
65
+ super(ctx, 'fileUploads')
66
+ const resolve: CommandFileReceiptResolver = (agent, receiptId) =>
67
+ this.resolve(agent, receiptId as FileUploadReceiptId)
68
+ ctx.effect(
69
+ () => ctx.commands.registerFileReceiptResolver(resolve),
70
+ 'file-upload: command file receipt resolver',
71
+ )
72
+ ctx.effect(
73
+ () => ctx.connection.fetch.register({
74
+ path: FILE_UPLOAD_PATH,
75
+ methods: ['POST'],
76
+ requestBody: 'streaming',
77
+ fetch: request => handleFileUploadHttp(this, request),
78
+ }),
79
+ 'file-upload: streaming route',
80
+ )
81
+ ctx.on('session/event', (session, event) => { this.observeSessionEvent(session, event) })
82
+ ctx.on('session/disposed', (session) => { this.stagedFiles.delete(session) })
83
+ }
84
+
85
+ /**
86
+ * Register the ordinary-Session resolver used when a raw upload addresses a cold Session.
87
+ * @param resolve - resolver that returns the exact live Agent or throws a Remote error.
88
+ * @returns disposer removing this resolver.
89
+ */
90
+ registerAgentResolver(resolve: AgentResolver): () => void {
91
+ if (this.agentResolver !== undefined) throw new Error('file-upload: Agent resolver is already registered')
92
+ this.agentResolver = resolve
93
+ return () => {
94
+ if (this.agentResolver === resolve) this.agentResolver = undefined
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Persist one encoded upload and stage it under the Agent receiver selected by Typert.
100
+ * @param agent - receiving Agent resolved from the Remote Agent scope.
101
+ * @param request - canonical base64 bytes and optional display name.
102
+ * @param signal - caller cancellation before storage begins.
103
+ * @returns the staged receipt and durable file reference.
104
+ */
105
+ @Remote('upload')
106
+ upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise<FileUploadValue> {
107
+ signal.throwIfAborted()
108
+ return this.commit(agent, async () => this.ctx.attachments.admitEncodedFile({
109
+ data: request.data,
110
+ ...(request.name === undefined ? {} : { name: request.name }),
111
+ }))
112
+ }
113
+
114
+ /**
115
+ * Persist raw chunks for one Session without aggregating the upload.
116
+ * @param request - Session identity, ordered bytes, cancellation, and optional display name.
117
+ * @returns the staged receipt and durable file reference.
118
+ */
119
+ async uploadStream(request: {
120
+ readonly sessionId: SessionId
121
+ readonly data: AsyncIterable<Uint8Array>
122
+ readonly signal?: AbortSignal
123
+ readonly name?: string
124
+ }): Promise<FileUploadValue> {
125
+ const agent = await this.resolveAgent(request.sessionId)
126
+ return this.commit(agent, async () => this.ctx.attachments.saveFileStream({
127
+ data: request.data,
128
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
129
+ ...(request.name === undefined ? {} : { name: request.name }),
130
+ }))
131
+ }
132
+
133
+ /**
134
+ * Resolve one staged receipt inside its receiving Agent scope.
135
+ * @param agent - receiving Agent.
136
+ * @param receiptId - opaque receipt minted for one completed upload.
137
+ * @returns durable file reference, or `undefined` for an unknown or foreign receipt.
138
+ */
139
+ resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined {
140
+ this.assertAgentScope(agent)
141
+ return this.stagedFiles.get(agent.session)?.get(receiptId)?.file
142
+ }
143
+
144
+ /**
145
+ * Bind receipts while one prompt enters an Agent inbox.
146
+ * Disposal restores every prior binding unless the caller commits successful delivery.
147
+ * @param agent - receiving Agent.
148
+ * @param receiptIds - distinct staged receipts referenced by the prompt.
149
+ * @param requestId - prompt identity later observed in queue or history.
150
+ * @returns binding kept after commit until queue or history observation retires its receipts.
151
+ */
152
+ bindPrompt(
153
+ agent: Agent,
154
+ receiptIds: readonly FileUploadReceiptId[],
155
+ requestId: string,
156
+ ): PromptFileBinding {
157
+ this.assertAgentScope(agent)
158
+ const staged = this.stagedFiles.get(agent.session)
159
+ const bound = receiptIds.map((receiptId) => {
160
+ const upload = staged?.get(receiptId)
161
+ if (upload === undefined) throw fileNotStaged()
162
+ return { upload, previous: upload.requestId }
163
+ })
164
+ for (const { upload } of bound) upload.requestId = requestId
165
+ return new PromptFileBindingGuard(() => {
166
+ for (const { upload, previous } of bound) {
167
+ if (previous === undefined) delete upload.requestId
168
+ else upload.requestId = previous
169
+ }
170
+ })
171
+ }
172
+
173
+ /**
174
+ * Retire every receipt accepted by one removed queue occurrence.
175
+ * @param agent - receiving Agent.
176
+ * @param requestId - prompt identity carried by the queue occurrence.
177
+ */
178
+ retirePrompt(agent: Agent, requestId: string): void {
179
+ this.assertAgentScope(agent)
180
+ this.retire(agent.session, requestId)
181
+ }
182
+
183
+ private async commit(agent: Agent, save: () => Promise<FileAttachmentRef>): Promise<FileUploadValue> {
184
+ this.assertOrdinaryAgent(agent)
185
+ let file: FileAttachmentRef
186
+ try {
187
+ file = await save()
188
+ } catch (error) {
189
+ if (this.ctx.attachments.isAttachmentError(error)) {
190
+ throw new RemoteError('session/attachment-invalid' as never, error.message, { reason: error.code } as never)
191
+ }
192
+ throw new RemoteError(
193
+ 'gateway/internal',
194
+ `failed to store file upload: ${String(error)}`,
195
+ {},
196
+ { cause: error },
197
+ )
198
+ }
199
+ if (this.ctx.agents.get(agent.id) !== agent) {
200
+ throw new RemoteError(
201
+ 'session/not-found',
202
+ `session "${agent.id}" was disposed before its file upload completed`,
203
+ { sessionId: agent.id },
204
+ )
205
+ }
206
+ let staged = this.stagedFiles.get(agent.session)
207
+ if (staged === undefined) {
208
+ staged = new Map()
209
+ this.stagedFiles.set(agent.session, staged)
210
+ }
211
+ const receiptId = randomUUID() as FileUploadReceiptId
212
+ staged.set(receiptId, { file })
213
+ return { receiptId, file }
214
+ }
215
+
216
+ private async resolveAgent(sessionId: SessionId): Promise<Agent> {
217
+ const live = this.ctx.agents.get(sessionId)
218
+ if (live !== undefined) return live
219
+ const resolver = this.agentResolver
220
+ if (resolver === undefined) {
221
+ throw new RemoteError('session/not-found', `session "${sessionId}" is not attached`, { sessionId })
222
+ }
223
+ return resolver(sessionId)
224
+ }
225
+
226
+ private assertAgentScope(agent: Agent): void {
227
+ if (scopeOf(agent.ctx) !== agent) throw new Error('file-upload: operation requires the Agent\'s own scope')
228
+ }
229
+
230
+ private assertOrdinaryAgent(agent: Agent): void {
231
+ this.assertAgentScope(agent)
232
+ if (agent.session.header.origin === 'subagent') {
233
+ throw new RemoteError(
234
+ 'subagent/attachment-invalid' as never,
235
+ 'subagent conversations do not accept file uploads',
236
+ { reason: 'SUBAGENT_FILE_UNSUPPORTED' } as never,
237
+ )
238
+ }
239
+ }
240
+
241
+ private observeSessionEvent(session: Session, event: SessionEvent): void {
242
+ if (event.type !== 'user/message' || event.data.source.kind !== 'user'
243
+ || !('rpcId' in event.data.source)) return
244
+ if (typeof event.data.source.rpcId === 'string') this.retire(session, event.data.source.rpcId)
245
+ }
246
+
247
+ private retire(session: Session, requestId: string): void {
248
+ const staged = this.stagedFiles.get(session)
249
+ if (staged === undefined) return
250
+ for (const [receiptId, upload] of staged) {
251
+ if (upload.requestId === requestId) staged.delete(receiptId)
252
+ }
253
+ if (staged.size === 0) this.stagedFiles.delete(session)
254
+ }
255
+ }
256
+
257
+ function fileNotStaged(): RemoteError {
258
+ return new RemoteError(
259
+ 'session/attachment-invalid' as never,
260
+ 'File was not uploaded for this session.',
261
+ { reason: 'FILE_NOT_STAGED' } as never,
262
+ )
263
+ }
264
+
265
+ export default FileUploads
packages/client/file-upload/src/protocol.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ /** Authenticated raw-byte route owned by the file-upload service. */
2
+ export const FILE_UPLOAD_PATH = '/api/session/uploadFileBinary'
packages/client/file-upload/src/types.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Browser-safe request and receipt types for staged file uploads. */
2
+
3
+ import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment/types'
4
+ import type { Branded } from '@deepseek-ai/dsh-brand'
5
+
6
+ /** Canonical encoded upload accepted by the Remote fallback. */
7
+ export interface EncodedFileUploadRequest {
8
+ /** Canonical base64 encoding of the exact file bytes. */
9
+ readonly data: string
10
+ /** Optional display name; the Host sanitizes it into the stored leaf name. */
11
+ readonly name?: string
12
+ }
13
+
14
+ /** Durable receipt for one staged file upload. */
15
+ export interface FileUploadValue {
16
+ /** Per-upload authority accepted only inside the receiving Agent scope. */
17
+ readonly receiptId: FileUploadReceiptId
18
+ readonly file: FileAttachmentRef
19
+ }
20
+
21
+ /** Host-minted authority for one staged file upload in one Agent scope. */
22
+ export type FileUploadReceiptId = Branded<'file-upload-receipt-id'>
23
+
24
+ /**
25
+ * Fetch-shaped carrier installed by a page that owns its Host transport.
26
+ * @param input - absolute same-origin upload URL.
27
+ * @param init - raw request body, headers, and cancellation signal.
28
+ * @returns the Host response.
29
+ */
30
+ export type FileUploadFetch = (input: URL, init: RequestInit) => Promise<Response>
31
+
32
+ /** Pre-Cordis hook supplied by a page whose Host runs in another execution context. */
33
+ export interface ClientFileUploadHooks {
34
+ readonly fetch: FileUploadFetch
35
+ }
packages/client/file-upload/tests/file-upload-http.host.spec.ts ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { runInNewContext } from 'node:vm'
2
+ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import type { Mock } from 'vitest'
5
+ import { handleFileUploadHttp } from '../src/http-route.ts'
6
+ import type { FileUploads } from '../src/index.ts'
7
+
8
+ function request(input: {
9
+ method?: string
10
+ sessionId?: string
11
+ name?: string
12
+ contentType?: string
13
+ body?: Uint8Array
14
+ } = {}): Request {
15
+ const query = new URLSearchParams()
16
+ if (input.sessionId !== undefined) query.set('sessionId', input.sessionId)
17
+ if (input.name !== undefined) query.set('name', input.name)
18
+ const suffix = query.size === 0 ? '' : `?${query.toString()}`
19
+ return new Request(`http://host/api/session/uploadFileBinary${suffix}`, {
20
+ method: input.method ?? 'POST',
21
+ headers: input.contentType === undefined ? {} : { 'content-type': input.contentType },
22
+ ...(input.body === undefined ? {} : { body: new Blob([Uint8Array.from(input.body).buffer]) }),
23
+ })
24
+ }
25
+
26
+ function uploads(result: unknown): FileUploads & {
27
+ uploadStream: Mock<FileUploads['uploadStream']>
28
+ uploadedChunks: Uint8Array[]
29
+ } {
30
+ const uploadedChunks: Uint8Array[] = []
31
+ const uploadStream = vi.fn<FileUploads['uploadStream']>(async (input) => {
32
+ for await (const chunk of input.data) uploadedChunks.push(chunk)
33
+ return await result as Awaited<ReturnType<FileUploads['uploadStream']>>
34
+ })
35
+ return {
36
+ uploadedChunks,
37
+ uploadStream,
38
+ } as unknown as FileUploads & {
39
+ uploadStream: Mock<FileUploads['uploadStream']>
40
+ uploadedChunks: Uint8Array[]
41
+ }
42
+ }
43
+
44
+ describe('background file upload Fetch route', () => {
45
+ it('accepts one authenticated streaming POST request', async () => {
46
+ const service = uploads(Promise.resolve({}))
47
+ expect((await handleFileUploadHttp(service, request({
48
+ sessionId: 's1', contentType: 'application/octet-stream',
49
+ }))).status).toBe(200)
50
+ })
51
+
52
+ it('rejects the wrong method, media type, and missing Session id without storing', async () => {
53
+ const service = uploads(Promise.resolve({}))
54
+ const wrongMethod = await handleFileUploadHttp(service, request({ method: 'GET' }))
55
+ expect(wrongMethod.status).toBe(405)
56
+ expect(wrongMethod.headers.get('allow')).toBe('POST')
57
+
58
+ const wrongType = await handleFileUploadHttp(service, request({ contentType: 'application/json' }))
59
+ expect(wrongType.status).toBe(415)
60
+ expect(await wrongType.text()).toBe('content type must be application/octet-stream')
61
+
62
+ const missingSession = await handleFileUploadHttp(
63
+ service,
64
+ request({ contentType: 'application/octet-stream' }),
65
+ )
66
+ expect(missingSession.status).toBe(400)
67
+ expect(await missingSession.text()).toBe('sessionId is required')
68
+ expect(service.uploadStream).not.toHaveBeenCalled()
69
+ })
70
+
71
+ it('stores the request bytes and returns the staged receipt', async () => {
72
+ const value = {
73
+ receiptId: 'receipt-1',
74
+ file: { attachmentId: 'file-1', name: 'large & final.bin', bytes: 4 },
75
+ }
76
+ const service = uploads(Promise.resolve(value))
77
+ const response = await handleFileUploadHttp(service, request({
78
+ sessionId: 's1',
79
+ name: 'large & final.bin',
80
+ contentType: 'application/octet-stream; charset=binary',
81
+ body: Uint8Array.of(1, 2, 3, 4),
82
+ }))
83
+ expect(service.uploadStream).toHaveBeenCalledOnce()
84
+ const upload = service.uploadStream.mock.calls[0]?.[0]
85
+ expect(upload).toMatchObject({ sessionId: 's1', name: 'large & final.bin' })
86
+ expect(upload?.signal).toBeInstanceOf(AbortSignal)
87
+ expect(service.uploadedChunks).toEqual([Uint8Array.of(1, 2, 3, 4)])
88
+ expect(response.status).toBe(200)
89
+ expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8')
90
+ expect(response.headers.get('cache-control')).toBe('no-store')
91
+ expect(await response.json()).toEqual({ ok: true, value })
92
+ })
93
+
94
+ it('returns business and internal storage failures and keeps an absent name absent', async () => {
95
+ const business = uploads(Promise.reject(new RemoteError(
96
+ 'session/attachment-invalid', 'denied', { reason: 'NOPE' },
97
+ )))
98
+ const businessResponse = await handleFileUploadHttp(business, request({
99
+ sessionId: 's1', contentType: 'application/octet-stream',
100
+ }))
101
+ expect(business.uploadStream).toHaveBeenCalledOnce()
102
+ const upload = business.uploadStream.mock.calls[0]?.[0]
103
+ expect(upload).toMatchObject({ sessionId: 's1' })
104
+ expect(upload?.signal).toBeInstanceOf(AbortSignal)
105
+ expect(business.uploadedChunks).toEqual([])
106
+ expect(await businessResponse.json()).toEqual({
107
+ ok: false,
108
+ error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
109
+ })
110
+
111
+ const internal = uploads(Promise.reject(new Error('disk offline')))
112
+ expect(await (await handleFileUploadHttp(internal, request({
113
+ sessionId: 's1', contentType: 'application/octet-stream',
114
+ }))).json()).toEqual({
115
+ ok: false, error: { code: 'gateway/internal', message: 'disk offline', details: {} },
116
+ })
117
+
118
+ const foreignError = runInNewContext('new Error("disk exception")') as unknown as Error
119
+ const exception = uploads(Promise.reject(foreignError))
120
+ expect(await (await handleFileUploadHttp(exception, request({
121
+ sessionId: 's1', contentType: 'application/octet-stream',
122
+ }))).json()).toEqual({
123
+ ok: false, error: { code: 'gateway/internal', message: 'Error: disk exception', details: {} },
124
+ })
125
+ })
126
+ })
packages/client/file-upload/tests/file-upload.client.spec.ts ADDED
@@ -0,0 +1,497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Context } from '@deepseek-ai/cordis'
2
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { apply } from '../src/client/index.ts'
5
+ import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts'
6
+ import type { FileUploadBody } from '../src/client/contract.ts'
7
+ import type { ClientFileUploadHooks } from '../src/types.ts'
8
+
9
+ interface UploadGlobal {
10
+ __DSH_FILE_UPLOAD__?: ClientFileUploadHooks
11
+ }
12
+
13
+ afterEach(() => {
14
+ delete (globalThis as UploadGlobal).__DSH_FILE_UPLOAD__
15
+ vi.restoreAllMocks()
16
+ vi.unstubAllGlobals()
17
+ })
18
+
19
+ describe('file upload worker body', () => {
20
+ it('sends a Blob with credentials and reports progress, completion, and failure', () => {
21
+ const posted: unknown[] = []
22
+ const scope: {
23
+ onmessage: ((event: MessageEvent<{
24
+ url: string
25
+ body: FileUploadBody
26
+ headers: Readonly<Record<string, string>>
27
+ }>) => void) | null
28
+ postMessage(message: unknown): void
29
+ } = { onmessage: null, postMessage: (message: unknown) => { posted.push(message) } }
30
+ const xhr = {
31
+ upload: { onprogress: null as ((event: ProgressEvent) => void) | null },
32
+ status: 201,
33
+ responseText: '{"ok":true}',
34
+ withCredentials: false,
35
+ onload: null as ((event: ProgressEvent) => void) | null,
36
+ onerror: null as ((event: ProgressEvent) => void) | null,
37
+ open: vi.fn(),
38
+ setRequestHeader: vi.fn(),
39
+ send: vi.fn(),
40
+ }
41
+ fileUploadWorker(scope, () => xhr)
42
+ const body = new Blob(['large'])
43
+ scope.onmessage?.({
44
+ data: { url: 'https://harness.test/upload', body, headers: { 'content-type': 'application/octet-stream' } },
45
+ } as never)
46
+ expect(xhr.open).toHaveBeenCalledWith('POST', 'https://harness.test/upload')
47
+ expect(xhr.withCredentials).toBe(true)
48
+ expect(xhr.setRequestHeader).toHaveBeenCalledWith('content-type', 'application/octet-stream')
49
+ expect(xhr.send).toHaveBeenCalledWith(body)
50
+
51
+ xhr.upload.onprogress?.({ loaded: 2, total: 4, lengthComputable: true } as ProgressEvent)
52
+ xhr.upload.onprogress?.({ loaded: 3, total: 0, lengthComputable: false } as ProgressEvent)
53
+ xhr.onload?.({} as ProgressEvent)
54
+ xhr.onerror?.({} as ProgressEvent)
55
+ expect(posted).toEqual([
56
+ { kind: 'progress', loaded: 2, total: 4 },
57
+ { kind: 'progress', loaded: 3 },
58
+ { kind: 'complete', status: 201, body: '{"ok":true}' },
59
+ { kind: 'error', message: 'background upload transport failed' },
60
+ ])
61
+ })
62
+
63
+ it('streams Uint8Array chunks through fetch and reports consumed bytes', async () => {
64
+ const posted: unknown[] = []
65
+ const scope = {
66
+ onmessage: null as ((event: MessageEvent) => void) | null,
67
+ postMessage: (message: unknown) => { posted.push(message) },
68
+ }
69
+ const fetch = vi.fn(async (_url: string, init: RequestInit & { readonly duplex: 'half' }) => {
70
+ const chunks: number[][] = []
71
+ for await (const chunk of init.body as ReadableStream<Uint8Array>) chunks.push([...chunk])
72
+ expect(chunks).toEqual([[1, 2], [3]])
73
+ expect(init).toMatchObject({
74
+ method: 'POST',
75
+ headers: { 'x-test': 'yes' },
76
+ credentials: 'include',
77
+ duplex: 'half',
78
+ })
79
+ return new Response('stored', { status: 202 })
80
+ })
81
+ const body = new ReadableStream<Uint8Array>({
82
+ start(controller) {
83
+ controller.enqueue(Uint8Array.of(1, 2))
84
+ controller.enqueue(Uint8Array.of(3))
85
+ controller.close()
86
+ },
87
+ })
88
+ fileUploadWorker(scope, () => { throw new Error('XHR must not handle streams') }, fetch)
89
+ scope.onmessage?.({ data: { url: 'https://harness.test/upload', body, headers: { 'x-test': 'yes' } } } as never)
90
+ await vi.waitFor(() => {
91
+ expect(posted).toEqual([
92
+ { kind: 'progress', loaded: 2 },
93
+ { kind: 'progress', loaded: 3 },
94
+ { kind: 'complete', status: 202, body: 'stored' },
95
+ ])
96
+ })
97
+ })
98
+
99
+ it('propagates cancellation from the fetch body to the source stream', async () => {
100
+ const posted: unknown[] = []
101
+ const scope = {
102
+ onmessage: null as ((event: MessageEvent) => void) | null,
103
+ postMessage: (message: unknown) => { posted.push(message) },
104
+ }
105
+ const cancel = vi.fn()
106
+ const source = new ReadableStream<Uint8Array>({ cancel })
107
+ fileUploadWorker(
108
+ scope,
109
+ () => { throw new Error('unused') },
110
+ async (_url, init) => {
111
+ await (init.body as ReadableStream<Uint8Array>).cancel('fetch stopped')
112
+ return new Response('cancelled')
113
+ },
114
+ )
115
+ scope.onmessage?.({ data: { url: '/upload', body: source, headers: {} } } as never)
116
+ await vi.waitFor(() => {
117
+ expect(cancel).toHaveBeenCalledWith('fetch stopped')
118
+ expect(posted.at(-1)).toEqual({ kind: 'complete', status: 200, body: 'cancelled' })
119
+ })
120
+ })
121
+
122
+ it('reports invalid bodies, stream chunks, and fetch failures', async () => {
123
+ const posted: unknown[] = []
124
+ const scope = {
125
+ onmessage: null as ((event: MessageEvent) => void) | null,
126
+ postMessage: (message: unknown) => { posted.push(message) },
127
+ }
128
+ fileUploadWorker(scope, () => { throw new Error('unused') })
129
+ scope.onmessage?.({ data: { url: '/upload', body: 'bad', headers: {} } } as never)
130
+ expect(posted).toEqual([{ kind: 'error', message: 'background upload worker received an invalid body' }])
131
+
132
+ const badChunk = new ReadableStream({ start(controller) { controller.enqueue('bad'); controller.close() } })
133
+ fileUploadWorker(
134
+ scope,
135
+ () => { throw new Error('unused') },
136
+ async (_url, init) => {
137
+ await new Response(init.body).arrayBuffer()
138
+ return new Response()
139
+ },
140
+ )
141
+ scope.onmessage?.({ data: { url: '/upload', body: badChunk, headers: {} } } as never)
142
+ await vi.waitFor(() => {
143
+ expect(posted.at(-1)).toEqual({
144
+ kind: 'error', message: 'background upload stream produced a non-Uint8Array chunk',
145
+ })
146
+ })
147
+
148
+ const body = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
149
+ fileUploadWorker(
150
+ scope,
151
+ () => { throw new Error('unused') },
152
+ () => Promise.reject(new Error('offline')),
153
+ )
154
+ scope.onmessage?.({ data: { url: '/upload', body, headers: {} } } as never)
155
+ await vi.waitFor(() => {
156
+ expect(posted.at(-1)).toEqual({ kind: 'error', message: 'offline' })
157
+ })
158
+
159
+ const failedSource = new ReadableStream<Uint8Array>({
160
+ start(controller) { controller.error('source failed') },
161
+ })
162
+ fileUploadWorker(
163
+ scope,
164
+ () => { throw new Error('unused') },
165
+ async (_url, init) => {
166
+ await (init.body as ReadableStream<Uint8Array>).getReader().read()
167
+ return new Response()
168
+ },
169
+ )
170
+ scope.onmessage?.({ data: { url: '/upload', body: failedSource, headers: {} } } as never)
171
+ await vi.waitFor(() => {
172
+ expect(posted.at(-1)).toEqual({ kind: 'error', message: 'source failed' })
173
+ })
174
+ })
175
+
176
+ it('uses Worker globals when the emitted body supplies no test seams', async () => {
177
+ const posted: unknown[] = []
178
+ const scope = {
179
+ onmessage: null as ((event: MessageEvent) => void) | null,
180
+ postMessage: (message: unknown) => { posted.push(message) },
181
+ }
182
+ const xhr = {
183
+ upload: { onprogress: null },
184
+ status: 204,
185
+ responseText: '',
186
+ withCredentials: false,
187
+ onload: null,
188
+ onerror: null,
189
+ open: vi.fn(),
190
+ setRequestHeader: vi.fn(),
191
+ send: vi.fn(),
192
+ }
193
+ vi.stubGlobal('self', scope)
194
+ vi.stubGlobal('XMLHttpRequest', vi.fn(function () { return xhr }))
195
+ fileUploadWorker()
196
+ scope.onmessage?.({ data: { url: '/upload', body: new Blob(), headers: {} } } as MessageEvent)
197
+ expect(xhr.send).toHaveBeenCalledOnce()
198
+
199
+ const fetch = vi.fn(async (_url: string, init: RequestInit) => {
200
+ await new Response(init.body).arrayBuffer()
201
+ return new Response(null, { status: 204 })
202
+ })
203
+ vi.stubGlobal('fetch', fetch)
204
+ fileUploadWorker()
205
+ const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
206
+ scope.onmessage?.({ data: { url: '/stream', body: stream, headers: {} } } as MessageEvent)
207
+ await vi.waitFor(() => {
208
+ expect(fetch).toHaveBeenCalledOnce()
209
+ expect(posted.at(-1)).toEqual({ kind: 'complete', status: 204, body: '' })
210
+ })
211
+ })
212
+ })
213
+
214
+ describe('file upload service', () => {
215
+ it('uses a page-owned Host fetch for Blob and ReadableStream bodies', async () => {
216
+ vi.stubGlobal('location', { origin: 'https://preview.test' })
217
+ const fetch = vi.fn((_url: URL, _init?: RequestInit) =>
218
+ Promise.resolve(new Response('accepted', { status: 202 })))
219
+ ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
220
+ const ctx = new Context()
221
+ const fiber = ctx.plugin(FileUploadRuntime)
222
+ await fiber
223
+ const blob = new Blob(['opaque'])
224
+ const signal = new AbortController().signal
225
+ await expect((ctx.fileUpload as FileUploadRuntime).post({
226
+ path: '/api/upload', body: blob, headers: { 'x-test': 'yes' }, signal,
227
+ })).resolves.toEqual({ status: 202, body: 'accepted' })
228
+ expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/api/upload'), {
229
+ method: 'POST', headers: { 'x-test': 'yes' }, body: blob, signal,
230
+ })
231
+
232
+ const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
233
+ await (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream })
234
+ expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/stream'), {
235
+ method: 'POST', body: stream, duplex: 'half',
236
+ })
237
+ await fiber.dispose()
238
+ })
239
+
240
+ it('mounts through the plugin entry and resolves non-browser URLs', async () => {
241
+ vi.stubGlobal('location', { origin: 'null' })
242
+ const fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })))
243
+ ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
244
+ const ctx = new Context()
245
+ const fiber = ctx.plugin({ apply })
246
+ await fiber
247
+ const body = new Blob()
248
+ await (ctx.fileUpload as FileUploadRuntime).post({ path: '/fallback', body })
249
+ expect(fetch).toHaveBeenCalledWith(new URL('http://dsh.internal/fallback'), {
250
+ method: 'POST', body,
251
+ })
252
+ await fiber.dispose()
253
+ })
254
+
255
+ it('fails loud when a served browser has no Worker implementation', async () => {
256
+ vi.stubGlobal('Worker', undefined)
257
+ const ctx = new Context()
258
+ const fiber = ctx.plugin(FileUploadRuntime)
259
+ await fiber
260
+ await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }))
261
+ .rejects.toThrow('background upload requires Web Worker support')
262
+ await fiber.dispose()
263
+ })
264
+
265
+ it('forwards progress and completion from a dedicated Worker and then terminates it', async () => {
266
+ const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
267
+ const revoked = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
268
+ class FakeWorker {
269
+ static last: FakeWorker | undefined
270
+ onmessage: ((event: MessageEvent) => void) | null = null
271
+ onerror: ((event: ErrorEvent) => void) | null = null
272
+ readonly postMessage = vi.fn()
273
+ readonly terminate = vi.fn()
274
+ constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this }
275
+ }
276
+ vi.stubGlobal('Worker', FakeWorker)
277
+ vi.stubGlobal('location', { origin: 'https://harness.test' })
278
+ const ctx = new Context()
279
+ const fiber = ctx.plugin(FileUploadRuntime)
280
+ await fiber
281
+ const progress = vi.fn()
282
+ const blob = new Blob(['bytes'])
283
+ const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/api/upload', body: blob, onProgress: progress })
284
+ const worker = FakeWorker.last
285
+ if (worker === undefined) throw new Error('worker missing')
286
+ expect(created).toHaveBeenCalledOnce()
287
+ expect(revoked).toHaveBeenCalledWith('blob:worker')
288
+ expect(worker.postMessage).toHaveBeenCalledWith({
289
+ url: 'https://harness.test/api/upload', body: blob, headers: {},
290
+ })
291
+ worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent)
292
+ worker.onmessage?.({ data: { kind: 'progress', loaded: 6 } } as MessageEvent)
293
+ worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent)
294
+ worker.onmessage?.({ data: { kind: 'complete', status: 500, body: 'late' } } as MessageEvent)
295
+ await expect(pending).resolves.toEqual({ status: 200, body: 'done' })
296
+ expect(progress.mock.calls).toEqual([
297
+ [{ loaded: 4, total: 5 }],
298
+ [{ loaded: 6 }],
299
+ ])
300
+ expect(worker.terminate).toHaveBeenCalledOnce()
301
+ await fiber.dispose()
302
+ })
303
+
304
+ it('transfers stream ownership to the dedicated Worker', async () => {
305
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
306
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
307
+ class FakeWorker {
308
+ static last: FakeWorker | undefined
309
+ onmessage: ((event: MessageEvent) => void) | null = null
310
+ onerror: ((event: ErrorEvent) => void) | null = null
311
+ readonly postMessage = vi.fn()
312
+ readonly terminate = vi.fn()
313
+ constructor() { FakeWorker.last = this }
314
+ }
315
+ vi.stubGlobal('Worker', FakeWorker)
316
+ const ctx = new Context()
317
+ const fiber = ctx.plugin(FileUploadRuntime)
318
+ await fiber
319
+ const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
320
+ const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream })
321
+ const worker = FakeWorker.last
322
+ if (worker === undefined) throw new Error('worker missing')
323
+ expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ body: stream }), [stream])
324
+ worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent)
325
+ await expect(pending).resolves.toEqual({ status: 200, body: 'done' })
326
+ await fiber.dispose()
327
+ })
328
+
329
+ it('rejects worker messages, worker errors, and caller cancellation', async () => {
330
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
331
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
332
+ class FakeWorker {
333
+ static all: FakeWorker[] = []
334
+ onmessage: ((event: MessageEvent) => void) | null = null
335
+ onerror: ((event: ErrorEvent) => void) | null = null
336
+ readonly postMessage = vi.fn()
337
+ readonly terminate = vi.fn()
338
+ constructor() { FakeWorker.all.push(this) }
339
+ }
340
+ vi.stubGlobal('Worker', FakeWorker)
341
+ const ctx = new Context()
342
+ const fiber = ctx.plugin(FileUploadRuntime)
343
+ await fiber
344
+
345
+ const reported = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
346
+ FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent)
347
+ await expect(reported).rejects.toThrow('network failed')
348
+
349
+ const errored = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
350
+ FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent)
351
+ await expect(errored).rejects.toThrow('worker crashed')
352
+
353
+ const unnamed = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
354
+ FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent)
355
+ await expect(unnamed).rejects.toThrow('background upload worker failed')
356
+
357
+ const controller = new AbortController()
358
+ const aborted = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: controller.signal })
359
+ controller.abort()
360
+ await expect(aborted).rejects.toMatchObject({ name: 'AbortError' })
361
+ expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce()
362
+
363
+ const already = new AbortController()
364
+ already.abort()
365
+ await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: already.signal }))
366
+ .rejects.toMatchObject({ name: 'AbortError' })
367
+ expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled()
368
+ await fiber.dispose()
369
+ })
370
+ })
371
+
372
+ describe('Session-addressed file upload', () => {
373
+ const SESSION_ID = 's1' as SessionId
374
+
375
+ async function scopedService(options: {
376
+ readonly remote?: ReturnType<typeof vi.fn>
377
+ } = {}) {
378
+ const ctx = new Context()
379
+ const remote = options.remote ?? vi.fn(() => Promise.resolve({
380
+ ok: true,
381
+ value: {
382
+ receiptId: 'remote-receipt',
383
+ file: { attachmentId: 'remote-file', name: 'file', bytes: 3 },
384
+ },
385
+ }))
386
+ ctx.provide('remote', { fileUploads: { upload: remote } } as never)
387
+ const fiber = ctx.plugin(FileUploadRuntime)
388
+ await fiber
389
+ return { ctx, fiber, remote, service: ctx.fileUpload }
390
+ }
391
+
392
+ it('assembles the scoped streaming request and parses progress and receipt fields', async () => {
393
+ vi.stubGlobal('location', { origin: 'https://preview.test' })
394
+ const progress = vi.fn()
395
+ const fetch = vi.fn((_url: URL, init: RequestInit) => {
396
+ expect(init.body).toBeInstanceOf(Blob)
397
+ progress({ loaded: 2, total: 4 })
398
+ return Promise.resolve(new Response(JSON.stringify({
399
+ ok: true,
400
+ value: {
401
+ receiptId: 'receipt-1',
402
+ file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
403
+ },
404
+ }), { status: 200 }))
405
+ })
406
+ ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
407
+ const { fiber, service } = await scopedService()
408
+ const signal = new AbortController().signal
409
+ const file = new Blob(['data'])
410
+
411
+ await expect(service.upload(SESSION_ID, file, 'notes & refs.pdf', signal, progress)).resolves.toEqual({
412
+ ok: true,
413
+ value: {
414
+ receiptId: 'receipt-1',
415
+ file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
416
+ },
417
+ })
418
+ expect(fetch).toHaveBeenCalledWith(
419
+ new URL('https://preview.test/api/session/uploadFileBinary?sessionId=s1&name=notes+%26+refs.pdf'),
420
+ expect.objectContaining({
421
+ method: 'POST',
422
+ headers: { 'content-type': 'application/octet-stream' },
423
+ body: file,
424
+ signal,
425
+ }),
426
+ )
427
+ await fiber.dispose()
428
+ })
429
+
430
+ it('uses the direct Remote fallback for exact bytes', async () => {
431
+ const remote = vi.fn(() => Promise.resolve({
432
+ ok: true,
433
+ value: {
434
+ receiptId: 'remote-receipt',
435
+ file: { attachmentId: 'remote-file', name: 'bytes.bin', bytes: 3 },
436
+ },
437
+ }))
438
+ const { fiber, service } = await scopedService({ remote })
439
+ await expect(service.upload(SESSION_ID, Uint8Array.of(0, 0, 0), 'bytes.bin'))
440
+ .resolves.toMatchObject({ ok: true })
441
+ await expect(service.upload(SESSION_ID, Uint8Array.of(1)))
442
+ .resolves.toMatchObject({ ok: true })
443
+ expect(remote.mock.calls).toEqual([
444
+ [SESSION_ID, { data: 'AAAA', name: 'bytes.bin' }, undefined],
445
+ [SESSION_ID, { data: 'AQ==' }, undefined],
446
+ ])
447
+ await fiber.dispose()
448
+ })
449
+
450
+ it('rejects malformed background results', async () => {
451
+ vi.stubGlobal('location', { origin: 'https://preview.test' })
452
+ ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
453
+ fetch: () => Promise.resolve(new Response(null, { status: 413 })),
454
+ }
455
+ const rejected = await scopedService()
456
+ await expect(rejected.service.upload(SESSION_ID, new Blob()))
457
+ .rejects.toThrow('file upload transport failed with HTTP 413')
458
+ await rejected.fiber.dispose()
459
+
460
+ const bodies: unknown[] = [
461
+ null,
462
+ { ok: 'yes' },
463
+ { ok: false, error: null },
464
+ { ok: false, error: { code: 1, message: 'denied', details: {} } },
465
+ { ok: false, error: { code: 'denied', message: 1, details: {} } },
466
+ { ok: false, error: { code: 'denied', message: 'denied', details: null } },
467
+ { ok: true, value: null },
468
+ { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } },
469
+ ]
470
+ for (const body of bodies) {
471
+ ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
472
+ fetch: () => Promise.resolve(new Response(JSON.stringify(body), { status: 200 })),
473
+ }
474
+ const malformed = await scopedService()
475
+ await expect(malformed.service.upload(SESSION_ID, new Blob()))
476
+ .rejects.toThrow(/file upload transport returned an invalid/)
477
+ await malformed.fiber.dispose()
478
+ }
479
+
480
+ ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
481
+ fetch: () => Promise.resolve(new Response(JSON.stringify({
482
+ ok: false,
483
+ error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
484
+ }), { status: 200 })),
485
+ }
486
+ const failed = await scopedService()
487
+ await expect(failed.service.upload(SESSION_ID, new Blob())).resolves.toMatchObject({
488
+ ok: false,
489
+ error: {
490
+ code: 'session/attachment-invalid',
491
+ message: 'denied',
492
+ details: { reason: 'NOPE' },
493
+ },
494
+ })
495
+ await failed.fiber.dispose()
496
+ })
497
+ })
packages/client/locale/README.i18n.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/client/locale/README.md
5
+ README.md: 56a9cff9c3ec18dcc395378fcd1691207c022284
6
+ README.zh.md: 1b7a7b7de48843961f3cbac3f4ee99cff641d212
packages/client/locale/README.md ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ description: "Localization for the web GUI: the zh/en preference, browser-derived fallback, typed namespace dictionaries, and the framework translation seat, for users and plugin authors."
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-client-locale
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ Use `dsh-client-locale` to switch the web GUI between the shipped English and Chinese locales or languages added by client plugins. User selections take effect immediately; loopback pages persist them in `$DSH_HOME/settings.yaml`, while non-loopback pages keep them only for the current process. New browsers use the first supported language requested by the browser until an allowed stored preference arrives. Plugin authors add typed namespace dictionaries and translate through the public locale API; slot-rendered copy updates without a reload.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Use it wherever the web GUI needs a language switch or translated copy: the shipped settings row covers users, and plugin authors register their own dictionaries. Nothing needs configuration to mount — the package activates with the client tree.
29
+
30
+ ### Choosing a language
31
+
32
+ Open Settings → General and select a registered language. The active locale is applied immediately: the UI copy switches, `<html lang>` points at the external id or built-in document tag, and the choice is written to the durable settings section. A browser without an explicit Host preference selects the first registered language that matches `navigator` by full tag and then primary subtag, falling back to English. A stored external locale waits for its definition to register instead of becoming active while unavailable.
33
+
34
+ ### Registering a dictionary
35
+
36
+ Call `ctx.locale.register(ns, { zh, en })` with a namespace merged into `LocaleNamespaceMap`; the compiler checks every key against the namespace's typed key union and requires both shipped locales. Consumers translate through `ctx.locale.bind(ns)` or the framework-injected `t` seat. A dictionary registered after the UI is already mounted is picked up without a remount.
37
+
38
+ ### Registering a language pack
39
+
40
+ An external client plugin registers the language definition and each translated namespace as owned effects; definitions and dictionaries may register in either order:
41
+
42
+ ```js
43
+ export const inject = ['locale']
44
+
45
+ export function apply(ctx) {
46
+ ctx.effect(
47
+ () => ctx.locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }),
48
+ 'my-locale: language',
49
+ )
50
+ ctx.effect(
51
+ () => ctx.locale.register('common', 'ja', {
52
+ cancel: 'キャンセル',
53
+ close: '閉じる',
54
+ }),
55
+ 'my-locale: common dictionary',
56
+ )
57
+ }
58
+ ```
59
+
60
+ An external id is a non-empty ASCII BCP 47-style tag. Its fallback must already be registered, and the chain must terminate at `en`; unknown targets, duplicate ids, and cycles fail at registration. Lookup walks the fallback chain in the requested namespace, repeats it in `common`, then displays the key. Unloading a definition removes it from the selector and returns an active selection to the available browser/default locale.
61
+
62
+ ### What the Host half does
63
+
64
+ The Host persists the preference through the settings service on loopback pages. The Client deliberately withholds that settings scope on non-loopback pages, so their locale selection remains process-local even though Connection authenticates every API method.
65
+
66
+ -----
67
+
68
+ <a id="understand-the-implementation"></a>
69
+ ## Understand the implementation
70
+
71
+ <details>
72
+ <summary>Implementation internals — click to expand</summary>
73
+
74
+ This section explains how the locale service is built; observable behavior is covered in [Use this package](#use-this-package).
75
+
76
+ ### Design concept
77
+
78
+ One `LocaleRuntime` owns the preference and the dictionary registry, and is itself the slot system's `LocaleFace`: `getSnapshot`/`subscribe` back the framework-injected `t` seat through `ctx.slots.installLocale`. The immutable snapshot carries the active locale, the selectable locales, and a monotonic revision; dictionary registration and locale switches both advance the revision, but only a switch emits the `locale/change` event. Product-authored Client UI text must enter through these typed dictionaries or an already-localized primitive prop; `verify-client-ui-i18n` enforces that source ownership ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
79
+
80
+ ### Preference resolution
81
+
82
+ The provisional locale comes from the browser (`navigator.languages` matched by full tag and then primary subtag, English as the fallback), standing in until the allowed Host-backed settings scope delivers its stored preference. The Host read runs after plugin activation so an unavailable or withheld settings scope cannot block the page, and the result replaces the provisional value live. A stored external locale waits for its definition to register. `setLocale` is the only write entry; it persists even when the id already matches the active locale, because the active value may be provisional and must survive a different browser sharing the same home.
83
+
84
+ ### Dictionary lookup
85
+
86
+ The typed object form requires complete dictionaries for both built-in locales. The per-locale form lets language packs register each namespace independently. For each key, lookup walks the active language's declared fallback chain in the requested namespace, repeats that chain in `common`, then displays the key itself. Bound translate functions retain stable identity per namespace so they can ride inject surfaces without breaking memoization.
87
+
88
+ ### Source map
89
+
90
+ | File | Role |
91
+ |---|---|
92
+ | [`src/client/index.ts`](src/client/index.ts) | `LocaleRuntime`, dictionary registry, Language row registration, `locale/change` event |
93
+ | [`src/index.ts`](src/index.ts) | Node half: registers the `locale` settings namespace |
94
+ | [`src/locale-settings.ts`](src/locale-settings.ts) | The durable schema for `locale.preference` |
95
+ | [`src/locales/`](src/locales/) | The shipped `zh`/`en` dictionaries |
96
+
97
+ </details>
98
+
99
+ -----
100
+
101
+ <a id="further-exploration"></a>
102
+ ## Further Exploration
103
+
104
+ Read these when the locale contract is not enough: the slot face it implements, the settings surface it rides, and the persistence decision behind the preference.
105
+
106
+ - [Client slot system](../ui-slots/README.md) — the slot model and the `LocaleFace` seat this package implements.
107
+ - [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) — why the preference persists in Host settings instead of the browser.
108
+ - [Settings group map](../../settings/README.md) — the settings service that stores the preference.
109
+ - [Client group map](../README.md) — the browser half this package belongs to.
110
+
111
+ -----
112
+
113
+ <a id="model-experience"></a>
114
+ ## Model Experience
115
+
116
+ None, as the locale service is a browser-side UI plugin layer that registers nothing model-facing.
117
+
118
+ #### KV Cache effect
119
+
120
+ None; this package neither assembles nor sends a provider request.
121
+
122
+ ## Known Limitations and Deferred Work
123
+
124
+ <a id="known-limitations-and-deferred-work"></a>
125
+
126
+
127
+ These limits define where localization is incomplete or frozen at registration time. They are current package constraints, not a task backlog.
128
+
129
+ - **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live.
130
+ - **Language packs own language-specific behavior** — the registry supplies selection, persistence, browser matching, key fallback, and `<html lang>`; it does not add plural rules or bidirectional layout.
131
+
132
+ <a id="dev-note"></a>
133
+ ### Dev Note
134
+
135
+ <details>
136
+ <summary>Working context for maintainers — click to expand</summary>
137
+
138
+ None.
139
+
140
+ </details>
141
+
142
+ **Runtime invariant:** No companion is published. The locale catalog and dictionaries have no independent runtime source to compare against; registration disposal, preference resolution, and fallback lookup are asserted by behavior specs.
packages/client/locale/README.zh.md ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ description: "面向用户与插件作者的 web GUI 本地化说明:zh/en 偏好、浏览器派生回退、类型化命名空间字典与框架翻译席位。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-client-locale
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ 使用 `dsh-client-locale` 可在 web GUI 中切换内置的英文和中文 locale,或 client 插件添加的语言。用户选择会立即生效;loopback 页面把选择持久化到 `$DSH_HOME/settings.yaml`,非 loopback 页面则只为当前进程保留选择。全新浏览器会使用浏览器请求的第一个受支持语言,直到允许读取的已存储偏好到达。插件作者可添加类型化命名空间字典,并通过公开 locale API 翻译;经 slot 渲染的文案无需重新加载即可随语言切换更新。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与暂缓事项](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 只要 web GUI 需要语言切换或翻译文案就使用它:已发布的设置行覆盖用户侧,插件作者则注册自己的字典。挂载无需任何配置——本包随客户端树一起激活。
29
+
30
+ ### 选择语言
31
+
32
+ 打开“设置 → 常规”并选择一种已注册语言。生效中的 locale 会立即应用:UI 文案切换、`<html lang>` 指向外部 id 或内置语言的文档标签,选择写入持久设置分区。没有显式 Host 偏好的浏览器会按完整标签、再按主语言子标签选择 `navigator` 请求的第一个已注册语言,无法匹配时回退到英文。已存储的外部 locale 会等待其定义注册,不会在不可用时生效。
33
+
34
+ ### 注册字典
35
+
36
+ 用已合并进 `LocaleNamespaceMap` 的命名空间调用 `ctx.locale.register(ns, { zh, en })`;编译器会对照该命名空间的类型化键并集检查每个键,并要求两个内置 locale 齐全。消费方通过 `ctx.locale.bind(ns)` 或框架注入的 `t` 席位翻译。UI 已挂载后再注册的字典无需重新挂载即可生效。
37
+
38
+ ### 注册语言包
39
+
40
+ 外部 client 插件把语言定义和每个已翻译命名空间注册为自身拥有的 effect;定义与字典可以按任意顺序注册:
41
+
42
+ ```js
43
+ export const inject = ['locale']
44
+
45
+ export function apply(ctx) {
46
+ ctx.effect(
47
+ () => ctx.locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }),
48
+ 'my-locale: language',
49
+ )
50
+ ctx.effect(
51
+ () => ctx.locale.register('common', 'ja', {
52
+ cancel: 'キャンセル',
53
+ close: '閉じる',
54
+ }),
55
+ 'my-locale: common dictionary',
56
+ )
57
+ }
58
+ ```
59
+
60
+ 外部 id 必须是非空的 ASCII BCP 47 风格标签。它的 fallback 必须已经注册,且整条链必须终止于 `en`;未知目标、重复 id 与循环会在注册时失败。查找时先在请求命名空间内遍历生效语言的 fallback 链,再在 `common` 中遍历该链,最后显示键本身。卸载语言定义会将其从选择器移除,并让生效中的选择回落到可用的浏览器语言或默认语言。
61
+
62
+ ### Host 半侧做什么
63
+
64
+ Host 通过 settings 服务为 loopback 页面持久化偏好。Client 会刻意拒绝非 loopback 页面使用该 settings scope,因此即使 Connection 认证所有 API 方法,它们的 locale 选择仍只存在于进程内。
65
+
66
+ -----
67
+
68
+ <a id="understand-the-implementation"></a>
69
+ ## 理解实现
70
+
71
+ <details>
72
+ <summary>实现细节——点击展开</summary>
73
+
74
+ 本节解释 locale 服务的构建方式;可观察行为已在[使用本包](#use-this-package)中说明。
75
+
76
+ ### 设计理念
77
+
78
+ 一个 `LocaleRuntime` 同时拥有偏好与字典注册表,并且自身就是 slot 系统的 `LocaleFace`:`getSnapshot`/`subscribe` 通过 `ctx.slots.installLocale` 支撑框架注入的 `t` 席位。不可变快照携带生效中的 locale、可选择的 locale 列表与单调 revision;字典注册与 locale 切换都会推进 revision,但只有切换会发出 `locale/change` 事件。产品编写的 Client UI 文本必须来自这些带类型的字典,或来自已经本地化的 primitive prop;`verify-client-ui-i18n` 强制执行该源码归属(见[决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。
79
+
80
+ ### 偏好解析
81
+
82
+ 临时 locale 来自浏览器(`navigator.languages` 先按完整标签、再按主语言子标签匹配,以英文作为回退),在允许使用的 Host-backed settings scope 送达其存储偏好之前生效。Host 读取在插件激活后运行,因此 settings scope 不可用或被拒绝都不会阻塞页面,结果会实时替换临时值。已存储的外部 locale 会等待其定义注册。`setLocale` 是唯一写入入口;即使 id 已与生效中的 locale 匹配也会持久化,因为生效中的值可能是临时的,必须能供共享同一 home 的其他浏览器继续使��。
83
+
84
+ ### 字典查找
85
+
86
+ 带类型的对象形式要求两个内置 locale 都有完整字典;逐 locale 形式允许语言包独立注册每个命名空间。逐键查找会先在请求命名空间中沿生效语言声明的 fallback 链查找,再在 `common` 中重复该链,最后显示键本身。绑定的翻译函数按命名空间保持稳定身份,因此可通过 inject 机制传递,且不会破坏 memoization。
87
+
88
+ ### 源码地图
89
+
90
+ | 文件 | 职责 |
91
+ |---|---|
92
+ | [`src/client/index.ts`](src/client/index.ts) | `LocaleRuntime`、字典注册表、Language 行注册、`locale/change` 事件 |
93
+ | [`src/index.ts`](src/index.ts) | node 半侧:注册 `locale` 设置命名空间 |
94
+ | [`src/locale-settings.ts`](src/locale-settings.ts) | `locale.preference` 的持久 schema |
95
+ | [`src/locales/`](src/locales/) | 内置的 `zh`/`en` 字典 |
96
+
97
+ </details>
98
+
99
+ -----
100
+
101
+ <a id="further-exploration"></a>
102
+ ## 进一步探索
103
+
104
+ 当仅阅读 locale 约定不足以解答问题时,请继续阅读以下页面,了解它实现的 slot 接口、依托的设置机制,以及偏好背后的持久化决策。
105
+
106
+ - [客户端 slot 系统](../ui-slots/README.zh.md)——本包实现的 slot 模型与 `LocaleFace` 席位。
107
+ - [Host 支撑偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)——偏好为何持久化在 Host 设置中而非浏览器里。
108
+ - [设置组地图](../../settings/README.zh.md)——存储该偏好的设置服务。
109
+ - [客户端组地图](../README.zh.md)——本包所属的浏览器半侧。
110
+
111
+ -----
112
+
113
+ <a id="model-experience"></a>
114
+ ## 模型体验
115
+
116
+ 无。locale 服务属于浏览器侧 UI 插件层,不注册任何面向模型的内容。
117
+
118
+ #### KV Cache 影响
119
+
120
+ 无;该包既不组装也不发送提供方请求。
121
+
122
+ ## 已知限制与暂缓事项
123
+
124
+ <a id="known-limitations-and-deferred-work"></a>
125
+
126
+
127
+ 这些限制说明本地化在哪些地方不完整,或在注册时被冻结。它们是当前包约束,不是待办事项清单。
128
+
129
+ - **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。
130
+ - **语言包负责语言特有行为**——注册表提供选择、持久化、浏览器匹配、逐键回退和 `<html lang>`;它不增加复数规则或双向布局。
131
+
132
+ <a id="dev-note"></a>
133
+ ### 开发备注
134
+
135
+ <details>
136
+ <summary>维护者的工作上下文——点击展开</summary>
137
+
138
+ 无。
139
+
140
+ </details>
141
+
142
+ **运行时不变式:** 不发布伴生入口。locale catalog 与字典没有可供交叉核对的独立运行时来源;注册释放、偏好解析和 fallback 查找由行为测试覆盖。
packages/client/locale/package.json ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@deepseek-ai/dsh-client-locale",
3
+ "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries",
4
+ "version": "0.1.6-alpha.1",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/client/locale"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./client": {
22
+ "types": "./lib/types/client/index.d.ts",
23
+ "default": "./lib/client.js"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "dsh": {
29
+ "client": {
30
+ "inject": [
31
+ "@deepseek-ai/dsh-client-connection",
32
+ "@deepseek-ai/dsh-client-ui-renderer",
33
+ "@deepseek-ai/dsh-client-ui-settings",
34
+ "@deepseek-ai/dsh-api-remotes"
35
+ ],
36
+ "platform": "web",
37
+ "immediately": true
38
+ }
39
+ },
40
+ "license": "MIT",
41
+ "peerDependencies": {
42
+ "@deepseek-ai/cordis": "workspace:^"
43
+ },
44
+ "devDependencies": {
45
+ "@deepseek-ai/cordis": "workspace:^",
46
+ "@deepseek-ai/dsh-api-remotes": "workspace:^",
47
+ "@deepseek-ai/dsh-client-store": "workspace:^",
48
+ "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
49
+ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
50
+ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
51
+ "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
52
+ "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
53
+ "@types/react": "~18.3.1",
54
+ "react": "^18.2.0",
55
+ "@deepseek-ai/dsh-client-connection": "workspace:^",
56
+ "@deepseek-ai/dsh-settings": "workspace:^"
57
+ },
58
+ "dependencies": {
59
+ "@deepseek-ai/schemastery": "workspace:^"
60
+ },
61
+ "files": [
62
+ "lib/index.js",
63
+ "lib/client.js",
64
+ "lib/types/**/*.d.ts"
65
+ ],
66
+ "scripts": {
67
+ "bundle": "tsdown",
68
+ "watch": "tsdown --watch"
69
+ }
70
+ }