Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- packages/client/connection/README.i18n.yaml +6 -0
- packages/client/connection/README.md +81 -0
- packages/client/connection/README.zh.md +81 -0
- packages/client/connection/package.json +57 -0
- packages/client/connection/src/api-path.ts +7 -0
- packages/client/connection/src/api-request-trust.ts +118 -0
- packages/client/connection/src/browser-auth.ts +313 -0
- packages/client/connection/src/client/api.ts +25 -0
- packages/client/connection/src/client/connection.ts +323 -0
- packages/client/connection/src/client/index.ts +324 -0
- packages/client/connection/src/client/random-uuid.ts +14 -0
- packages/client/connection/src/client/rpc.ts +120 -0
- packages/client/connection/src/http-bridge.ts +111 -0
- packages/client/connection/src/index.ts +143 -0
- packages/client/connection/src/loopback-hostname.ts +18 -0
- packages/client/connection/src/recovery-config.ts +44 -0
- packages/client/connection/src/rpc-host.ts +303 -0
- packages/client/connection/src/rpc-schema.ts +53 -0
- packages/client/connection/src/rpc.ts +251 -0
- packages/client/connection/tests/api-helpers.client.spec.ts +21 -0
- packages/client/connection/tests/api-request-trust.host.spec.ts +115 -0
- packages/client/connection/tests/browser-auth.host.spec.ts +250 -0
- packages/client/connection/tests/browser-credentials.ts +36 -0
- packages/client/connection/tests/client-apply.client.spec.ts +620 -0
- packages/client/connection/tests/connection.client.spec.ts +800 -0
- packages/client/connection/tests/fake-generation.client.ts +80 -0
- packages/client/connection/tests/fetch-routes.host.spec.ts +83 -0
- packages/client/connection/tests/generation.client.spec.ts +65 -0
- packages/client/connection/tests/http-bridge.host.spec.ts +147 -0
- packages/client/connection/tests/loopback-hostname.client.spec.ts +18 -0
- packages/client/connection/tests/node-half.host.spec.ts +565 -0
- packages/client/connection/tests/recovery-config.client.spec.ts +32 -0
- packages/client/connection/tests/rpc-schema.host.spec.ts +58 -0
- packages/client/connection/tsconfig.client.json +42 -0
- packages/client/connection/tsconfig.host.json +43 -0
- packages/client/connection/tsconfig.json +11 -0
- packages/client/connection/tsdown.config.ts +3 -0
- packages/client/file-upload/src/client/contract.ts +33 -0
- packages/client/file-upload/src/client/index.ts +26 -0
- packages/client/file-upload/src/client/runtime.ts +345 -0
- packages/client/file-upload/src/http-route.ts +81 -0
- packages/client/file-upload/src/index.ts +265 -0
- packages/client/file-upload/src/protocol.ts +2 -0
- packages/client/file-upload/src/types.ts +35 -0
- packages/client/file-upload/tests/file-upload-http.host.spec.ts +126 -0
- packages/client/file-upload/tests/file-upload.client.spec.ts +497 -0
- packages/client/locale/README.i18n.yaml +6 -0
- packages/client/locale/README.md +142 -0
- packages/client/locale/README.zh.md +142 -0
- 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 |
+
}
|