Merge pull request #5945 from deepseek-harness/worktree/client-control/operator-connect

feat(connection): scope the connect handshake to one operator
This commit is contained in:
imccyu authored and GitHub committed 2026-10-14 16:56:53 +08:00
commit a941635df3
99 files changed
+2351 -265

No files matched your search

@@ -9,8 +9,8 @@
en: 01293500639c9d79
zh: 6d19e4843955c811
/agent-note-browser-launch-token-authentication/decision:
en: 54efa59339f75256
zh: 0f53ead3844151c6
en: 51885594bdeb6f0c
zh: 9c2e41ad79ccdd08
/agent-note-browser-launch-token-authentication/verification:
en: 189b36d0fc5f3a17
zh: 08993cb585204fc0
@@ -10,6 +10,8 @@ The Web Host runs tool-capable Sessions with the current operating-system user's
## Decision
Browser callers follow this decision; independent CLI/MCP admission is owned by the [API connect decision](2026-10-09-operator-scoped-connect-handshake.md).
`dsh-client-connection` authenticates the complete Host API before dispatch. Every API Proxy method, Remote unary call, generic Connection channel, and Remote WebSocket stream requires the same browser session; endpoint ownership and method names do not alter authority. The existing Host/Origin checks run first and retain their DNS-rebinding and cross-site-request role, returning 403 when they fail. A trusted Host without a valid browser session receives 401. The browser-trust rules remain owned by the [carrier-level browser trust decision](2026-07-28-api-browser-trust-boundary.md).
Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `./`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `./`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public.
@@ -10,6 +10,8 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session
## 决策
浏览器调用方遵循本决策;独立 CLI/MCP 接纳由 [API connect 决策](2026-10-09-operator-scoped-connect-handshake.zh.md)定义。
`dsh-client-connection` 在分发前认证完整 Host API。每个 API Proxy 方法、Remote 一元调用、通用 Connection channel 和 Remote WebSocket stream 都要求同一个浏览器会话;endpoint 所有权与方法名称不改变 authority。既有 Host/Origin 校验先执行,继续负责 DNS rebinding 和跨站请求防御,失败时返回 403。Host 可信但没有有效浏览器会话时返回 401。浏览器信任规则仍由[载体级浏览器信任决策](2026-07-28-api-browser-trust-boundary.zh.md)持有。
每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `./`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `./`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。
@@ -42,8 +42,8 @@
en: a7180d3c194cf9d9
zh: 0dd7833e0e5d0a71
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/peerscope:
en: a162af337571f56e
zh: 0ae0b9d62d2cb16e
en: 182634406ed2b25b
zh: 0bd5900a39802224
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/client-face:
en: 9f10347a73402b8b
zh: b939c7a254a8e579
@@ -188,16 +188,18 @@ export interface PeerScope {
readonly principal: Principal
/** Aborted once the Peer is disposed. */
readonly signal: AbortSignal
/** Participation in forwarded interactions; independent of RBAC. */
readonly interactions: 'answer' | 'none'
dispose(): Promise<void>
}
```
`@deepseek-ai/dsh-client-connection` implements the one Peer class, `Peer(owner, { principal })`, with `createScope(owner, peer)`, the same mechanism an Agent uses to create its own scope: the Peer object is the ScopeKey, `peer.ctx` carries connection-lifetime effects, `peer.signal` aborts when the scope ends, and `dispose()` quiets the fiber. Connection creates `connection.browserPeer` with the owner principal when the service applies; [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns principals, `createPeer()`, and access checks.
`@deepseek-ai/dsh-client-connection` implements the one Peer class, `Peer(owner, { principal, interactions? })`, with `createScope(owner, peer)`, the same mechanism an Agent uses to create its own scope: the Peer object is the ScopeKey, `peer.ctx` carries connection-lifetime effects, `peer.signal` aborts when the scope ends, and `dispose()` quiets the fiber. Connection creates `connection.browserPeer` with the owner principal when the service applies; [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns principals, `createPeer()`, and access checks.
| Member | Semantics |
| --- | --- |
| `connection.browserPeer: PeerScope` | The Peer every authenticated browser connection shares; owner principal; lives and dies with the Connection |
| `connection.admit(request): PeerAdmission` | Runs the existing `requestRejection`: returns `{ rejection: 401 \| 403 }` when refused, otherwise `{ peer: browserPeer }`. Future admitters plug in here |
| `connection.admit(request): PeerAdmission` | Checks Host/Origin and signed browser or API Cookies; returns `{ rejection: 401 \| 403 }` or the credential's live Peer; connect publishes the API Peer and its lifecycle socket releases it |
The Gateway's `handleUpgrade(req, socket, head, peer)` binds an opener with a fixed Peer to that socket and registers socket closure in `peer.ctx.effect`; when the scope is already inactive, it closes the socket directly with 1001 instead. `InvokeRemoteRequest.peer` and the `peer` parameter of `RemoteStreamOpener` and `wireStream.open` are required, so every in-process caller names its Peer, and the Gateway creates none. Looking up a Peer by id and Peer open and close events wait until the first consumer that needs them appears.
@@ -188,16 +188,18 @@ export interface PeerScope {
readonly principal: Principal
/** Aborted once the Peer is disposed. */
readonly signal: AbortSignal
/** Participation in forwarded interactions; independent of RBAC. */
readonly interactions: 'answer' | 'none'
dispose(): Promise<void>
}
```
`@deepseek-ai/dsh-client-connection` 实现唯一的 Peer 类 `Peer(owner, { principal })`,用 `createScope(owner, peer)` 建立,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,scope 结束时 `peer.signal` 中止,`dispose()` 让 fiber 静默。Connection 在服务 apply 时以 owner principal 创建 `connection.browserPeer`;principal、`createPeer()` 与访问检查归 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)。
`@deepseek-ai/dsh-client-connection` 实现唯一的 Peer 类 `Peer(owner, { principal, interactions? })`,用 `createScope(owner, peer)` 建立,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,scope 结束时 `peer.signal` 中止,`dispose()` 让 fiber 静默。Connection 在服务 apply 时以 owner principal 创建 `connection.browserPeer`;principal、`createPeer()` 与访问检查归 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)。
| 成员 | 语义 |
| --- | --- |
| `connection.browserPeer: PeerScope` | 所有通过认证的浏览器连接共享的 Peer;principal 为 owner;随 connection 生灭 |
| `connection.admit(request): PeerAdmission` | 过既有的 `requestRejection`:被拒返回 `{ rejection: 401 \| 403 }`,否则返回 `{ peer: browserPeer }`。将来的接纳方在这里接入 |
| `connection.admit(request): PeerAdmission` | 检查 Host/Origin 与浏览器或 API 签名 Cookie;返回 `{ rejection: 401 \| 403 }` 或凭据对应的存活 Peer;API Peer 在 connect 时发布,并由其生命周期 socket 负责释放 |
网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener,并在 `peer.ctx.effect` 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。`InvokeRemoteRequest.peer`、`RemoteStreamOpener` 与 `wireStream.open` 的 `peer` 形参均为 required,每个进程内调用方都显式给出 Peer,网关不自建 Peer。按 id 查 Peer、Peer 的开关事件,都等第一个需要它们的消费者出现时再加。
@@ -0,0 +1,31 @@
# Bilingual-pair consistency record for 2026-10-09-operator-scoped-connect-handshake.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-09-operator-scoped-connect-handshake.md
/agent-note-peer-scoped-connect-handshake:
en: 90137ec146d1a60e
zh: 3dbb0c96426beb36
/agent-note-peer-scoped-connect-handshake/problem:
en: 3658160e034e0e58
zh: 980d922aded8713b
/agent-note-peer-scoped-connect-handshake/decision:
en: 414dfe949a53c4d9
zh: 7e6efdd57f2eb240
/agent-note-peer-scoped-connect-handshake/decision/admission-and-ownership:
en: 95ad4c8c433fdc9b
zh: 379ccd5e077d7dee
/agent-note-peer-scoped-connect-handshake/decision/accepted-retention:
en: f58c1b39855740da
zh: 99343095a357fa3f
/agent-note-peer-scoped-connect-handshake/decision/events-and-client-scope:
en: e0bdf2f82d79d2a8
zh: 5df84ee473916161
/agent-note-peer-scoped-connect-handshake/alternatives-considered:
en: bcc97e06d4565be1
zh: d11e9e9680b26c03
/agent-note-peer-scoped-connect-handshake/verification:
en: 81ce03ab4e1fd0d4
zh: c2729e7e2db56f67
/agent-note-peer-scoped-connect-handshake/consequences:
en: 91f5f80f733c174c
zh: 76cbb31b0d2e8764
@@ -0,0 +1,55 @@
# Agent Note: Peer-scoped connect handshake
Status: implemented
English | [中文](2026-10-09-operator-scoped-connect-handshake.zh.md)
## Problem
Automatically subscribing automation clients to forwarded events lets method-only callers receive and answer human interactions. CLI and MCP callers need independent participation and cleanup without changing browser authentication or assigning per-browser identities.
## Decision
Browsers retain their existing signed Cookie, root launch-token exchange, shared `connection.browserPeer` with owner principal and `interactions: 'answer'`, and recovery behavior. Only API callers create independent Peers through `GET /connect?token=<launch-token>&interactions=answer|none`. The endpoint is API-only, defaults to `none`, rejects invalid or repeated declarations with 400, and retains Host/Origin checks and token authentication. It returns 302 with the Remote Mux destination and an API Cookie.
### Admission and ownership
- Connect publishes the API Peer before returning. Its signed API Cookie carries the authority, Peer id, and issue/expiry interval; interaction participation belongs to the live Peer, not another credential field. Browser v1 Cookies remain valid and always select the shared Peer. API v2 Cookies select only an existing independent Peer.
- HTTP validates the Cookie and finds its Peer without waiting for a WebSocket. Connect, HTTP, and WebSocket require no cross-transport readiness ordering.
- One API lifecycle WebSocket owns the Peer. Duplicate attachment is refused; closure removes the Peer before asynchronous disposal completes. Later HTTP and reconnect attempts using that API Cookie are refused. A fresh connect is caller-owned.
- Already-admitted unary requests retain their request-level cancellation semantics. Closing the lifecycle socket does not cancel independent HTTP requests or accepted Session work.
- Browser sockets never own the shared Peer. Refresh, concurrent tabs, repeated launch-URL visits, and Host restart require no per-browser Peer restoration or credential reuse machinery.
### Accepted retention
An API caller may exit after connect without establishing its WebSocket. If it has performed no other work, its Peer retains only lightweight identity, scope, and registry state, not a socket, subscription, or background job. It may remain until Host shutdown. No initial-connect deadline, idle sweep, or in-memory expiry timer exists; Cookie expiration is checked on admission independently of record retention. Repeated abandoned connects accumulate lightweight records, an accepted cost for authenticated local callers. Actual operations still own cleanup of resources they acquire.
### Events and Client scope
The Client creates forwarded-event machinery only for `answer`. The Host checks `peer.signal.aborted` first, then `peer.interactions` at both `$events` and `$events/result`: `none` refuses both regardless of principal. An answering owner receives forwarded waterfalls and may reply only through the same Peer as the stream; an answering non-owner receives only RBAC-authorized broadcasts and cannot reply to waterfalls. Only active owner answering streams count as live answering clients. Browsers remain one shared answering party; distinguishing browser credentials from each other is deliberately unsupported.
Non-page Clients supply HTTP and WebSocket transports using the same API Cookie and Host base URL. Passive clients open their lifecycle socket independently of unary calls and may call generated raw stream methods. They do not use `$stream()`, the browser-generation recovery helper: it rejects immediately in `none` mode. Raw stream failure is terminal; the caller owns any new connect and retry. Existing answering-client generation behavior and Host fact delivery remain unchanged; there is no additional API recovery subsystem.
## Alternatives considered
**Independent Peers for browsers too.** This adds credential identities, restart reconstruction, and repeated-login reuse to preserve tabs. Browser callers can remain one party, so the original browser model stays intact.
**Publish only after upgrade or require Mux readiness before unary.** Both reject or delay valid HTTP that arrives first. Connect is the publication point.
**Deadlines and pending/bound activation states.** They reclaim lightweight idle records at the cost of timers and transition races. Unused Peers may remain until shutdown.
**Adapt browser stream recovery to passive callers.** This spreads interaction mode into generation-aware stream consumers and their tests. Raw streams already support CLI/MCP calls; passive callers manage failure themselves.
**Client-only events switch or Host-global configuration.** Neither enforces independent participation. The authenticated Peer supplies the mode checked by the Host.
## Verification
Connection tests cover API publication, Cookie verification, idle retention, socket ownership, and unchanged browser admission. The [Loader tests](../../../../packages/api/gateway/tests/operator-connect.host.spec.ts) cover HTTP before WebSocket, duplicate attachment, prompt removal during slow cleanup, unary cancellation independence, and event refusal/ownership. [Node Client tests](../../../../packages/api/gateway/tests/authenticated-node.client.spec.ts) cover authenticated transports, delayed socket establishment, and raw streams without passive recovery.
The [recorded approval scenario](../../../../apps/web/tests/approval-composer.e2e.ts) adds a passive caller alongside the answering browser. Full replay requires a usable command sandbox for its file-writing operation; local environments without one cannot validate that final step. No Session format or model-visible input changes.
## Consequences
Unconnected API records need not be promptly reclaimed; resource-heavy additions to idle Peers require revisiting that choice. API Cookies cannot restore a Peer after its socket closes or the Host restarts. Browser Cookie format and authentication behavior remain unchanged, with no reauthentication migration. API and browser Cookies use the same authority-specific name, so API callers keep their own Cookie storage rather than exchanging credentials in a shared browser jar. Any launch-token holder may request `answer`; this is interaction participation, not user isolation.
The [duplex-stream decision](2026-09-19-remote-duplex-stream.md) retains Peer scopes and stream framing. The [browser authentication decision](2026-08-24-browser-token-authentication.md) retains browser token, signature, authority, and lifetime semantics; only independent API admission is added here. Both records remain active.
@@ -0,0 +1,55 @@
# Agent Note: 以 Peer 为作用域的建连握手
Status: implemented
[English](2026-10-09-operator-scoped-connect-handshake.md) | 中文
## 问题
让自动化客户端自动订阅转发事件,会使只需调用方法的客户端也收到并回答人机交互。CLI 与 MCP 调用方需要独立参与和清理机制,而不改变浏览器认证或为每个浏览器赋予独立身份。
## 决策
浏览器保留现有签名 Cookie、根路径 launch token 交换、共享 `connection.browserPeer` 和恢复行为。只有 API 调用方通过 `GET /connect?token=<launch-token>&interactions=answer|none` 创建独立 Peer。该端点仅供 API 使用,默认 `none`,非法或重复声明返回 400,并保留 Host/Origin 检查和 token 认证。它返回 302、Remote Mux 目标地址和 API Cookie。
### 接纳与所有权
- Connect 在返回前发布 API Peer。签名 API Cookie 包含 authority、Peer 标识和签发/过期区间;交互参与模式属于有效 Peer,不再重复写入凭证字段。浏览器 v1 Cookie 保持有效,始终选择共享 Peer;API v2 Cookie 只选择已存在的独立 Peer。
- HTTP 校验 Cookie 并查找 Peer,不等待 WebSocket。Connect、HTTP 与 WebSocket 不要求跨传输的就绪顺序。
- 一条 API 生命周期 WebSocket 拥有 Peer。重复关联被拒绝;关闭时在异步释放完成前移除 Peer。之后使用该 API Cookie 的 HTTP 与重连请求被拒绝,调用方负责重新 connect。
- 已受理的 unary 请求保留请求级取消语义。生命周期连接关闭不会取消独立 HTTP 请求或已接受的 Session 工作。
- 浏览器 socket 不拥有共享 Peer。刷新、多标签页、重复打开启动 URL 和 Host 重启不需要每浏览器 Peer 的恢复或凭证复用机制。
### 接受的保留行为
API 调用方可能在 connect 后退出,始终没有建立 WebSocket。如果它没有执行其他工作,Peer 仅保留轻量身份、作用域与登记状态,不持有 socket、订阅或后台任务,可以保留到 Host 退出。不设置首次建联期限、空闲扫描或内存过期定时器;Cookie 过期在接纳时检查,与记录保留无关。反复放弃 connect 会累积轻量记录,这是已认证本地调用方场景下接受的成本。实际操作仍负责清理其获取的资源。
### 事件与 Client 范围
Client 仅在 `answer` 模式创建转发事件机制。Host 首先检查 `peer.signal.aborted`,随后在 `$events` 与 `$events/result` 两处检查 `peer.interactions`:无论 principal 是什么,`none` 都被拒绝。回答型 owner 收到转发的 waterfall,且只能从拥有事件流的同一 Peer 回答;回答型非 owner 仅收到经 RBAC 授权的广播,不能回答 waterfall。只有未取消的 owner 回答流计入在线回答客户端。浏览器仍是同一个共享回答方;本设计明确不区分不同浏览器凭证的身份。
非页面 Client 提供使用同一 API Cookie 与 Host 基址的 HTTP 和 WebSocket 传输。被动客户端独立于 unary 调用打开生命周期连接,也可以调用生成的原始流方法。它们不使用浏览器代际恢复辅助方法 `$stream()`:该方法在 `none` 模式立即拒绝。原始流失败直接结束;调用方负责重新 connect 和重试。既有回答型客户端的代际行为与 Host facts 下发不变,不另建 API 恢复系统。
## 备选方案
**也为浏览器创建独立 Peer。** 为保留多标签页行为,这需要凭证身份、重启重建和重复登录复用。浏览器调用方可以保持为同一方,因此保留原模型。
**升级后才发布,或在 unary 前等待 Mux 就绪。** 两者都会拒绝或延迟先到达的有效 HTTP。Connect 才是发布时机。
**建联期限和 pending/bound 激活状态。** 它们以定时器和状态转换竞争为代价回收轻量空闲记录。未使用的 Peer 可以留到退出。
**为被动调用方适配浏览器流恢复。** 这会把交互模式传入依赖代际的流消费者及其测试。原始流已支持 CLI/MCP 调用,被动调用方自行处理失败。
**仅客户端事件开关或 Host 全局配置。** 两者都不能强制独立参与规则。由已认证 Peer 提供 Host 检查的模式。
## 验证
Connection 测试覆盖 API 发布、Cookie 校验、闲置保留、连接所有权和不变的浏览器接纳。[Loader 测试](../../../../packages/api/gateway/tests/operator-connect.host.spec.ts)覆盖 HTTP 先于 WebSocket、重复关联、慢清理期间及时移除、unary 取消独立性和事件拒绝/所有权。[Node Client 测试](../../../../packages/api/gateway/tests/authenticated-node.client.spec.ts)覆盖认证传输、延迟建联和不使用被动恢复的原始流。
[录制审批场景](../../../../apps/web/tests/approval-composer.e2e.ts)在回答页面旁增加一个被动调用方。完整回放需要可用命令 sandbox 执行写文件;缺少该后端的本地环境无法验证最后一步。本次不改变 Session 格式或模型可见输入。
## 后果
未建联的 API 记录不要求及时回收;若为空闲 Peer 增加重资源,必须重新评估这一取舍。连接关闭或 Host 重启后,API Cookie 不能恢复 Peer。浏览器 Cookie 格式与认证行为保持不变,无需重新认证迁移。API 与浏览器 Cookie 使用同一 authority 专属名称,因此 API 调用方维护自己的 Cookie 存储,不在共享浏览器 Cookie jar 中交换凭证。任何 launch token 持有者都可以请求 `answer`;这是交互参与,不是用户隔离。
[双工流决策](2026-09-19-remote-duplex-stream.zh.md)继续定义 Peer 作用域和流帧格式。[浏览器认证决策](2026-08-24-browser-token-authentication.zh.md)保留浏览器 token、签名、authority 与有效期语义;本记录只增加独立 API 接纳。两份既有记录继续保留。
@@ -18,8 +18,8 @@
en: 6663ea33561eee82
zh: 722fd3b8fae0ef90
/agent-note-peer-principals-and-method-level-remote-access-control/decision/peer-model:
en: db5b0cf33679e5ed
zh: 1b04328268a95abc
en: d9ac7c94e40c2494
zh: fa6f5e6690b2fb90
/agent-note-peer-principals-and-method-level-remote-access-control/decision/identity-types:
en: 31430c5b95295ead
zh: 8f4ba35b4406558d
@@ -69,8 +69,8 @@
en: e9a2e0d1534b8fe7
zh: 085813c0b2b3c0d8
/agent-note-peer-principals-and-method-level-remote-access-control/decision/out-of-scope-and-future-work:
en: 4bb0314c20e4f25c
zh: 6ede1dd2193a01d7
en: a84e07716719e3b5
zh: 9649ccb4a035a1df
/agent-note-peer-principals-and-method-level-remote-access-control/decision/relation-to-existing-notes:
en: 305ec45a7cd85ed7
zh: d2cd4209a56b1d0c
@@ -57,12 +57,14 @@ export interface PeerScope {
readonly principal: Principal
/** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */
readonly signal: AbortSignal
/** Forwarded human-interaction participation; independent of authorization. */
readonly interactions: 'answer' | 'none'
dispose(): Promise<void>
}
// @deepseek-ai/dsh-client-connection
export class Peer implements PeerScope {
constructor(owner: Context, options: { readonly principal: Principal })
constructor(owner: Context, options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' })
}
interface HostConnectionHandle {
@@ -71,7 +73,7 @@ interface HostConnectionHandle {
/** Network admission: the admitted Peer or the refusal status. */
admit(request: ConnectionTrustRequest): PeerAdmission
/** In-process only; disposed with the Context that read `ctx.connection`, or earlier by dispose(). */
createPeer(options: { readonly principal: Principal }): PeerScope
createPeer(options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' }): PeerScope
}
interface ConnectionFetchHandler {
@@ -90,6 +92,7 @@ wireStream.open(endpoint, payload, uplink, peer: PeerScope, signal) //
| Browser connections share `browserPeer` | Every browser connection shares one Peer, and every use site names `connection.browserPeer` explicitly; there is no fallback variable. The in-page Host (webworker-runtime) also passes `browserPeer` explicitly |
| In-process Host automation | Code that calls Remote methods creates its own Peer with `createPeer({ principal })`. No production caller exists yet, so no fixed Host Peer is pre-provisioned |
| Multiple Peers | Each `createPeer()` returns an independent Peer; liveshare is to create one per viewer connection (wired on the #3854 side) |
| Interaction participation | `Peer(owner, { principal, interactions? })` and `createPeer()` default omitted `interactions` to `answer`. `none` refuses `$events` and `$events/result` for every principal, even owner; this is independent of method-level RBAC. Answering owner Peers receive waterfalls, and non-owner Peers receive only RBAC-authorized broadcasts and cannot answer |
| Exact Fetch routes | Exact routes such as file-upload, present-open, media-references, session-log-export, and inspector bypass the Gateway; the shared Fetch handler answers 403 to every non-owner Peer |
### Identity types
@@ -547,9 +550,9 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R
### Out of scope and future work
- The `/connect` handshake, API cookies, one Peer per API connection, and WebSocket lifetimes belong to pr-5945.
- The API-only `/connect` handshake, API cookies, one Peer per API connection, and WebSocket lifetimes are described in [Peer-scoped connect handshake](2026-10-09-operator-scoped-connect-handshake.md).
- Multiple browser Peers (such as one per tab): every browser connection shares `browserPeer`.
- Interaction participation (interactions) and approval isolation by principal come with later work; non-owners currently receive only authorized `$events` broadcast frames.
- Interaction participation is attached to each Peer. A non-owner with `interactions: 'answer'` still receives only RBAC-authorized broadcast frames and cannot answer owner waterfalls; per-principal approval handling remains outside this mechanism.
- `agent-preset/selected` and the other forwarded events declare no resources and are decided on `global`; when its viewers need a Session event, #3854 declares that event's resources and lists `event:<name>` in a role.
- Liveshare integration (declaring `share-viewer`, one Peer per viewer connection, defining roles and registering a policy, removing `origin` / `prompt/origin` / `READ_ENDPOINTS`) happens on the #3854 side.
- `session/cancel` ownership checks, the fork `childSessionId`, and `ToolExecutionInput.turn` do not change.
@@ -57,12 +57,14 @@ export interface PeerScope {
readonly principal: Principal
/** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */
readonly signal: AbortSignal
/** Forwarded human-interaction participation; independent of authorization. */
readonly interactions: 'answer' | 'none'
dispose(): Promise<void>
}
// @deepseek-ai/dsh-client-connection
export class Peer implements PeerScope {
constructor(owner: Context, options: { readonly principal: Principal })
constructor(owner: Context, options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' })
}
interface HostConnectionHandle {
@@ -71,7 +73,7 @@ interface HostConnectionHandle {
/** Network admission: the admitted Peer or the refusal status. */
admit(request: ConnectionTrustRequest): PeerAdmission
/** In-process only; disposed with the Context that read `ctx.connection`, or earlier by dispose(). */
createPeer(options: { readonly principal: Principal }): PeerScope
createPeer(options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' }): PeerScope
}
interface ConnectionFetchHandler {
@@ -90,6 +92,7 @@ wireStream.open(endpoint, payload, uplink, peer: PeerScope, signal) //
| 浏览器连接共享 `browserPeer` | 所有浏览器连接共享一个 Peer,每个使用处都显式引用 `connection.browserPeer`,不存在兜底变量;页内 Host(webworker-runtime)同样显式传 `browserPeer` |
| Host 进程内自动化 | 需要调用 Remote 方法时用 `createPeer({ principal })` 创建自己的 Peer。目前没有这类生产调用方,因此不预置固定的 Host Peer |
| 多 Peer | `createPeer()` 每次返回独立 Peer;liveshare 将用它为读者的每条连接建一个 Peer(在 #3854 侧接入) |
| 交互参与 | `Peer(owner, { principal, interactions? })` 与 `createPeer()` 省略 interactions 时默认 `answer`。`none` 对所有 principal 拒绝 `$events` 与 `$events/result`;交互模式不赋予方法权限。回答型 owner 能接收并回答 waterfall,非 owner 只能接收 RBAC 授权的广播,不能回答 |
| exact Fetch route | file-upload、present-open、media-references、session-log-export、inspector 等 exact route 不经 Gateway;共享 Fetch handler 对非 owner Peer 一律返回 403 |
### 身份类型
@@ -547,9 +550,9 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot
### 不在范围与 Future work
- `/connect` 握手、API Cookie、每条 API 连接一个 Peer、WebSocket 生命周期:归 pr-5945。
- 仅供 API 的 `/connect` 握手、API Cookie、每条 API 连接一个 Peer 与 WebSocket 生命周期见 [Peer 作用域建连握手](2026-10-09-operator-scoped-connect-handshake.zh.md)。
- 浏览器多 Peer(如每标签页一个):所有浏览器连接共享 `browserPeer`。
- 交互参与(interactions)与按 principal 隔离审批由后续工作引入;目前非 owner 只收被授权的 `$events` 广播帧。
- 交互参与属于 Peer;`interactions: 'answer'` 的非 owner 依然只能收到 RBAC 授权的广播,不能回答 owner 的 waterfall;按 principal 处理审批不在本机制内。
- `agent-preset/selected` 等其余转发事件未声明资源,按 `global` 判定;读者需要某个会话事件时,#3854 为其声明资源并在 role 中列出 `event:<name>`。
- liveshare 接入(声明 `share-viewer`、每条读者连接一个 Peer、定义 role 并注册 policy、删除 `origin` / `prompt/origin` / `READ_ENDPOINTS`):#3854 侧。
- `session/cancel` 归属检查、fork 的 `childSessionId`、`ToolExecutionInput.turn`:不改。
+32 -1
View File
@@ -5,7 +5,9 @@ import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
import { once } from 'node:events'
import WebSocket from 'ws'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
// Empty type import: carries the approval package's session-event merge, so
// the decided-outcome assertion below type-checks against the real union.
@@ -74,6 +76,34 @@ describe.each(MODE === 'record' ? ['button'] as const : ['button', 'keyboard'] a
{ timeout: 15_000 },
).toBe(1)
const connectUrl = new URL(scaffold.authenticatedUrl)
connectUrl.pathname = '/connect'
connectUrl.searchParams.set('interactions', 'none')
const connected = await fetch(connectUrl, { redirect: 'manual', headers: { accept: 'application/json' } })
expect(connected.status).toBe(302)
const cookie = connected.headers.get('set-cookie')!.split(';', 1)[0]!
const muxUrl = new URL(connected.headers.get('location')!, connectUrl)
muxUrl.protocol = muxUrl.protocol === 'https:' ? 'wss:' : 'ws:'
const passive = new WebSocket(muxUrl, { headers: { cookie } })
const passiveClosed = new Promise<void>((resolve) => { passive.once('close', resolve) })
passive.on('error', () => { /* The open/message waits below report carrier errors. */ })
onTestFinished(async () => { passive.terminate(); await passiveClosed })
const passiveFrames: string[] = []
const refused = Promise.withResolvers<string>()
passive.on('message', (data) => {
const bytes = Array.isArray(data) ? Buffer.concat(data)
: Buffer.from(data instanceof ArrayBuffer ? new Uint8Array(data) : data)
const text = bytes.toString('utf8')
passiveFrames.push(text)
refused.resolve(text)
})
await once(passive, 'open')
passive.once('error', (error) => { refused.reject(error) })
passive.send(JSON.stringify({ type: 'open', streamId: 'passive-events', endpoint: '$events', payload: { args: {} } }))
expect(JSON.parse(await refused.promise)).toMatchObject({
type: 'error', streamId: 'passive-events', error: { code: 'gateway/interactions-forbidden' },
})
const settled = scaffold.whenTurnSettled(MODE === 'record' ? 240_000 : 60_000)
await input.fill(PROMPT)
await input.press('Enter')
@@ -126,6 +156,7 @@ describe.each(MODE === 'record' ? ['button'] as const : ['button', 'keyboard'] a
}
const sessionId = await settled
expect(passiveFrames).toHaveLength(1)
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
await assertFinalWorkspaceSnapshot(SNAPSHOT_DIR, join(scaffold.workspaceCwd, 'workspace'))
+3 -3
View File
@@ -182,7 +182,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-gateway`
- `inject`: `typert` · `remoteAccess`
- `source`: [`packages/api/gateway/src/index.ts:155`](../packages/api/gateway/src/index.ts)
- `source`: [`packages/api/gateway/src/index.ts:156`](../packages/api/gateway/src/index.ts)
```ts config-catalog
/** Gateway transport configuration. */
@@ -435,7 +435,7 @@ export type Config = LocalConfig
## `@deepseek-ai/dsh-client-connection`
- `inject`: `credentials`
- `source`: [`packages/client/connection/src/index.ts:93`](../packages/client/connection/src/index.ts)
- `source`: [`packages/client/connection/src/index.ts:96`](../packages/client/connection/src/index.ts)
```ts config-catalog
/** Browser authentication, request limits, and connection recovery configuration. */
@@ -449,7 +449,7 @@ export interface ConnectionConfig {
* an entry here. A non-canonical or non-bare authority fails plugin load.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
/** Absolute browser and API cookie lifetime in days. Default: 30. */
cookieMaxAgeDays?: number
/** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */
maxRequestBodyBytes?: number
+3 -3
View File
@@ -184,7 +184,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-gateway`
- `inject`: `typert` · `remoteAccess`
- `source`: [`packages/api/gateway/src/index.ts:155`](../packages/api/gateway/src/index.ts)
- `source`: [`packages/api/gateway/src/index.ts:156`](../packages/api/gateway/src/index.ts)
```ts config-catalog
/** Gateway transport configuration. */
@@ -437,7 +437,7 @@ export type Config = LocalConfig
## `@deepseek-ai/dsh-client-connection`
- `inject`: `credentials`
- `source`: [`packages/client/connection/src/index.ts:93`](../packages/client/connection/src/index.ts)
- `source`: [`packages/client/connection/src/index.ts:96`](../packages/client/connection/src/index.ts)
```ts config-catalog
/** Browser authentication, request limits, and connection recovery configuration. */
@@ -451,7 +451,7 @@ export interface ConnectionConfig {
* an entry here. A non-canonical or non-bare authority fails plugin load.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
/** Absolute browser and API cookie lifetime in days. Default: 30. */
cookieMaxAgeDays?: number
/** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */
maxRequestBodyBytes?: number
+1 -1
View File
@@ -32,7 +32,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:68`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - |
| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - |
| `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:383`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
| `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:389`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
| `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:395`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
+1 -1
View File
@@ -34,7 +34,7 @@
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:68`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - |
| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - |
| `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:383`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
| `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:389`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
| `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:395`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
+3
View File
@@ -229,6 +229,8 @@ interface PeerScope {
readonly principal: Principal
/** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */
readonly signal: AbortSignal
/** Whether the Peer may open a forwarded-event stream; this does not grant business permissions. */
readonly interactions: 'answer' | 'none'
/**
* Tear down every registration made through `ctx`.
* @returns settles once the scope has quiesced; racing calls share one completion.
@@ -414,6 +416,7 @@ type TypertGatewayErrorCode =
| 'gateway/context-unavailable'
| 'gateway/definition-unavailable'
| 'gateway/input-invalid'
| 'gateway/interactions-forbidden'
| 'gateway/invocation-unavailable'
| 'gateway/lookup-failed'
| 'gateway/lookup-not-found'
+3
View File
@@ -229,6 +229,8 @@ interface PeerScope {
readonly principal: Principal
/** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */
readonly signal: AbortSignal
/** Whether the Peer may open a forwarded-event stream; this does not grant business permissions. */
readonly interactions: 'answer' | 'none'
/**
* Tear down every registration made through `ctx`.
* @returns settles once the scope has quiesced; racing calls share one completion.
@@ -414,6 +416,7 @@ type TypertGatewayErrorCode =
| 'gateway/context-unavailable'
| 'gateway/definition-unavailable'
| 'gateway/input-invalid'
| 'gateway/interactions-forbidden'
| 'gateway/invocation-unavailable'
| 'gateway/lookup-failed'
| 'gateway/lookup-not-found'
+14 -9
View File
@@ -116,22 +116,27 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler
requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection
/**
* Admit one network request: it passes {@link requestRejection} and speaks
* for {@link browserPeer}, or it is refused with that status.
* Admit an HTTP request against its signed browser or API credential and live Peer.
* @param request - request headers from the HTTP or upgrade request.
* @returns the admitted Peer, or the rejection status.
* @returns the credential's Peer, or the rejection status.
*/
admit(request: ConnectionTrustRequest): PeerAdmission
/**
* Create an independent Peer. In-process only: no route, header, query, or
* cookie reaches it. Disposed with the Context that read `ctx.connection`,
* or earlier by its own `dispose()`.
* @param options - caller identity the Peer speaks for.
* @param options.principal - principal fixed for the Peer's lifetime.
* Create an independent in-process Peer, disposed with the Context that read `ctx.connection`.
* @param options - fixed principal and optional interaction participation (default `answer`).
* @returns a new Peer distinct from every other Peer.
*/
createPeer(options: { readonly principal: Principal }): PeerScope
createPeer(options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' }): PeerScope
/**
* Claim a physical socket for an API Peer; browser sockets share their owner Peer.
* @param request - authenticated upgrade request headers.
* @returns the Peer and idempotent release, or 409 for duplicate API ownership.
* The carrier releases on socket termination or failed upgrade; release removes
* API admission synchronously without cancelling independent HTTP calls.
*/
admitConnection(request: ConnectionTrustRequest): ConnectionAdmission
/**
* Authenticate one frontend index request, owning a token redirect or 401.
+14 -9
View File
@@ -116,22 +116,27 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler
requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection
/**
* Admit one network request: it passes {@link requestRejection} and speaks
* for {@link browserPeer}, or it is refused with that status.
* Admit an HTTP request against its signed browser or API credential and live Peer.
* @param request - request headers from the HTTP or upgrade request.
* @returns the admitted Peer, or the rejection status.
* @returns the credential's Peer, or the rejection status.
*/
admit(request: ConnectionTrustRequest): PeerAdmission
/**
* Create an independent Peer. In-process only: no route, header, query, or
* cookie reaches it. Disposed with the Context that read `ctx.connection`,
* or earlier by its own `dispose()`.
* @param options - caller identity the Peer speaks for.
* @param options.principal - principal fixed for the Peer's lifetime.
* Create an independent in-process Peer, disposed with the Context that read `ctx.connection`.
* @param options - fixed principal and optional interaction participation (default `answer`).
* @returns a new Peer distinct from every other Peer.
*/
createPeer(options: { readonly principal: Principal }): PeerScope
createPeer(options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' }): PeerScope
/**
* Claim a physical socket for an API Peer; browser sockets share their owner Peer.
* @param request - authenticated upgrade request headers.
* @returns the Peer and idempotent release, or 409 for duplicate API ownership.
* The carrier releases on socket termination or failed upgrade; release removes
* API admission synchronously without cancelling independent HTTP calls.
*/
admitConnection(request: ConnectionTrustRequest): ConnectionAdmission
/**
* Authenticate one frontend index request, owning a token redirect or 401.
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/operator-connect/guide.md
/:
en: 4e3353e294abb4b0
zh: 86300d2f509ee4c7
/select-interaction-participation-for-non-page-clients:
en: d1465a2b3240a12f
zh: ff9c76166146b2ff
/select-interaction-participation-for-non-page-clients/change:
en: c0c9719880da8939
zh: 185d0aa4c2dfbd2f
/select-interaction-participation-for-non-page-clients/migration:
en: c5691e130194dfac
zh: efa1a58ebdf97710
@@ -0,0 +1,18 @@
---
kind: upgrade-guide
description: "Non-page Clients default to no forwarded interactions and use raw streams without browser recovery."
---
# Select interaction participation for non-page clients
English | [中文](guide.zh.md)
## Change
Non-page Connection installations default to `interactions: 'none'`. They do not open `$events`, and `$on()` and the generation-recovery helper `$stream()` are unavailable in that mode. Generated unary and raw stream methods remain usable. Browser authentication, existing browser Cookies, the shared Peer, and recovery behavior are unchanged; browsers need no reauthentication.
## Migration
1. Exchange the launch token at `GET /connect?token=<launch-token>&interactions=none` with manual redirect handling. Keep the returned API Cookie in client-owned storage and use it for HTTP and the lifecycle WebSocket at the returned 302 Location. HTTP need not wait for the socket to open.
2. For passive streaming, call the generated raw stream method directly rather than `$stream()`. After lifecycle-socket closure or Host restart, perform a new connect before retrying.
3. If the caller intentionally answers forwarded interactions, request `interactions=answer` and install Connection with that same mode. Confirm passive callers are refused at both event endpoints while the answering caller can subscribe.
@@ -0,0 +1,18 @@
---
kind: upgrade-guide
description: "非页面 Client 默认不参与转发交互,并使用不带浏览器恢复的原始流。"
---
# 为非页面客户端选择交互参与模式
[English](guide.md) | 中文
## 变更
非页面 Connection 安装默认 `interactions: 'none'`。该模式不打开 `$events`,也不提供 `$on()` 和代际恢复辅助方法 `$stream()`;生成的 unary 和原始流方法仍可使用。浏览器认证、既有 Cookie、共享 Peer 与恢复行为均不变,无需重新认证。
## 迁移
1. 使用手动重定向处理,在 `GET /connect?token=<launch-token>&interactions=none` 交换 launch token。将返回的 API Cookie 放在客户端独立存储中,供 HTTP 和返回的 302 Location 所指的生命周期 WebSocket 使用;HTTP 无需等待 socket 打开。
2. 被动流调用直接使用生成的原始流方法,不使用 `$stream()`。生命周期连接关闭或 Host 重启后,重试前先重新 connect。
3. 如果调用方确实要回答转发交互,请请求 `interactions=answer`,并用相同模式安装 Connection。确认被动调用方在两个事件端点被拒绝,而回答型调用方可以订阅。
+4 -4
View File
@@ -15,11 +15,11 @@
en: d9859219cdb0f367
zh: 2a3cfa8cb786e327
/deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway:
en: 6019f9b47d9ab474
zh: b72d9c378bdafffb
en: a7af967ac7550a3d
zh: 35a49f5b1694fdd0
/deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote:
en: c106f0ca3e547f84
zh: 8eadd3bffde9e49d
en: 0916113cdbe794fb
zh: 14893c2cba95bfd6
/deepseek-ai-dsh-api-gateway/model-experience:
en: 6cb309d3378f035d
zh: 998042a02ee32850
+4 -4
View File
@@ -46,12 +46,12 @@ Stream cancellation and stopping keep read-local state, not a history of deliver
Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. A `$events` stream whose Peer principal is not the owner never receives a scoped waterfall it could answer; it receives a broadcast emit frame, whole and untrimmed, only when `ctx.remoteAccess.decide({ principal, action: 'event:<name>', resources })` allows it on the resources the frame's readers name, or on `global` for a frame without readers; the resources are resolved once per frame, a frame whose resources are invalid reaches no non-owner stream without a decision, and a denied frame is dropped for that stream alone. An `$events/result` reply from such a Peer fails with `gateway/invocation-unavailable`.
`hasLiveClient()` checks the existing `$events` records for an uncancelled stream whose Peer principal is the owner, the only kind that can answer a forwarded waterfall. A cancelled stream does not count even while its iterator is still awaiting cleanup; a bare WebSocket does not count. This synchronous observation does not guarantee delivery or Client provider readiness.
A disposed Peer is refused before event-stream participation checks. `$events` and `$events/result` require `peer.interactions === 'answer'` for owner and non-owner alike; refusal returns `gateway/interactions-forbidden`. Only owner Peers receive forwarded waterfalls and may answer; a reply must belong to an event stream owned by the same Peer. `hasLiveClient()` checks uncancelled owner answering streams; non-owner broadcast streams and bare WebSockets do not count. Cancellation excludes a stream even while its iterator awaits cleanup. This synchronous observation does not guarantee delivery or Client provider readiness.
<a id="client-service-clientremote-ctx-key-remote"></a>
## Client service: `ClientRemote` (ctx key: `remote`)
The browser carrier accepts the shell-owned stream origin described by [Connection](../../client/connection/README.md#use-this-package); logical stream framing and lifecycle remain unchanged.
The same `./client` import selects standalone Node ESM `lib/client.node.js` or browser loader factory `lib/client.browser.js`; the Host package root stays separate. The browser carrier accepts the shell-owned stream origin described by [Connection](../../client/connection/README.md#use-this-package); logical stream framing and lifecycle remain unchanged.
`ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.<namespace>` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and Client-supplied fields without strict generated codecs fail before methods become callable.
@@ -61,9 +61,9 @@ Every unary call resolves to `RemoteResult<T>` — `{ ok: true, value }` or `{ o
`ctx.remote.$host` reads the fixed Host facts as plain values: `home` (undefined until the first ready frame) and `isLoopback`. It is not a store — no subscription, no generation counter — so a consumer that must react to reconnection listens for `connection/reset` instead of polling it.
`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. Every terminal failure leaves this face as a `RemoteError`, including exhausted carrier retries and a generation that ends before its opening value, so a stream consumer discriminates the same way a unary caller does. `RemoteStreamCarrierError` names a retryable physical loss and reaches a domain only as the `carrierFailed` callback argument, never as a terminal outcome. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. A domain may also carry cursorless notifications: they never advance or repair the durable cursor, and notifications received during gap repair publish only after the replacement page commits. If a newer generation supersedes that repair, held notifications from the superseded generation are discarded with its page. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped.
`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. For answering Clients it permits one immediate retry after an isolated stream failure while the carrier remains available; after physical WebSocket loss it waits for Connection to reopen the carrier. If the Host generation is already unavailable, it waits for the next connected generation. Each item carries its physical generation. Passive Clients cannot use `$stream()`; they call generated raw stream methods and handle terminal carrier failure themselves. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. Every terminal failure leaves this face as a `RemoteError`, including exhausted carrier retries and a generation that ends before its opening value, so a stream consumer discriminates the same way a unary caller does. `RemoteStreamCarrierError` names a retryable physical loss and reaches a domain only as the `carrierFailed` callback argument, never as a terminal outcome. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. A domain may also carry cursorless notifications: they never advance or repair the durable cursor, and notifications received during gap repair publish only after the replacement page commits. If a newer generation supersedes that repair, held notifications from the superseded generation are discarded with its page. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped.
`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it under continuous, capped jittered exponential backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier.
`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source only when `connection.interactions` is `answer`, whether any `$on` listener exists. With `none`, `$on()` is unavailable and the Client opens only the physical lifecycle socket; unary calls never wait for socket readiness. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it under continuous, capped jittered exponential backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier.
Client waterfall Context resolution stays synchronous. A resolver can return a borrowed Context or `TypertOwnedValue<Context>`; Gateway releases the owned value only after handler use and reply settlement. Context-resolution failures retain the existing report-and-delegate behavior, while handler failures produce rejected replies. Cancellation suppresses late replies without releasing a Context still used by the handler. Every handler must observe `request.signal` and settle after cancellation; plugin disposal and Connection generation replacement wait for outstanding handlers to settle. Session Context acquisition itself performs no history I/O.
+4 -4
View File
@@ -46,12 +46,12 @@ Host 下行、Host 上行和 Client 上行泵的取消与停止状态归属单
Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。Peer 的 principal 不是 owner 的 `$events` 流从不收到它可以作答的 scoped waterfall(瀑布式事件);只有当 `ctx.remoteAccess.decide({ principal, action: 'event:<name>', resources })` 在该帧读取函数指明的资源上(帧没有读取函数时为 `global`)允许时,才整条、不裁剪地收到广播 emit 帧;每帧只解析一次资源,资源非法的帧不经判定即不送达任何非 owner 流,被拒的帧只对该流丢弃。这类 Peer 的 `$events/result` 回复以 `gateway/invocation-unavailable` 失败。
`hasLiveClient()` 检查已有 `$events` 记录中是否有 Peer principal 为 owner 的未取消流,只有这类流能回答转发的 waterfall。已取消的流即使尚未完成 iterator 清理也不计入,单独的 WebSocket 也不计入。这一同步观察不保证后续投递成功或 Client Provider 已就绪。
已释放的 Peer 在事件流参与检查之前被拒绝。无论 principal 是否为 owner,`$events` 与 `$events/result` 都要求 `peer.interactions === 'answer'`;否则返回 `gateway/interactions-forbidden`。只有 owner Peer 接收并回答转发的 waterfall,回复必须来自持有该事件流的同一 Peer。`hasLiveClient()` 只计算未取消的 owner 回答流;非 owner 广播流、单独的 WebSocket 与已取消但尚未完成 iterator 清理的流均不计入。这一同步观察不保证后续投递成功或 Client Provider 已就绪。
<a id="client-service-clientremote-ctx-key-remote"></a>
## Client 服务:`ClientRemote`(ctx key:`remote`)
浏览器载体接受 [Connection](../../client/connection/README.zh.md#use-this-package) 定义的 shell 所拥有的流 origin;逻辑流帧与生命周期保持一致。
同一个 `./client` 导入在 Node 中选择独立的 ESM `lib/client.node.js`,在浏览器中选择 loader factory `lib/client.browser.js`;Host 包根入口保持独立。浏览器载体接受 [Connection](../../client/connection/README.zh.md#use-this-package) 定义的 shell 所拥有的流 origin;逻辑流帧与生命周期保持一致。
`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.<namespace>` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格 codec 的 Client 供值字段,都会在方法可调用前报错。
@@ -61,9 +61,9 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source
`ctx.remote.$host` 以普通值读取固定的 Host 事实:`home`(首个 ready 帧之前为 undefined)与 `isLoopback`。它不是存储——没有订阅、没有代次计数——所以需要响应重连的消费方去监听 `connection/reset`,而不是轮询它。
`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个初始快照和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。领域还可以携带无 cursor 的通知:通知绝不推进或修复持久 cursor,在缺口修复期间收到的通知只会在 replacement page 提交后发布。若更新代次取代该修复,旧代次 held notification 会与其 page 一同丢弃。对任一种流执行 dispose(资源释放)时,系统会取消该流的请求,并在活动 iterator 完全停止后完成资源释放。
`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。对于回答型 Client,载体仍可用时的独立流故障允许一次立即重试;物理 WebSocket 丢失后等待 Connection 重开载体。如果 Host generation 已不可用,则等待下一代连接。每个流项都带有其物理代次。被动 Client 不使用 `$stream()`,而是调用生成的原始流方法,并自行处理终态载体故障。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个初始快照和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。领域还可以携带无 cursor 的通知:通知绝不推进或修复持久 cursor,在缺口修复期间收到的通知只会在 replacement page 提交后发布。若更新代次取代该修复,旧代次 held notification 会与其 page 一同丢弃。对任一种流执行 dispose(资源释放)时,系统会取消该流的请求,并在活动 iterator 完全停止后完成资源释放。
`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,无论当前是否存在 `$on` listener。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote 流故障、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按持续且间隔封顶的带抖动指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。
`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务仅在 `connection.interactions` 为 `answer` 时把 `$events` pump 注册为 Connection generation source,无论当前是否存在 `$on` listener。选择 `none` 时,`$on()` 不可用,Client 只打开物理生命周期连接;unary 调用不等待 socket 就绪。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote 流故障、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按持续且间隔封顶的带抖动指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall(瀑布式事件)允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。
Client waterfall 的 Context 解析保持同步。解析器可以返回借用的 Context 或 `TypertOwnedValue<Context>`;Gateway 仅在处理器使用和回复结算均结束后释放 owned value。Context 解析失败保留既有的记录错误并委托语义,处理器失败产生拒绝回复。取消会抑制迟到回复,但不会释放处理器仍在使用的 Context。每个 handler 都必须响应 `request.signal` 并在取消后结束;插件销毁与 Connection generation 替换会等待未结束的 handler 结算。Session Context 的获取本身不执行历史 I/O。
+5 -2
View File
@@ -20,7 +20,9 @@
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
"node": "./lib/client.node.js",
"browser": "./lib/client.browser.js",
"default": "./lib/client.browser.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
@@ -51,7 +53,8 @@
"files": [
"locale/*.json",
"lib/index.js",
"lib/client.js",
"lib/client.browser.js",
"lib/client.node.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
+22 -9
View File
@@ -102,7 +102,8 @@ interface InstalledMethod {
/** Typed Remote service augmented by generated direct namespaces and Gateway stream supervision. */
export interface ClientRemote extends TypertClientRemote {
/**
* Create one independently cancellable, reconnecting logical stream.
* Create an independently cancellable, reconnecting logical stream.
* Requires interactions=answer; passive Clients use generated stream methods.
* @param options - domain-owned opener and generation-end classification.
* @returns a single-consumer stream annotated with physical generation ids.
*/
@@ -146,8 +147,8 @@ class ClientRemoteService extends Service implements ClientRemote {
private readonly connection: ConnectionHandle
private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
private hostFacts: RemoteHostFacts | undefined
private readonly streams = new RemoteStreamMuxClient()
private readonly events: ClientRemoteEvents
private readonly streams: RemoteStreamMuxClient
private readonly events: ClientRemoteEvents | undefined
private mutations = Promise.resolve()
constructor(ctx: Context) {
@@ -155,16 +156,24 @@ class ClientRemoteService extends Service implements ClientRemote {
this.ownerCtx = ctx
const connection = ctx.get('connection') as ConnectionHandle
this.connection = connection
this.events = new ClientRemoteEvents(
ctx,
connection,
(endpoint, payload, signal) => this.openRemoteStream(endpoint, payload, signal),
this.streams = new RemoteStreamMuxClient(
path => connection.openWebSocket(path),
connection.interactions === 'answer',
)
if (connection.interactions === 'answer') {
this.events = new ClientRemoteEvents(
ctx,
connection,
(endpoint, payload, signal) => this.openRemoteStream(endpoint, payload, signal),
)
}
// API callers retain a lifecycle socket even when they do not receive events.
// HTTP dispatch never waits for this independent connection attempt.
if (connection.rpc.open === undefined) this.streams.start()
let disposed = false
let loop: ReturnType<ConnectionHandle['start']> | undefined
const start = (): void => {
if (disposed) return
if (disposed || connection.interactions === 'none') return
if (connection.rpc.open === undefined) this.streams.start()
loop = connection.start({
onConnected: () => { this.ownerCtx.emit('connection/reset') },
@@ -179,12 +188,15 @@ class ClientRemoteService extends Service implements ClientRemote {
ctx.effect(() => async () => {
disposed = true
loop?.stop()
await this.events.dispose()
await this.events?.dispose()
await this.streams.close()
}, 'api-gateway.client.transport')
}
$stream<Item>(options: RemoteStreamOptions<Item>): RemoteStream<Item> {
if (this.connection.interactions === 'none') {
throw new Error('client api: $stream requires interactions=answer; use a generated stream method')
}
return new RemoteStream(this.connection, options)
}
@@ -213,6 +225,7 @@ class ClientRemoteService extends Service implements ClientRemote {
event: Event,
listener: TypertClientEventListener<Event>,
): () => void {
if (this.events === undefined) throw new Error('client api: forwarded events require interactions=answer')
return this.events.subscribe(this.ctx, event, listener)
}
@@ -1,5 +1,6 @@
/** Client owner for the Gateway multiplexed Remote stream socket. */
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
/** Browser owner for the Gateway multiplexed Remote stream socket. */
import type { ConnectionWebSocket } from '@deepseek-ai/dsh-client-connection/client'
import {
parseRemoteStreamServerMessage,
@@ -24,7 +25,7 @@ export class RemoteStreamCarrierError extends Error {
interface SocketWaiter {
readonly revision: number
resolve(socket: WebSocket): void
resolve(socket: ConnectionWebSocket): void
reject(error: unknown): void
}
@@ -49,20 +50,31 @@ const UPLINK_DONE: IteratorReturnResult<undefined> = { value: undefined, done: t
* starts one.
*/
export class RemoteStreamMuxClient {
private socket: WebSocket | undefined
private socket: ConnectionWebSocket | undefined
private cancelCandidate: ((error: Error) => void) | undefined
private keepAlive: Promise<void> | undefined
private revision = 0
private readonly streams = new Map<string, LogicalStream>()
private readonly waiters = new Set<SocketWaiter>()
private readonly socketClosures = new Set<Promise<void>>()
private running = false
private disposed = false
private failure: RemoteStreamCarrierError | undefined
/**
* @param openSocket - selected authenticated carrier; omission uses the browser socket.
* @param waitForReconnect - answering Clients wait for Connection to replace a lost socket; passive Clients fail later opens immediately.
*/
constructor(
private readonly openSocket: (path: string) => ConnectionWebSocket = () => new WebSocket(remoteStreamUrl()),
private readonly waitForReconnect = false,
) {}
/** Ensure a physical attempt exists, following the current attempt once if needed. */
start(): void {
if (this.disposed) return
this.running = true
if (this.socket?.readyState === WebSocket.OPEN) return
if (this.socket?.readyState === 1) return
const pending = this.keepAlive
if (pending === undefined) this.maintain()
else void pending.then(() => { this.maintain() })
@@ -74,6 +86,7 @@ export class RemoteStreamMuxClient {
const failure = new RemoteStreamCarrierError('api gateway: Remote stream reconnect requested')
const pending = this.keepAlive
this.revision++
this.failure = undefined
this.cancelCandidate?.(failure)
const socket = this.socket
if (socket !== undefined) {
@@ -87,8 +100,9 @@ export class RemoteStreamMuxClient {
/**
* Open one logical stream on the persistent physical connection.
* If no physical attempt is active, opening waits for Connection to request
* one or for the signal to abort.
* After an established socket is lost, answering Clients wait for Connection
* to reconnect or for their signal to abort; passive Clients reject immediately.
* An initial socket failure rejects both modes.
* @param endpoint - Typert Remote stream endpoint.
* @param payload - endpoint request encoded on the wire.
* @param signal - cancellation for this logical stream.
@@ -106,7 +120,7 @@ export class RemoteStreamMuxClient {
const streamId = randomUUID()
const inbox = new StreamInbox()
const stream: LogicalStream = { inbox, pump: undefined }
let carrier: WebSocket | undefined
let carrier: ConnectionWebSocket | undefined
let opened = false
let terminal = false
const abort = (): void => { inbox.fail(signal.reason) }
@@ -136,7 +150,7 @@ export class RemoteStreamMuxClient {
signal.removeEventListener('abort', abort)
this.streams.delete(streamId)
stream.pump?.stop()
if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) {
if (opened && !terminal && carrier?.readyState === 1) {
this.send(carrier, { type: 'cancel', streamId })
}
// The old generation's pump has stopped before a supervisor reopens the next one.
@@ -152,7 +166,7 @@ export class RemoteStreamMuxClient {
* generator blocked in `next()` only completes it once it yields.
*/
private pumpUplink(
socket: WebSocket,
socket: ConnectionWebSocket,
streamId: string,
uplink: AsyncIterable<unknown>,
signal: AbortSignal,
@@ -206,7 +220,7 @@ export class RemoteStreamMuxClient {
/**
* Permanently stop the carrier, close the physical socket, and fail every
* active logical stream.
* @returns once the active connection attempt has stopped.
* @returns once the connection attempt and every physical socket have closed.
*/
async close(): Promise<void> {
if (!this.disposed) {
@@ -221,20 +235,24 @@ export class RemoteStreamMuxClient {
socket?.close(1000, 'disposed')
}
await this.keepAlive
await Promise.all(this.socketClosures)
}
private connect(): Promise<WebSocket> {
const socket = new WebSocket(remoteStreamUrl())
const connecting = new Promise<WebSocket>((resolve, reject) => {
private async connect(): Promise<ConnectionWebSocket> {
const socket = this.openSocket(REMOTE_STREAM_MUX_PATH)
const closed = new Promise<void>((resolve) => { socket.addEventListener('close', () => { resolve() }, { once: true }) })
this.socketClosures.add(closed)
void closed.then(() => { this.socketClosures.delete(closed) })
const connecting = new Promise<ConnectionWebSocket>((resolve, reject) => {
let settled = false
const rejectCandidate = (error: Error): void => {
settled = true
socket.removeEventListener('open', opened)
socket.removeEventListener('error', failed)
socket.removeEventListener('message', received)
socket.removeEventListener('close', closed)
this.cancelCandidate = undefined
socket.close()
// Node ws emits error when a connecting socket is closed. Keep its
// error listener until close even after rejecting the attempt.
if (socket.readyState < 2) socket.close()
reject(error)
}
const opened = (): void => {
@@ -253,9 +271,12 @@ export class RemoteStreamMuxClient {
}
const error = new RemoteStreamCarrierError('api gateway: Remote stream WebSocket failed')
this.lost(socket, error)
socket.close()
if (socket.readyState < 2) socket.close()
}
const closed = (): void => {
socket.removeEventListener('open', opened)
socket.removeEventListener('error', failed)
socket.removeEventListener('message', received)
if (!settled) {
rejectCandidate(new RemoteStreamCarrierError(
'api gateway: Remote stream WebSocket closed before opening',
@@ -264,21 +285,22 @@ export class RemoteStreamMuxClient {
}
this.lost(socket)
}
const received = (event: MessageEvent): void => { this.receive(socket, event.data) }
const received = (event: { readonly data: unknown }): void => { this.receive(socket, event.data) }
this.cancelCandidate = rejectCandidate
socket.addEventListener('open', opened, { once: true })
socket.addEventListener('error', failed, { once: true })
socket.addEventListener('error', failed)
socket.addEventListener('message', received)
socket.addEventListener('close', closed, { once: true })
})
return connecting
}
private waitForSocket(signal: AbortSignal): Promise<WebSocket> {
private waitForSocket(signal: AbortSignal): Promise<ConnectionWebSocket> {
signal.throwIfAborted()
if (this.socket?.readyState === WebSocket.OPEN) return Promise.resolve(this.socket)
if (this.socket?.readyState === 1) return Promise.resolve(this.socket)
if (this.disposed) return Promise.reject(new Error('api gateway: Remote stream client disposed'))
if (!this.running) return Promise.reject(new Error('api gateway: Remote stream client not started'))
if (this.failure !== undefined) return Promise.reject(this.failure)
return new Promise((resolve, reject) => {
const aborted = (): void => { waiter.reject(signal.reason) }
const cleanup = (): void => {
@@ -303,7 +325,7 @@ export class RemoteStreamMuxClient {
})
}
private receive(socket: WebSocket, data: unknown): void {
private receive(socket: ConnectionWebSocket, data: unknown): void {
if (socket !== this.socket) return
try {
if (typeof data !== 'string') throw new Error('api gateway: Remote stream WebSocket requires text messages')
@@ -322,26 +344,34 @@ export class RemoteStreamMuxClient {
}
private lost(
socket: WebSocket,
socket: ConnectionWebSocket,
error: RemoteStreamCarrierError = new RemoteStreamCarrierError(
'api gateway: Remote stream WebSocket closed',
),
): void {
if (this.socket !== socket) return
this.socket = undefined
if (!this.waitForReconnect) this.failure = error
this.failAll(error)
if (!this.waitForReconnect) {
for (const waiter of [...this.waiters]) waiter.reject(error)
}
}
private maintain(): void {
if (!this.running || this.disposed) return
if (this.socket?.readyState === WebSocket.OPEN || this.keepAlive !== undefined) return
if (this.socket?.readyState === 1 || this.keepAlive !== undefined) return
const revision = this.revision
this.failure = undefined
const task = this.connect().then(
() => undefined,
(error: unknown) => {
if (!this.running) return
const failure = error instanceof RemoteStreamCarrierError ? error
: new RemoteStreamCarrierError('api gateway: Remote stream WebSocket failed to open', { cause: error })
if (this.revision === revision) this.failure = failure
for (const waiter of [...this.waiters]) {
if (waiter.revision <= revision) waiter.reject(error)
if (waiter.revision <= revision) waiter.reject(failure)
}
},
)
@@ -358,7 +388,7 @@ export class RemoteStreamMuxClient {
}
}
private send(socket: WebSocket, message: RemoteStreamClientMessage): void {
private send(socket: ConnectionWebSocket, message: RemoteStreamClientMessage): void {
socket.send(JSON.stringify(message))
}
}
+51 -3
View File
@@ -121,6 +121,7 @@ interface RegisteredRemoteEventSource {
}
interface RemoteEventClient {
readonly peer: PeerScope
readonly id: RemoteEventClientId
readonly queue: RemoteEventQueue
readonly signal: AbortSignal
@@ -254,17 +255,46 @@ export class TypertGatewayService extends Service implements TypertGateway {
resolved.websocketHeartbeatIntervalMs,
resolved.streamInboxBytes,
)
const releases = new Set<Promise<void>>()
webCtx.effect(function* () {
yield () => mux.close()
yield async () => {
await mux.close()
await Promise.all(releases)
}
const route: WebUpgradeRoute = {
path: REMOTE_STREAM_MUX_PATH,
handler: (req, socket, head) => {
const admission = webCtx.connection.admit(req)
const admission = webCtx.connection.admitConnection(req)
if ('rejection' in admission) {
rejectRemoteStreamUpgrade(socket, admission.rejection)
return
}
mux.handleUpgrade(req, socket, head, admission.peer)
const release = (): void => {
// release removes the API Peer synchronously, before awaiting
// scope cleanup or any logical-stream iterator's return.
const done = admission.release().catch((error: unknown) => {
console.error('[api-gateway] Peer release failed:', error)
})
releases.add(done)
void done.then(() => { releases.delete(done) })
}
const closed = (): void => {
socket.removeListener('error', release)
release()
}
socket.on('error', release)
socket.once('close', closed)
if (socket.destroyed) {
closed()
return
}
try {
mux.handleUpgrade(req, socket, head, admission.peer)
} catch (error) {
release()
socket.destroy()
console.error('[api-gateway] Remote stream upgrade failed:', error)
}
},
}
yield webCtx.webServer.registerUpgrade(route)
@@ -450,6 +480,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) {
try {
assertPeerLive(peer, endpoint)
requireInteractions(peer, endpoint)
// oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap.
if (peer.principal.kind !== 'owner') {
throw new TypertGatewayError('gateway/invocation-unavailable', endpoint, 'only owner Peers answer forwarded Remote events')
@@ -459,6 +490,12 @@ export class TypertGatewayService extends Service implements TypertGateway {
if (client === undefined) {
throw new Error('typert gateway: Remote event result identifies no active event stream')
}
if (client.peer !== peer) {
throw new TypertGatewayError(
'gateway/interactions-forbidden', endpoint,
'Remote event result belongs to another Peer',
)
}
this.receiveRemoteEventResult(client, result)
return { ok: true, value: undefined }
} catch (error) {
@@ -481,6 +518,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
// A Gateway-owned stream reads no uplink: releasing it now keeps its items out of the bounded inbox.
releaseUplink(uplink)
assertPeerLive(peer, endpoint)
requireInteractions(peer, endpoint)
return this.openRemoteEvents(payload, signal, peer)
}
return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink, uplinkWindow }, control)
@@ -519,6 +557,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
let clientId = randomUUID() as RemoteEventClientId
while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId
const client: RemoteEventClient = {
peer,
id: clientId,
queue: new RemoteEventQueue(),
signal: lifetime,
@@ -1183,6 +1222,15 @@ function assertRemoteEventName(frame: { readonly event: unknown }): void {
}
}
function requireInteractions(peer: PeerScope, endpoint: string): void {
if (peer.interactions !== 'answer') {
throw new TypertGatewayError(
'gateway/interactions-forbidden', endpoint,
'forwarded Remote events require interactions=answer',
)
}
}
function parseRemoteEventResultPayload(payload: unknown): ReturnType<typeof parseRemoteEventResult> {
if (!isObject(payload)
|| !isPlainObject(payload)
@@ -22,6 +22,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
'gateway/context-unavailable': TypertGatewayFaultDetails
'gateway/definition-unavailable': TypertGatewayFaultDetails
'gateway/input-invalid': TypertGatewayFaultDetails
'gateway/interactions-forbidden': TypertGatewayFaultDetails
'gateway/invocation-unavailable': TypertGatewayFaultDetails
'gateway/lookup-failed': TypertGatewayFaultDetails
'gateway/lookup-not-found': TypertGatewayFaultDetails
+8 -7
View File
@@ -80,12 +80,13 @@ export class RemoteStreamMuxServer {
const bound: BoundStreamOpener = (endpoint, payload, uplink, control, window) =>
this.open(endpoint, payload, uplink, peer, control, window)
const connection = new RemoteStreamMuxConnection(websocket, bound, this.failure, this.streamInboxBytes, alive)
const done = connection.run()
this.connections.add(done)
void done.then(() => {
this.connections.delete(done)
void release()
const done = connection.run().catch((error: unknown) => {
console.error('[api-gateway] Remote stream connection failed:', error)
}).then(async () => { await release() }).catch((error: unknown) => {
console.error('[api-gateway] Remote stream scope cleanup failed:', error)
})
this.connections.add(done)
void done.then(() => { this.connections.delete(done) })
})
}
@@ -453,8 +454,8 @@ function rawText(data: RawData): string {
* @param socket - carrier socket that receives the HTTP rejection.
* @param status - authentication or browser-trust rejection status.
*/
export function rejectRemoteStreamUpgrade(socket: Duplex, status: 401 | 403): void {
const reason = status === 401 ? 'Unauthorized' : 'Forbidden'
export function rejectRemoteStreamUpgrade(socket: Duplex, status: 401 | 403 | 409): void {
const reason = status === 401 ? 'Unauthorized' : status === 403 ? 'Forbidden' : 'Conflict'
const body = reason.toLowerCase()
socket.end([
`HTTP/1.1 ${String(status)} ${reason}`,
+1
View File
@@ -130,6 +130,7 @@ export type TypertGatewayErrorCode =
| 'gateway/context-unavailable'
| 'gateway/definition-unavailable'
| 'gateway/input-invalid'
| 'gateway/interactions-forbidden'
| 'gateway/invocation-unavailable'
| 'gateway/lookup-failed'
| 'gateway/lookup-not-found'
@@ -0,0 +1,196 @@
/** Non-page Clients use one credential across independent HTTP and WebSocket transports. */
import { once } from 'node:events'
import { createServer } from 'node:http'
import type { Duplex } from 'node:stream'
import { Context } from '@deepseek-ai/cordis'
import { installConnection, type ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import type { RemoteResult, RemoteStreamHandle, TypertRemoteNamespace } from '@deepseek-ai/dsh-typert-protocol'
import { describe, expect, it, vi } from 'vitest'
import WebSocket, { WebSocketServer } from 'ws'
import { z } from 'zod'
import { apply, inject } from '../src/client/index.ts'
import { parseRemoteStreamClientMessage } from '../src/stream-protocol.ts'
declare module '@deepseek-ai/dsh-typert-protocol' {
interface TypertRemoteMap {
'nodeProbe/read': () => Promise<RemoteResult<string>>
'nodeProbe/watch': () => RemoteStreamHandle<string, never>
}
interface TypertRemoteNamespaceMap {
nodeProbe: TypertRemoteNamespace<'nodeProbe'>
}
}
describe('authenticated Node Client', () => {
it.each(['none', 'answer'] as const)('finishes unary HTTP while the %s lifecycle socket is still connecting', async (interactions) => {
const fixture = await nodeFixture(interactions)
try {
await fixture.upgrading.promise
await expect(fixture.ctx.remote.nodeProbe.read()).resolves.toEqual({ ok: true, value: 'authenticated' })
expect(fixture.socket?.readyState).toBe(WebSocket.CONNECTING)
expect(fixture.cookies).toEqual(['fixture=credential', 'fixture=credential'])
expect(fixture.endpoints).toEqual([])
fixture.upgrade()
await once(fixture.socket!, 'open')
if (interactions === 'answer') {
await vi.waitFor(() => { expect(fixture.connection.generation.getSnapshot()?.host.home).toBe('/home/node') })
expect(fixture.endpoints).toEqual(['$events'])
} else {
expect(fixture.connection.generation.getSnapshot()).toBeUndefined()
expect(() => fixture.ctx.remote.$on('fixture/approval' as never, () => {})).toThrow('interactions=answer')
const open = vi.fn(() => fixture.ctx.remote.nodeProbe.watch())
expect(() => fixture.ctx.remote.$stream({ name: 'passive', open, ended: () => new Error('ended') }))
.toThrow('$stream requires interactions=answer')
expect(open).not.toHaveBeenCalled()
expect(fixture.endpoints).toEqual([])
}
const stream = fixture.ctx.remote.nodeProbe.watch()
const iterator = stream[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'stream item' })
if (interactions === 'none') {
fixture.socket!.close()
await expect(iterator.next()).rejects.toThrow('WebSocket closed')
await expect(fixture.ctx.remote.nodeProbe.watch()[Symbol.asyncIterator]().next()).rejects.toThrow('WebSocket closed')
} else {
stream.dispose()
await iterator.return?.()
}
} finally {
await fixture.close()
}
})
it('finishes unary with no WebSocket upgrade and contains Node cancellation until physical close', async () => {
const fixture = await nodeFixture('none')
try {
await fixture.upgrading.promise
await expect(fixture.ctx.remote.nodeProbe.read()).resolves.toEqual({ ok: true, value: 'authenticated' })
const socket = fixture.socket!
const errors: Error[] = []
socket.on('error', error => errors.push(error))
await fixture.ctx.fiber.dispose()
expect(socket.readyState).toBe(WebSocket.CLOSED)
expect(errors).toHaveLength(1)
expect(errors[0]?.message).toContain('closed before the connection was established')
expect(fixture.endpoints).toEqual([])
} finally {
await fixture.close()
}
})
it('contains a synchronous socket factory failure without blocking unary HTTP', async () => {
const ctx = new Context()
installConnection(ctx, {
transport: {
streamBaseUrl: 'http://127.0.0.1/',
createWebSocket: () => { throw new Error('socket factory failed') },
rpc: { call: async () => ({ ok: true, value: 'local HTTP substitute' }) },
},
})
try {
await ctx.plugin(TypertRegistry)
await ctx.plugin({ apply, inject })
await mount(ctx)
await expect(ctx.remote.nodeProbe.read()).resolves.toEqual({ ok: true, value: 'local HTTP substitute' })
await expect(ctx.remote.nodeProbe.watch()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
name: 'RemoteStreamCarrierError', cause: { message: 'socket factory failed' },
})
} finally {
await ctx.fiber.dispose()
}
})
})
async function nodeFixture(interactions: 'none' | 'answer') {
const ctx = new Context()
const cookies: Array<string | undefined> = []
const endpoints: string[] = []
const upgrading = Promise.withResolvers<undefined>()
const rawSockets = new Set<Duplex>()
const mux = new WebSocketServer({ noServer: true })
let upgrade = (): void => { throw new Error('fixture upgrade has not arrived') }
let socket: WebSocket | undefined
const server = createServer((request, response) => {
cookies.push(request.headers.cookie)
const receive = async (): Promise<void> => {
let body = ''
for await (const chunk of request) body += String(chunk)
const envelope = z.object({ rpcId: z.string() }).parse(JSON.parse(body))
response.setHeader('content-type', 'application/json')
response.end(JSON.stringify({ type: 'server-response', rpcId: envelope.rpcId, result: { ok: true, value: 'authenticated' } }))
}
void receive().catch((error: unknown) => { response.destroy(error instanceof Error ? error : undefined) })
})
server.on('upgrade', (request, rawSocket, head) => {
cookies.push(request.headers.cookie)
rawSockets.add(rawSocket)
rawSocket.on('error', () => { rawSocket.destroy() })
rawSocket.once('close', () => { rawSockets.delete(rawSocket) })
upgrade = () => {
mux.handleUpgrade(request, rawSocket, head, (peer) => {
peer.on('error', () => { peer.terminate() })
peer.on('message', (data) => {
const text = Array.isArray(data) ? Buffer.concat(data).toString('utf8')
: Buffer.from(data instanceof ArrayBuffer ? new Uint8Array(data) : data).toString('utf8')
const frame = parseRemoteStreamClientMessage(text)
if (frame.type !== 'open') return
endpoints.push(frame.endpoint)
peer.send(JSON.stringify({
type: 'item', streamId: frame.streamId,
value: frame.endpoint === '$events'
? { type: 'ready', clientId: 'node-client', host: { home: '/home/node' } } : 'stream item',
}))
})
})
}
upgrading.resolve(undefined)
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('fixture did not acquire a port')
const base = `http://127.0.0.1:${address.port}/mount/`
const close = async (): Promise<void> => {
await ctx.fiber.dispose()
for (const rawSocket of rawSockets) rawSocket.destroy()
for (const peer of mux.clients) peer.terminate()
await new Promise<void>((resolve) => { mux.close(() => { resolve() }) })
server.closeAllConnections()
await new Promise<void>((resolve, reject) => {
server.close((error) => { if (error === undefined) resolve(); else reject(error) })
})
}
try {
installConnection(ctx, {
interactions,
transport: {
streamBaseUrl: base,
fetch: (input, init) => fetch(new URL(input, base), {
...init, headers: { ...Object.fromEntries(new Headers(init.headers)), cookie: 'fixture=credential' },
}),
createWebSocket: (url) => { socket = new WebSocket(url, { headers: { cookie: 'fixture=credential' } }); return socket },
},
})
await ctx.plugin(TypertRegistry)
await ctx.plugin({ apply, inject })
await mount(ctx)
return {
ctx, connection: ctx.get('connection') as ConnectionHandle, cookies, endpoints, upgrading,
get socket() { return socket }, upgrade: () => { upgrade() }, close,
}
} catch (error) {
await close()
throw error
}
}
async function mount(ctx: Context): Promise<void> {
await ctx.remote.$mount({
package: '@fixture/node', descriptors: ['read', 'watch'].map(method => ({
id: `@fixture/node#nodeProbe/${method}`, service: 'nodeProbe', namespace: 'nodeProbe', method,
invocation: { kind: 'direct' }, parameters: [], ...(method === 'watch' ? { mode: 'stream' as const } : {}),
result: { mode: 'strict', typeSymbol: '@fixture#String', create: () => z.string() },
})),
})
}
@@ -662,6 +662,31 @@ function eventSource(ctx: Context): (dispatch: TypertRemoteEventDispatch) => voi
}
describe('Gateway forwarded events for non-owner Peers', () => {
it('denies event streams and results for passive owner and guest Peers', async () => {
const { ctx } = await setup({ connection: true })
const passive = [
ctx.connection.createPeer({ principal: OWNER, interactions: 'none' }),
ctx.connection.createPeer({ principal: GUEST, interactions: 'none' }),
]
const push = eventSource(ctx)
for (const peer of passive) {
await expect(ctx.typertGateway.wireStream.open(
'$events', { args: {} }, (async function* () {})(), peer, new AbortController().signal,
)).rejects.toMatchObject({ code: 'gateway/interactions-forbidden' })
const result = await ctx.connection.createSharedFetchHandler('/api').fetch(new Request('http://host/api/$events/result', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request', rpcId: 'rpc-passive', method: '$events/result',
payload: { args: { clientId: 'absent', eventId: 'absent', outcome: { kind: 'next' } } },
}),
}), peer)
expect(await result.json()).toMatchObject({ result: { ok: false, error: { code: 'gateway/interactions-forbidden' } } })
}
push({ event: 'fixture/notice', args: ['ignored'] })
expect(ctx.typertGateway.hasLiveClient()).toBe(false)
})
it('delivers only allowed broadcasts and no answerable waterfall to a non-owner, and refuses its results', async () => {
const { ctx, hold } = await setup({ connection: true })
hold(shelf('s1'))
@@ -5,8 +5,10 @@ import type { Fiber } from '@deepseek-ai/cordis'
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { z } from 'zod'
import { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
import { openConnectionWebSocket } from '@deepseek-ai/dsh-client-connection/src/client/socket.ts'
import {
apply as applyConnection,
installConnection,
type ClientTransportHooks,
type ConnectionGeneration,
type ConnectionGenerationSource,
@@ -348,10 +350,16 @@ async function benchFiber(
const generation = new GenerationHarness()
const start = vi.fn<ConnectionHandle['start']>(() => ({ stop: () => {} }))
ctx.provide('connection', {
interactions: 'answer',
openWebSocket: openConnectionWebSocket,
isLoopback: true,
generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
state: { getSnapshot: () => undefined, subscribe: () => () => {} },
reconnect: () => {},
rpc,
registerGenerationSource: generation.register,
start,
} as unknown as ConnectionHandle)
} satisfies ConnectionHandle)
const client = ctx.plugin({ inject, apply })
await client
return { ctx, client, generation, start }
@@ -451,10 +459,16 @@ async function loaderReadinessBench(
const start = vi.fn<ConnectionHandle['start']>(() => ({ stop }))
const call = vi.fn<ConnectionHandle['rpc']['call']>()
ctx.provide('connection', {
interactions: 'answer',
openWebSocket: openConnectionWebSocket,
isLoopback: true,
generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
state: { getSnapshot: () => undefined, subscribe: () => () => {} },
reconnect: () => {},
rpc: carrier === 'web' ? { call } : { call, open: () => unexpectedInProcessStream() },
registerGenerationSource: generation.register,
start,
} as unknown as ConnectionHandle)
} satisfies ConnectionHandle)
ctx.provide('loader', { await: () => readiness })
const client = ctx.plugin({ inject, apply })
await client
@@ -587,16 +601,20 @@ describe('Client Remote transport readiness', () => {
await ctx.plugin(TypertRegistry)
const generation = new GenerationHarness()
const live: { snapshot: ConnectionGeneration | undefined } = { snapshot: undefined }
const handle = {
const handle: ConnectionHandle = {
interactions: 'answer',
openWebSocket: openConnectionWebSocket,
isLoopback: true,
generation: { getSnapshot: () => live.snapshot, subscribe: () => () => {} },
state: { getSnapshot: () => undefined, subscribe: () => () => {} },
reconnect: () => {},
rpc: {
call: vi.fn<ConnectionHandle['rpc']['call']>(),
open: () => unexpectedInProcessStream(),
},
registerGenerationSource: generation.register,
start: () => ({ stop: () => {} }),
} as unknown as ConnectionHandle
}
const withdraw = ctx.provide('connection', handle)
const client = ctx.plugin({ inject, apply })
await client
@@ -2805,6 +2823,91 @@ describe('Client Typert API', () => {
})
describe('Remote stream client carrier lifecycle', () => {
it('keeps answering streams pending while event cleanup delays Connection recovery', async () => {
await withFakeWebSocket('https://harness.example/', async () => {
const ctx = new Context()
installConnection(ctx, { interactions: 'answer', location: { hostname: 'harness.example' } })
const connection = ctx.get('connection') as ConnectionHandle
const mux = new RemoteStreamMuxClient(undefined, true)
const cleanup = Promise.withResolvers<undefined>()
const carrierFailed = vi.fn()
let openings = 0
const sibling = new RemoteStream(connection, {
name: 'fixture follow',
open: (signal) => {
openings++
return mux.open('fixture/follow', {}, signal)
},
ended: () => new RemoteStreamCarrierError('follow ended'),
carrierFailed,
})
mux.start()
const unregister = connection.registerGenerationSource(async (signal, ready) => {
try {
for await (const _value of mux.open('$events', { args: {} }, signal)) {
ready({ home: '/home/fixture' })
}
} finally {
// An in-flight forwarded answer may settle after the event stream fails.
await cleanup.promise
}
})
const loop = connection.start({ onReconnectRequested: () => { mux.reconnect() } }, { backoffBaseMs: 1 })
const iterator = sibling[Symbol.asyncIterator]()
try {
const firstSocket = FakeWebSocket.sockets[0]!
await vi.waitFor(() => { expect(firstSocket.sent).toHaveLength(1) })
const eventOpen = parseRemoteStreamClientMessage(firstSocket.sent[0]!)
if (eventOpen.type !== 'open') throw new Error('fixture expected event open')
firstSocket.receive({ type: 'item', streamId: eventOpen.streamId, value: 'ready' })
await vi.waitFor(() => { expect(connection.generation.getSnapshot()).toBeDefined() })
const opening = iterator.next()
await vi.waitFor(() => { expect(firstSocket.sent).toHaveLength(2) })
const firstFollow = parseRemoteStreamClientMessage(firstSocket.sent[1]!)
if (firstFollow.type !== 'open') throw new Error('fixture expected follow open')
firstSocket.receive({ type: 'item', streamId: firstFollow.streamId, value: 'initial' })
const initial = await opening
if (initial.done) throw new Error('fixture follow ended before opening')
initial.value.accept()
const continued = iterator.next()
void continued.catch(() => undefined)
firstSocket.drop()
await vi.waitFor(() => { expect(openings).toBe(2) })
// Settle the second open's promise reactions while event cleanup is still held.
await new Promise<void>((resolve) => { setImmediate(resolve) })
expect(connection.generation.getSnapshot()).toBeDefined()
expect(carrierFailed).toHaveBeenCalledTimes(1)
expect(FakeWebSocket.sockets).toHaveLength(1)
const cancelled = new AbortController()
const awaiting = mux.open('fixture/abandoned', {}, cancelled.signal)[Symbol.asyncIterator]().next()
const reason = new Error('caller cancelled while offline')
cancelled.abort(reason)
await expect(awaiting).rejects.toBe(reason)
cleanup.resolve(undefined)
await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) })
const replacement = FakeWebSocket.sockets[1]!
await vi.waitFor(() => { expect(replacement.sent).toHaveLength(2) })
const replacementEvents = replacement.sent.map(parseRemoteStreamClientMessage)
for (const frame of replacementEvents) {
if (frame.type !== 'open') throw new Error('fixture expected replacement open')
replacement.receive({
type: 'item', streamId: frame.streamId,
value: frame.endpoint === '$events' ? 'ready' : 'replacement',
})
}
await expect(continued).resolves.toMatchObject({ value: { generation: 2, value: 'replacement' } })
} finally {
cleanup.resolve(undefined)
loop.stop()
unregister()
await sibling.dispose()
await mux.close()
await ctx.fiber.dispose()
}
})
})
it('does not pull another uplink item when sending ends the stream', async () => {
await withFakeWebSocket('https://harness.example', async () => {
const client = new RemoteStreamMuxClient()
@@ -2983,6 +3086,33 @@ describe('Remote stream client carrier lifecycle', () => {
})
})
it('rejects a stream waiting on an established socket when its close completes', async () => {
await withFakeWebSocket('https://harness.example', async () => {
FakeWebSocket.autoOpen = false
FakeWebSocket.dispatchClose = false
const client = new RemoteStreamMuxClient()
client.start()
const socket = FakeWebSocket.sockets[0]!
socket.open()
socket.close()
expect(socket.readyState).toBe(FakeWebSocket.CLOSING)
const pending = client.open('feed/follow', {}, new AbortController().signal)
[Symbol.asyncIterator]().next()
const rejected = expect(pending).rejects.toMatchObject({
name: 'RemoteStreamCarrierError', message: 'api gateway: Remote stream WebSocket closed',
})
try {
socket.drop()
await rejected
expect(socket.sent).toEqual([])
expect(FakeWebSocket.sockets).toHaveLength(1)
} finally {
socket.drop()
await client.close()
}
})
})
it('mints a new wire stream id when the same endpoint opens on a replacement socket', async () => {
await withFakeWebSocket('https://harness.example', async () => {
const client = new RemoteStreamMuxClient()
@@ -3089,6 +3219,21 @@ describe('Remote stream client carrier lifecycle', () => {
})
})
it('rejects an answering stream when its initial socket never opens', async () => {
await withFakeWebSocket('http://harness.example/', async () => {
FakeWebSocket.autoOpen = false
const client = new RemoteStreamMuxClient(undefined, true)
client.start()
try {
const pending = client.open('$events', {}, new AbortController().signal)[Symbol.asyncIterator]().next()
FakeWebSocket.sockets[0]!.drop()
await expect(pending).rejects.toThrow('Remote stream WebSocket closed before opening')
} finally {
await client.close()
}
})
})
it('fails waiters with one socket attempt and lets the owner start the next attempt', async () => {
await withFakeWebSocket('http://harness.example/', async () => {
FakeWebSocket.autoOpen = false
@@ -3194,10 +3339,12 @@ describe('Remote stream client carrier lifecycle', () => {
[Symbol.asyncIterator]().next()
await vi.waitFor(() => { expect(FakeWebSocket.sockets[1]?.sent).toHaveLength(1) })
const disposedSocket = FakeWebSocket.sockets[1]!
await disposedClient.close()
const closing = disposedClient.close()
const rejected = expect(disposed).rejects.toThrow('Remote stream client disposed')
disposedSocket.receive({ type: 'end', streamId: 'stale' })
disposedSocket.drop()
await expect(disposed).rejects.toThrow('Remote stream client disposed')
await closing
await rejected
})
})
})
@@ -1286,7 +1286,7 @@ describe('TypertGatewayService', () => {
})(), { home: '/home/fixture' })
const carrier = new AbortController()
const events = (await ctx.typertGateway.wireStream.open(
'$events', { args: {} }, (async function* () {})(), ownerPeer(ctx), carrier.signal,
'$events', { args: {} }, (async function* () {})(), connection.browserPeer, carrier.signal,
))[Symbol.asyncIterator]()
const opening = await events.next()
expect(opening).toMatchObject({
@@ -1387,8 +1387,9 @@ describe('TypertGatewayService', () => {
const removeLookup = registerAgentLookup(ctx, { id: 'agent-1' })
const removeStrict = registerStrict(ctx, [createDescriptor()])
let strictActive = true
expect(routes).toHaveLength(1)
const server = await serveRoute(routes[0]!)
const apiRoute = routes.find(route => route.path === '/api')
if (apiRoute === undefined) throw new Error('fixture API route was not installed')
const server = await serveRoute(apiRoute)
const cookie = browserCookie(ctx.connection, server.origin)
try {
@@ -0,0 +1,122 @@
/** Loader-mounted Remote methods and interactions for authenticated transport tests. */
import { Context } from '@deepseek-ai/cordis'
import { Remote, TypertRemoteService, type PeerScope } from '@deepseek-ai/dsh-typert-protocol'
import type { TypertRemoteEventDispatch, TypertRemoteEventOutcome } from '@deepseek-ai/dsh-api-gateway'
import type {} from '@deepseek-ai/dsh-client-connection'
import type {} from '@deepseek-ai/dsh-typert-registry'
interface CallObservation {
readonly peer: PeerScope
readonly signal: AbortSignal
finished: boolean
}
declare module '@deepseek-ai/cordis' {
interface Context {
operatorConnectFixture: OperatorConnectFixture
}
}
/** Test producer; credentials, admission, dispatch and disposal use the real plugins. */
export default class OperatorConnectFixture extends TypertRemoteService {
static inject = ['typert', 'typertGateway', 'connection']
readonly calls = new Map<string, CallObservation>()
readonly effects = new Set<string>()
readonly holds = new Map<string, () => void>()
readonly streamCleanup = Promise.withResolvers<undefined>()
delayStreamCleanup = false
private readonly dispatches: TypertRemoteEventDispatch[] = []
private wake: (() => void) | undefined
constructor(ctx: Context) {
super(ctx, 'operatorConnectFixture', { namespace: 'fixture' })
ctx.effect(() => ctx.typertGateway.registerRemoteEvents(signal => this.events(signal), { home: '/home/fixture' }))
}
/** Record the authenticated caller and its scope. */
@Remote
identity(label: string, signal: AbortSignal): object {
const call = this.observe(label, signal)
call.finished = true
return { peerId: call.peer.id, interactions: call.peer.interactions }
}
/** Hold an independently cancellable HTTP request until the test completes it. */
@Remote
async hold(label: string, signal: AbortSignal): Promise<string> {
const call = this.observe(label, signal)
const finish = Promise.withResolvers<undefined>()
const complete = (): void => { finish.resolve(undefined) }
this.holds.set(label, complete)
signal.addEventListener('abort', complete, { once: true })
try {
if (!signal.aborted) await finish.promise
return signal.aborted ? 'cancelled' : 'completed'
} finally {
signal.removeEventListener('abort', complete)
this.holds.delete(label)
call.finished = true
}
}
/** Keep a stream open, optionally delaying its iterator cleanup after carrier loss. */
@Remote({ mode: 'stream' })
async *follow(label: string, signal: AbortSignal): AsyncIterable<string> {
const call = this.observe(label, signal)
try {
yield `${label}:ready`
await new Promise<void>((resolve) => {
if (signal.aborted) resolve()
else signal.addEventListener('abort', () => { resolve() }, { once: true })
})
} finally {
if (this.delayStreamCleanup) await this.streamCleanup.promise
call.finished = true
}
}
/**
* Enqueue an interaction without introducing Agent or model dependencies.
* @param label - prompt observed by the answering Client.
* @returns producer settlement, left pending while no Client answers.
*/
pending(label: string): Promise<TypertRemoteEventOutcome> {
const settled = Promise.withResolvers<TypertRemoteEventOutcome>()
void settled.promise.catch(() => {})
const subject = { ctx: this.ctx }
this.dispatches.push({
event: 'fixture/approval', request: { agent: subject, prompt: label },
context: { value: this.ctx, subject, agentId: `agent-${label}` },
resolve: settled.resolve, reject: settled.reject,
})
this.wake?.()
this.wake = undefined
return settled.promise
}
private observe(label: string, signal: AbortSignal): CallObservation {
const invocation = this.ctx.invocation
if (invocation === undefined) throw new Error('fixture method ran outside Gateway invocation')
const call: CallObservation = { peer: invocation.peer, signal, finished: false }
this.calls.set(label, call)
invocation.peer.ctx.effect(() => {
this.effects.add(label)
return () => { this.effects.delete(label) }
})
return call
}
private async *events(signal: AbortSignal): AsyncIterable<TypertRemoteEventDispatch> {
const wake = (): void => { this.wake?.(); this.wake = undefined }
signal.addEventListener('abort', wake, { once: true })
try {
while (!signal.aborted) {
const next = this.dispatches.shift()
if (next !== undefined) yield next
else await new Promise<void>((resolve) => { this.wake = resolve })
}
} finally {
signal.removeEventListener('abort', wake)
}
}
}
@@ -0,0 +1,350 @@
/** Connect lifecycle and event ownership through real Loader, HTTP and WebSocket composition. */
import { randomUUID } from 'node:crypto'
import { once } from 'node:events'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { IncomingMessage } from 'node:http'
import { tmpdir } from 'node:os'
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import * as Connection from '@deepseek-ai/dsh-client-connection'
import LocalCredentials from '@deepseek-ai/dsh-credentials-local'
import WebServer from '@deepseek-ai/dsh-host-webserver'
import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import TypertGateway from '@deepseek-ai/dsh-api-gateway'
import RemoteAccess from '@deepseek-ai/dsh-api-remote-access'
import { afterEach, describe, expect, it, vi } from 'vitest'
import WebSocket from 'ws'
import { z } from 'zod'
import { parseRemoteStreamServerMessage, type RemoteStreamServerMessage } from '../src/stream-protocol.ts'
import { RemoteStreamMuxServer } from '../src/stream-server.ts'
import OperatorConnectFixture from './operator-connect-fixture.ts'
interface SocketClient {
readonly socket: WebSocket
readonly closed: Promise<void>
readonly frames: RemoteStreamServerMessage[]
readonly errors: Error[]
}
interface Host {
readonly ctx: Context
readonly origin: string
readonly fixture: OperatorConnectFixture
}
const roots: string[] = []
const hosts: Host[] = []
const clients: SocketClient[] = []
const readySchema = z.object({ type: z.literal('ready'), clientId: z.string(), host: z.object({ home: z.string() }) })
const deliverySchema = z.object({
type: z.literal('waterfall'), eventId: z.string(), request: z.object({ prompt: z.string() }),
})
afterEach(async () => {
for (const host of hosts) {
host.fixture.streamCleanup.resolve(undefined)
for (const finish of host.fixture.holds.values()) finish()
}
await Promise.all(clients.splice(0).map(async (client) => {
if (client.socket.readyState !== WebSocket.CLOSED) client.socket.terminate()
await client.closed
}))
await Promise.all(hosts.splice(0).map(host => host.ctx.fiber.dispose()))
for (const root of roots.splice(0)) {
if (!isAbsolute(root) || dirname(root) !== resolve(tmpdir()) || !basename(root).startsWith('dsh-operator-connect-')) {
throw new Error(`refusing unexpected fixture cleanup path: ${root}`)
}
await rm(root, { recursive: true, force: true })
}
})
describe('operator connect through Loader composition', () => {
it('admits HTTP before attachment and releases the Operator before slow stream cleanup', async () => {
const host = await boot()
const cookie = await exchange(host, 'none')
await success(host, cookie, 'fixture/identity', { label: 'before' })
expect(call(host, 'before').peer.interactions).toBe('none')
expect(call(host, 'before').peer === host.ctx.connection.browserPeer).toBe(false)
const client = await mux(host, cookie)
expect(await refusedMux(host, cookie)).toBe(409)
await success(host, cookie, 'fixture/identity', { label: 'owner-alive' })
host.fixture.delayStreamCleanup = true
open(client, 'follow', 'fixture/follow', { label: 'follow' })
await frame(client, 'follow', 'item')
await close(client)
await vi.waitFor(() => { expect(call(host, 'follow').signal.aborted).toBe(true) })
expect(call(host, 'follow').finished).toBe(false)
expect((await rpc(host, cookie, 'fixture/identity', { label: 'after' })).status).toBe(401)
expect(await refusedMux(host, cookie)).toBe(401)
expect(host.fixture.calls.has('after')).toBe(false)
await vi.waitFor(() => { expect(host.fixture.effects.size).toBe(0) })
host.fixture.streamCleanup.resolve(undefined)
await vi.waitFor(() => { expect(call(host, 'follow').finished).toBe(true) })
})
it('reports failed Operator release without retaining its credential or blocking shutdown', async () => {
const host = await boot()
const cookie = await exchange(host, 'none')
await success(host, cookie, 'fixture/identity', { label: 'release-failure' })
const peer = call(host, 'release-failure').peer
const dispose = peer.dispose.bind(peer)
const failure = new Error('fixture Operator disposal failure')
const disposing = vi.spyOn(peer, 'dispose').mockImplementationOnce(async () => {
await dispose()
throw failure
})
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
await close(await mux(host, cookie))
await vi.waitFor(() => {
expect(report).toHaveBeenCalledWith('[api-gateway] Peer release failed:', failure)
})
expect((await rpc(host, cookie, 'fixture/identity', { label: 'refused' })).status).toBe(401)
expect(host.fixture.calls.has('refused')).toBe(false)
expect(host.fixture.effects.size).toBe(0)
await host.ctx.fiber.dispose()
expect(disposing).toHaveBeenCalledOnce()
} finally {
disposing.mockRestore()
report.mockRestore()
}
})
it('releases admission when the underlying socket is destroyed before upgrade', async () => {
const host = await boot()
const cookie = await exchange(host, 'none')
const admit = host.ctx.connection.admitConnection.bind(host.ctx.connection)
const admission = vi.spyOn(host.ctx.connection, 'admitConnection').mockImplementationOnce((request) => {
const result = admit(request)
if (!(request instanceof IncomingMessage)) throw new Error('fixture expected a Node upgrade request')
request.socket.destroy()
return result
})
const upgrade = vi.spyOn(RemoteStreamMuxServer.prototype, 'handleUpgrade')
try {
const client = socketClient(host, cookie)
await client.closed
expect(client.socket.readyState).toBe(WebSocket.CLOSED)
expect(upgrade).not.toHaveBeenCalled()
expect((await rpc(host, cookie, 'fixture/identity', { label: 'refused' })).status).toBe(401)
expect(host.fixture.calls.has('refused')).toBe(false)
} finally {
admission.mockRestore()
upgrade.mockRestore()
}
})
it('releases admission and closes the socket when the WebSocket upgrade throws', async () => {
const host = await boot()
const cookie = await exchange(host, 'none')
const failure = new Error('fixture WebSocket upgrade failure')
const upgrade = vi.spyOn(RemoteStreamMuxServer.prototype, 'handleUpgrade').mockImplementationOnce(() => {
throw failure
})
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const client = socketClient(host, cookie)
await client.closed
expect(client.socket.readyState).toBe(WebSocket.CLOSED)
expect(report).toHaveBeenCalledWith('[api-gateway] Remote stream upgrade failed:', failure)
expect((await rpc(host, cookie, 'fixture/identity', { label: 'refused' })).status).toBe(401)
expect(host.fixture.calls.has('refused')).toBe(false)
await close(await mux(host, await exchange(host, 'none')))
} finally {
upgrade.mockRestore()
report.mockRestore()
}
})
it('lets an admitted unary finish after its lifecycle socket closes', async () => {
const host = await boot()
const cookie = await exchange(host, 'none')
const client = await mux(host, cookie)
const pending = rpc(host, cookie, 'fixture/hold', { label: 'inflight' })
await vi.waitFor(() => { expect(host.fixture.holds.has('inflight')).toBe(true) })
await close(client)
expect((await rpc(host, cookie, 'fixture/identity', { label: 'refused' })).status).toBe(401)
expect(call(host, 'inflight').signal.aborted).toBe(false)
expect(call(host, 'inflight').finished).toBe(false)
host.fixture.holds.get('inflight')?.()
await expect(pending).resolves.toMatchObject({ status: 200, body: { result: { ok: true, value: 'completed' } } })
expect(call(host, 'inflight').signal.aborted).toBe(false)
})
it('forbids passive subscription and results, enforces stream ownership, and accepts an answering page', async () => {
const host = await boot()
const passiveCookie = await exchange(host, 'none')
const passive = await mux(host, passiveCookie)
const pageCookie = await exchange(host, 'html')
const page = await mux(host, pageCookie)
await success(host, pageCookie, 'fixture/identity', { label: 'browser' })
expect(call(host, 'browser').peer === host.ctx.connection.browserPeer).toBe(true)
const ready = await events(page)
const otherCookie = await exchange(host, 'answer')
const other = await mux(host, otherCookie)
await events(other)
open(passive, 'forbidden', '$events', {})
expect(await frame(passive, 'forbidden', 'error')).toMatchObject({ error: { code: 'gateway/interactions-forbidden' } })
const pending = host.fixture.pending('approval')
const delivery = await invocation(page, 'approval')
const args = { clientId: ready.clientId, eventId: delivery.eventId, outcome: { kind: 'result', value: 'allowed' } }
for (const cookie of [passiveCookie, otherCookie]) {
expect(await rpc(host, cookie, '$events/result', args)).toMatchObject({
body: { result: { ok: false, error: { code: 'gateway/interactions-forbidden' } } },
})
}
await success(host, pageCookie, '$events/result', args)
await expect(pending).resolves.toEqual({ kind: 'result', value: 'allowed' })
expect(passive.frames.filter(item => item.type === 'item')).toEqual([])
})
})
async function boot(): Promise<Host> {
const root = await mkdtemp(join(tmpdir(), 'dsh-operator-connect-'))
roots.push(root)
const distIndex = join(root, 'index.html')
await writeFile(distIndex, '<head></head><body>operator fixture</body>')
const configPath = join(root, 'cordis.yml')
const rows = [
{ name: '@deepseek-ai/dsh-credentials-local', config: { path: join(root, '.credentials.yaml'), watch: false } },
{ name: '@deepseek-ai/dsh-host-webserver', config: { host: '127.0.0.1', port: 0 } },
{ name: '@deepseek-ai/dsh-client-connection' },
{ name: '@deepseek-ai/dsh-host-frontend-static', config: { distIndex } },
{ name: '@deepseek-ai/dsh-typert-registry' },
{ name: '@deepseek-ai/dsh-api-remote-access' },
{ name: '@deepseek-ai/dsh-api-gateway' },
{ name: '@fixture/operator-connect' },
]
await writeFile(configPath, rows.map(row => `- ${JSON.stringify({ ...row, name: `cordis:${row.name}` })}`).join('\n') + '\n')
const ctx = new Context()
ctx.baseUrl = pathToFileURL(root).href + '/'
try {
await ctx.plugin(Loader)
Object.assign(ctx.loader.builtins, {
include: Include,
'@deepseek-ai/dsh-credentials-local': LocalCredentials,
'@deepseek-ai/dsh-host-webserver': WebServer,
'@deepseek-ai/dsh-client-connection': Connection,
'@deepseek-ai/dsh-host-frontend-static': FrontendStatic,
'@deepseek-ai/dsh-typert-registry': TypertRegistry,
'@deepseek-ai/dsh-api-remote-access': RemoteAccess,
'@deepseek-ai/dsh-api-gateway': TypertGateway,
'@fixture/operator-connect': OperatorConnectFixture,
})
await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
await ctx.loader.await()
expect(ctx.webServer.port).toBeGreaterThan(0)
const host = { ctx, origin: `http://127.0.0.1:${ctx.webServer.port}`, fixture: ctx.operatorConnectFixture }
hosts.push(host)
return host
} catch (error) {
await ctx.fiber.dispose()
throw error
}
}
async function exchange(host: Host, mode: 'html' | 'none' | 'answer'): Promise<string> {
const target = new URL(host.ctx.connection.authenticatedUrl(`${host.origin}${mode === 'html' ? '/' : '/connect'}`))
if (mode !== 'html') target.searchParams.set('interactions', mode)
const response = await fetch(target, { redirect: 'manual', headers: { accept: mode === 'html' ? 'text/html' : 'application/json' } })
expect(response.status).toBe(mode === 'html' ? 303 : 302)
const cookie = response.headers.get('set-cookie')
await response.text()
if (cookie === null) throw new Error('connect omitted its signed cookie')
return cookie.split(';', 1)[0]!
}
function socketClient(host: Host, cookie: string): SocketClient {
const socket = new WebSocket(`${host.origin.replace('http:', 'ws:')}/api/remote.mux`, { headers: { cookie } })
const closed = new Promise<void>((resolve) => { socket.once('close', () => { resolve() }) })
const frames: RemoteStreamServerMessage[] = []
const errors: Error[] = []
socket.on('error', error => errors.push(error))
socket.on('message', (data) => {
const text = Array.isArray(data) ? Buffer.concat(data).toString('utf8')
: Buffer.from(data instanceof ArrayBuffer ? new Uint8Array(data) : data).toString('utf8')
frames.push(parseRemoteStreamServerMessage(text))
})
const client = { socket, closed, frames, errors }
clients.push(client)
return client
}
async function mux(host: Host, cookie: string): Promise<SocketClient> {
const client = socketClient(host, cookie)
await once(client.socket, 'open')
return client
}
async function refusedMux(host: Host, cookie: string): Promise<number> {
const client = socketClient(host, cookie)
const status = await new Promise<number>((resolve, reject) => {
client.socket.once('open', () => { reject(new Error('forbidden Mux was admitted')) })
client.socket.once('unexpected-response', (_request, response) => {
response.resume()
resolve(response.statusCode ?? 0)
client.socket.terminate()
})
client.socket.once('error', reject)
})
await client.closed
return status
}
async function close(client: SocketClient): Promise<void> {
client.socket.close()
await client.closed
expect(client.errors).toEqual([])
}
function open(client: SocketClient, streamId: string, endpoint: string, args: object): void {
client.socket.send(JSON.stringify({ type: 'open', streamId, endpoint, payload: { args } }))
}
async function frame(client: SocketClient, streamId: string, type: RemoteStreamServerMessage['type']): Promise<RemoteStreamServerMessage> {
await vi.waitFor(() => { expect(client.frames.some(frame => frame.streamId === streamId && frame.type === type)).toBe(true) })
return client.frames.find(frame => frame.streamId === streamId && frame.type === type)!
}
async function events(client: SocketClient): Promise<z.infer<typeof readySchema>> {
open(client, 'events', '$events', {})
const ready = await frame(client, 'events', 'item')
if (ready.type !== 'item') throw new Error('event stream failed to open')
return readySchema.parse(ready.value)
}
async function invocation(client: SocketClient, label: string): Promise<z.infer<typeof deliverySchema>> {
let delivery: z.infer<typeof deliverySchema> | undefined
await vi.waitFor(() => {
for (const frame of client.frames) {
if (frame.type !== 'item') continue
const parsed = deliverySchema.safeParse(frame.value)
if (parsed.success && parsed.data.request.prompt === label) delivery = parsed.data
}
expect(delivery).toBeDefined()
})
return delivery!
}
async function rpc(
host: Host, cookie: string, endpoint: string, args: object,
): Promise<{ status: number; body: unknown }> {
const response = await fetch(`${host.origin}/api/${endpoint}`, {
method: 'POST', headers: { cookie, 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: endpoint, payload: { args } }),
})
return { status: response.status, body: response.status === 200 ? await response.json() : await response.text() }
}
async function success(host: Host, cookie: string, endpoint: string, args: object): Promise<void> {
expect(await rpc(host, cookie, endpoint, args)).toMatchObject({ status: 200, body: { result: { ok: true } } })
}
function call(host: Host, label: string) {
const observation = host.fixture.calls.get(label)
if (observation === undefined) throw new Error(`fixture call was not admitted: ${label}`)
return observation
}
@@ -282,6 +282,48 @@ describe('Remote stream mux server carrier lifecycle', () => {
await didReturn
})
it('reports connection and Peer cleanup faults after the physical socket has closed', async () => {
const entry = await startMux(async () => { throw new Error('fixture opener failure') })
const client = await connect(entry.url)
const serverSocket = acceptedSocket(entry.mux)
const write = Promise.withResolvers<(error?: Error) => void>()
const writer = serverSocket as { send(data: unknown, callback: (error?: Error) => void): void }
const sending = vi.spyOn(writer, 'send').mockImplementation((_data, callback) => { write.resolve(callback) })
const connectionFailure = new Error('fixture terminal close failure')
const cleanupFailure = new Error('fixture Peer close failure')
const close = serverSocket.close.bind(serverSocket)
const closing = vi.spyOn(serverSocket, 'close').mockImplementation((code, reason) => {
if (code === 1011) throw connectionFailure
if (code === 1001) throw cleanupFailure
close(code, reason)
})
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
let finishWrite: ((error?: Error) => void) | undefined
try {
client.send(openFrame('write-during-close'))
finishWrite = await write.promise
const clientClosed = once(client, 'close')
const serverClosed = once(serverSocket, 'close')
client.close()
await Promise.all([clientClosed, serverClosed])
finishWrite(new Error('fixture write interrupted by socket close'))
await entry.mux.close()
expect(serverSocket.readyState).toBe(WebSocket.CLOSED)
expect(client.readyState).toBe(WebSocket.CLOSED)
expect(report.mock.calls).toEqual([
['[api-gateway] Remote stream connection failed:', connectionFailure],
['[api-gateway] Remote stream scope cleanup failed:', cleanupFailure],
])
expect(closing).toHaveBeenCalledWith(1011, 'Remote stream failure could not be delivered')
expect(closing).toHaveBeenCalledWith(1001, 'peer left')
} finally {
sending.mockRestore()
closing.mockRestore()
finishWrite?.(new Error('fixture teardown'))
report.mockRestore()
}
})
it('terminates active sockets on close and reports a repeated close', async () => {
let opened!: () => void
const didOpen = new Promise<void>((resolve) => { opened = resolve })
@@ -576,6 +618,7 @@ async function fixturePeer(): Promise<PeerScope> {
id: 'fixture-peer' as PeerId,
ctx: fiber.ctx,
principal: { kind: 'owner' },
interactions: 'answer',
signal: new AbortController().signal,
dispose: async () => { await fiber.dispose() },
}
+1 -1
View File
@@ -1,3 +1,3 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle(import.meta.dirname, ['lib/types/index.js'])
export default clientBundle(import.meta.dirname, ['lib/types/index.js'], { dualClient: true })
@@ -21,6 +21,8 @@ async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCal
const rowsCalls: unknown[] = []
const killCalls: unknown[] = []
const connection: ConnectionHandle = {
interactions: 'answer',
openWebSocket: () => { throw new Error('unexpected WebSocket') },
isLoopback: true,
generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} },
state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} },
+4 -2
View File
@@ -117,9 +117,11 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
host.agents.register(rootAgent)
host.agents.register(scopedAgent)
if (routes.length !== 1 || routes[0].path !== '/api') {
const apiRoutes = routes.filter(route => route.path === '/api')
if (apiRoutes.length !== 1) {
throw new Error('Connection did not register exactly one /api route')
}
const apiRoute = apiRoutes[0]
const server = createServer((request, response) => {
if ((request.url ?? '/').startsWith('/?')) {
if (host.connection.authorizeIndex(request, response)) {
@@ -128,7 +130,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
}
return
}
void routes[0].handler(request, response)
void apiRoute.handler(request, response)
})
await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen))
const address = server.address()
@@ -69,7 +69,7 @@ function guestPolicy(ctx: Context, ...bindings: RemoteAccessBinding[]): RemoteAc
/** Invoke `session/<method>` as the guest through the Gateway. */
function guestCall(ctx: Context, method: string, args: Readonly<Record<string, unknown>>): Promise<unknown> {
const peer: PeerScope = { id: 'session-access-peer' as PeerId, ctx, principal: GUEST, signal: new AbortController().signal, dispose: async () => {} }
const peer: PeerScope = { id: 'session-access-peer' as PeerId, ctx, principal: GUEST, interactions: 'answer', signal: new AbortController().signal, dispose: async () => {} }
return ctx.typertGateway.invoke({ peer, namespace: 'session', method, args })
}
@@ -93,7 +93,7 @@ async function harness(): Promise<{
const invocation: RemoteInvocation = {
request: { namespace: 'session', method, args: {} },
service: 'sessionController',
peer: { id: 'session-access-peer' as PeerId, ctx, principal, signal: new AbortController().signal, dispose: async () => {} },
peer: { id: 'session-access-peer' as PeerId, ctx, principal, interactions: 'answer', signal: new AbortController().signal, dispose: async () => {} },
signal: new AbortController().signal,
uplink: <In>(): AsyncIterable<In> => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }),
}
@@ -737,7 +737,7 @@ function attachController(
) {
const invocation: RemoteInvocation = {
request: { namespace: 'terminal', method: 'attach', args: {} }, service: 'terminalController', signal,
peer: { id: 'test-peer' as PeerId, ctx: agent.ctx, principal: { kind: 'owner' }, signal, dispose: async () => {} },
peer: { id: 'test-peer' as PeerId, ctx: agent.ctx, principal: { kind: 'owner' }, interactions: 'answer', signal, dispose: async () => {} },
uplink: <In>() => input as AsyncIterable<In>,
uplinkWindow,
}
@@ -94,7 +94,7 @@ function followerContext(ctx: Context, principal: Principal): Context {
const invocation: RemoteInvocation = {
request: { namespace: 'workspace', method: 'follow', args: {} },
service: 'workspaceController',
peer: { id: 'workspace-access-peer' as PeerId, ctx, principal, signal: new AbortController().signal, dispose: async () => {} },
peer: { id: 'workspace-access-peer' as PeerId, ctx, principal, interactions: 'answer', signal: new AbortController().signal, dispose: async () => {} },
signal: new AbortController().signal,
uplink: <In>(): AsyncIterable<In> => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }),
}
+8 -8
View File
@@ -15,14 +15,14 @@
en: 18792bf2fb4f8030
zh: 84c63acc7e0099ae
/deepseek-ai-dsh-client-connection/use-this-package:
en: 13b54969ff1d00a4
zh: 4d2ffff0445472ed
en: da6810858bf74a86
zh: d623e510ec74520f
/deepseek-ai-dsh-client-connection/browser-authentication-and-request-trust:
en: dbd48251ac03d968
zh: 6323c09d75e56ecc
en: e2154aa9caa859b4
zh: 32832efe4dc37c06
/deepseek-ai-dsh-client-connection/connection-generation:
en: 116f94e44c0d87e7
zh: 59ef48984154d858
en: d9fe48c025ace7ce
zh: 0e23b299fbf25ae6
/deepseek-ai-dsh-client-connection/model-experience:
en: 88bbc33fe9ae9df8
zh: bfbab6d51b4abd40
@@ -30,8 +30,8 @@
en: ca75c51c89c2c9b0
zh: de54f3467ef3b9be
/deepseek-ai-dsh-client-connection/known-limitations-and-deferred-work:
en: ad73c3b97f348ad1
zh: fec950f970112182
en: a860aa95302e4f02
zh: 4df79a08b5fe018e
/deepseek-ai-dsh-client-connection/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
+9 -4
View File
@@ -25,18 +25,20 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con
<a id="use-this-package"></a>
## Use this package
The `./client` import selects the standalone `lib/client.node.js` ESM bundle in Node and the `lib/client.browser.js` loader factory in browsers. The Web Host still serves the browser export at the logical `/plugins/.../client.js` URL. Import the Client entry for a non-page Cordis tree; the separate package root is the Host entry.
A static desktop page can provide `__DSH_TRANSPORT__.streamBaseUrl` for the HTTP origin of its owned Host. The Gateway uses that origin for its WebSocket while HTTP transport remains independently selected. The desktop carrier owns authentication; setting the origin alone grants no access.
Unary RPC requests use JSON. A Host handler may return byte attachments that it has already separated from the JSON-compatible result value, with each attachment naming its result-relative path. Connection writes these values as multipart parts. The JSON `metadata` part contains the RPC response envelope with `null` placeholders and an attachment table recording each path, codec, and part identifier. Paths use string keys and numeric array indices; no business field name is reserved. The Client validates the envelope, `rpcId`, attachment table, and parts, then restores each byte value as an `ArrayBuffer`-backed view. Results without attachments, including base64 strings, and failures remain JSON. Logical RPC carriers return decoded native values directly. Connection does not discover binary fields or depend on Typert; the handler that owns a result protocol performs any type-directed or runtime projection before returning. Binary parameters, events, and streamed binary results are unsupported.
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, including a stream's uplink, 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).
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, including a stream's uplink, 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. Non-page carriers supply `ClientTransportHooks.createWebSocket` with the same authenticated Cookie as HTTP and a `streamBaseUrl`; `ConnectionHandle.openWebSocket()` resolves the Mux path against that base and invokes the selected factory. 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).
-----
<a id="browser-authentication-and-request-trust"></a>
## Browser authentication and request trust
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 its application URL with `?token=...`, preserving the caller's authority and mount; `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 `./`, which drops the token and keeps the request's directory. 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.
Over the Web carrier, every Host RPC method and WebSocket stream requires an authenticated browser or API Peer; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens its application URL with `?token=...`, preserving the caller's authority and mount; `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 `./`, which drops the token and keeps the request's directory. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. Static assets remain public. The HTTP carrier accepts query tokens only at the root exchange and `/connect`, and accepts no Authorization-header token.
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`, and carry `Secure` exactly when the listener that received the index request serves TLS; only that listener's own protocol decides, never a `publicUrl` announcement or a forwarded header.
@@ -44,14 +46,16 @@ HTTPS cookie names and signed audiences include the `https:` scheme, so HTTP coo
Before authentication, every request passes `src/api-request-trust.ts`. Its `Host` must be loopback, equal the listener's bind IP literal on any port, or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries. Host and configured entries use the listener's scheme for WHATWG default-port normalization, not a forwarded header or `Origin`. An attached HTTP(S) `Origin` must name the same authority, with Host parsed under that Origin's scheme to support HTTPS proxies over HTTP upstreams; `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. `allowsRemoteAuthorities` reports whether `trustedHosts` permits a non-loopback authority; accepting the bind IP does not change that list. 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` is 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).
Every request speaks for one Peer, a `PeerScope` whose `principal` names the caller and whose `signal` aborts when its scope ends. `ctx.connection.browserPeer` is the Peer every authenticated browser connection shares; its principal is `{ kind: 'owner' }` and its scope is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or `browserPeer`; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `ctx.connection.createPeer({ principal })` creates an independent in-process Peer, disposed with the Context that read `ctx.connection` or earlier by its own `dispose()`; no route, header, query, or cookie can create one or set its principal. The exported `Peer` class implements both, and a composition without Connection constructs it directly as `new Peer(owner, { principal })`. `fetch(request, peer)` and the HTTP bridge take the Peer explicitly; an exact route answers 403 to a Peer whose principal is not the owner. Method-level access for other principals belongs to [`dsh-api-remote-access`](../../api/remote-access/README.md).
Every request speaks for one Peer, a `PeerScope` whose `principal` names the caller, whose `interactions` governs participation in forwarded events, and whose `signal` aborts when its scope ends. `ctx.connection.browserPeer` is shared by authenticated browser connections; it has owner principal and defaults to `interactions: 'answer'`. `ctx.connection.admit(request)` checks trust and browser or API authentication and returns the credential's live Peer or a rejection. The `/api` route and Gateway WebSocket upgrade use this admission; handlers receive the call's Peer. `ctx.connection.createPeer({ principal, interactions? })` creates an independent in-process Peer, disposed with the Context that read the service or earlier through its own `dispose()`; omitted interactions default to `answer`. The exported `Peer` class accepts the same options. `fetch(request, peer)` and the HTTP bridge take the Peer explicitly; an exact route answers 403 to a non-owner Peer. Method-level access for other principals belongs to [`dsh-api-remote-access`](../../api/remote-access/README.md).
Only API callers use `GET /connect?token=...&interactions=answer|none`: it immediately creates a Peer with owner principal and returns 302 with the Mux destination and a signed API Cookie. The API connection defaults to `none`; invalid or repeated declarations return 400. HTTP may precede WebSocket establishment. One API socket owns cleanup: duplicate attachment returns 409, closure removes its Peer, and later HTTP using that Cookie is refused. Already-admitted unary calls retain request-level cancellation, but `invocation.peer.ctx` cannot register effects after its Peer has been disposed. API callers keep their Cookie separate from browser storage; the authority-specific Cookie name is shared.
Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. Client disconnection aborts the handler signal; the bridge stops socket writes and drains any remaining response chunks. WebSocket stream ownership remains with API Gateway.
<a id="connection-generation"></a>
## Connection generation
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.
`installConnection` resolves `interactions` to `answer` for pages and `none` for non-page callers unless explicitly supplied. Only answering Clients register 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.
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`.
@@ -75,6 +79,7 @@ None; this package neither assembles nor sends a provider request.
- **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.
- **A plain-HTTP listener exposes the bearer cookie in transit** — such a deployment cannot mark the browser cookie `Secure`, so serving the same authority over a network can leak the session cookie; configure TLS on the Web carrier ([`dsh-host-webserver`](../../host/webserver/README.md)) to have the cookie marked `Secure` and confined to TLS.
- **Unused API Peers may remain until Host shutdown** — no connect deadline or idle collection is imposed on lightweight records whose caller never establishes a WebSocket. Cookie expiry still rejects new requests. API callers must reconnect through `/connect` after their owning socket closes or the Host restarts.
- **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record and restarting `dsh` revokes every session.
+9 -4
View File
@@ -25,18 +25,20 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
`./client` 导入在 Node 中选择独立的 `lib/client.node.js` ESM bundle,在浏览器中选择 `lib/client.browser.js` loader factory。Web Host 仍以逻辑 URL `/plugins/.../client.js` 提供浏览器导出。非页面 Cordis 树应导入 Client 入口;独立的包根入口是 Host 入口。
静态桌面页面可以通过 `__DSH_TRANSPORT__.streamBaseUrl` 提供其所拥有 Host 的 HTTP origin。Gateway 将该 origin 用于 WebSocket,HTTP 传输仍独立选择。桌面载体负责认证;仅设置 origin 不会授予访问权限。
一元 RPC 请求使用 JSON。Host handler 可以返回已经从 JSON 兼容结果值中分离的字节附件,每个附件标明其相对于结果的路径。Connection 将这些值写为 multipart 部分。JSON `metadata` 部分包含带 `null` 占位值的 RPC 响应信封,以及记录各路径、codec 和部分标识符的附件表。路径使用字符串键与数字数组下标,不保留任何业务字段名。Client 校验信封、`rpcId`、附件表和各部分,再将每个字节值恢复为以 `ArrayBuffer` 为底层缓冲区的视图。没有附件的结果(包括 base64 字符串)与失败仍使用 JSON。逻辑 RPC 载体直接返回解码后的原生值。Connection 不识别二进制字段,也不依赖 Typert;拥有结果协议的 handler 在返回前执行按类型或按运行时值的投影。不支持二进制参数、事件和二进制流式结果。
浏览器通过 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) 提供。
浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流(包括流的上行),不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。非页面载体通过 `ClientTransportHooks.createWebSocket` 使用与 HTTP 相同的认证 Cookie,并提供 `streamBaseUrl`;`ConnectionHandle.openWebSocket()` 根据该 base 解析 Mux 路径,再调用所选工厂。每次调用都会创建一个归所属 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) 提供。
-----
<a id="browser-authentication-and-request-trust"></a>
## 浏览器认证与请求信任
每个 Host RPC 方法和 WebSocket 流都要求一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的应用 URL,保留调用方的 authority 与挂载;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `./`,移除令牌并保留请求目录。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。
通过 Web 载体访问时,每个 Host RPC 方法和 WebSocket 流都要求经过认证的浏览器或 API Peer,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的应用 URL,保留调用方的 authority 与挂载;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `./`,移除令牌并保留请求目录。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体只在根路径交换与 `/connect` 接受 query token,不接受 Authorization header token。
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`;收到该 index 请求的监听器提供 TLS 时才带上 `Secure`,且只由该监听器自身的协议决定,`publicUrl` 声明与转发头都不参与判断。
@@ -44,14 +46,16 @@ HTTPS cookie 名称与签名 audience 包含 `https:` scheme,因此即使 host
认证之前,每个请求都经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback、等于监听器的绑定 IP 字面量(端口不限),或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口。Host 与配置条目按监听器的 scheme 做 WHATWG 默认端口归一化,不采信转发 header 或 `Origin`。附带的 HTTP(S) `Origin` 必须指向同一 authority;此时按 Origin 的 scheme 解析 Host,以支持 HTTPS 代理转发到 HTTP 上游;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。`allowsRemoteAuthorities` 报告 `trustedHosts` 是否允许非 loopback authority;接受绑定 IP 不会改变该列表。这些检查防御 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)。
每个请求都代表一个 Peer:一个 `PeerScope`,其 `principal` 指明调用方,其 `signal` 在 scope 结束时中止。`ctx.connection.browserPeer` 是所有通过认证的浏览器连接共享的 Peer;其 principal 为 `{ kind: 'owner' }`,scope 随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或 `browserPeer` 作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`ctx.connection.createPeer({ principal })` 创建一个独立的进程内 Peer,随读取 `ctx.connection` 的 Context 释放,或更早经其自身的 `dispose()` 释放;任何路由、header、query 或 cookie 都无法创建它或设置其 principal。导出的 `Peer` 类实现这两者,没有 Connection 的组合直接以 `new Peer(owner, { principal })` 构造。`fetch(request, peer)` 与 HTTP bridge 显式接收 Peer;对 principal 不是 owner 的 Peer,exact route 返回 403。其他 principal 的方法级访问归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md)。
每个请求都代表一个 Peer:`principal` 指明调用方,`interactions` 决定能否参与转发事件,`signal` 在 scope 结束时中止。`ctx.connection.browserPeer` 由通过认证的浏览器连接共享,principal 为 owner,默认 `interactions: 'answer'`。`ctx.connection.admit(request)` 检查信任与浏览器或 API 认证,返回凭据对应的存活 Peer 或拒绝状态;`/api` 路由和 Gateway WebSocket 升级都使用该结果,处理器接收本次调用的 Peer。`ctx.connection.createPeer({ principal, interactions? })` 创建独立进程内 Peer,由读取该服务的 Context 或其自身的 `dispose()` 释放;省略 interactions 时默认 `answer`。导出的 `Peer` 类接受相同参数。`fetch(request, peer)` 和 HTTP bridge 必须显式接收 Peer;对非 owner Peer,exact route 返回 403。其他 principal 的方法级访问归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md)。
仅供 API 使用的 `GET /connect?token=...&interactions=answer|none` 立即创建 principal 为 owner 的 Peer,返回 302、Mux 目标地址与签名 API Cookie。API 连接默认 `none`,非法或重复声明返回 400。HTTP 可先于 WebSocket 建立。一条 API socket 负责清理:重复关联返回 409,关闭时移除其 Peer,之后使用该 Cookie 的 HTTP 被拒绝。已受理 unary 调用保留请求级取消,但 Peer 释放后 `invocation.peer.ctx` 不再接受新的 effect 注册。API 调用方将 Cookie 与浏览器存储分开;两者使用相同的 authority 专属 Cookie 名称。
通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。客户端断开会中止处理函数的信号;桥接器停止写入 socket,并排空剩余响应块。WebSocket 流仍由 API Gateway 负责。
<a id="connection-generation"></a>
## Connection generation
API Gateway Client 把内部 `$events` 逻辑流注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事件。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。
未显式指定时,`installConnection` 将页面的 `interactions` 解析为 `answer`,非页面调用方解析为 `none`。只有回答型 Client 把内部 `$events` 逻辑流注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事件。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。
`$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`。
@@ -75,6 +79,7 @@ API Gateway Client 把内部 `$events` 逻辑流注册为唯一 generation sourc
- **缓冲型 `/api` 路由会把每个请求体保留在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)限制普通图片与 RPC 信封。显式启用的流式路由接收带背压的分块并绕过总量上限;路由实现负责持久化、取消与存储配额。
- **明文 HTTP 监听器会在传输中暴露 bearer cookie**:此类部署无法给浏览器 cookie 加 `Secure`;若把同一 authority 暴露到网络上,会话 cookie 可能泄露;在 Web 载体([`dsh-host-webserver`](../../host/webserver/README.zh.md))上配置 TLS 即可让 cookie 带上 `Secure` 并只经 TLS 发送。
- **未使用的 API Peer 可以保留到 Host 退出**:调用方从未建立 WebSocket 时,其轻量记录不受建联期限或空闲回收约束。Cookie 过期仍拒绝新请求。其生命周期 socket 关闭或 Host 重启后,API 调用方必须通过 `/connect` 重新接入。
- **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录并重启 `dsh` 会撤销全部会话。
+5 -2
View File
@@ -20,7 +20,9 @@
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
"node": "./lib/client.node.js",
"browser": "./lib/client.browser.js",
"default": "./lib/client.browser.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json",
@@ -42,7 +44,8 @@
"files": [
"locale/*.json",
"lib/index.js",
"lib/client.js",
"lib/client.browser.js",
"lib/client.node.js",
"lib/types/**/*.d.ts"
],
"peerDependencies": {
+85 -15
View File
@@ -1,6 +1,8 @@
/** Browser-session authentication for the Host Connection carrier. */
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import type { PeerId } from '@deepseek-ai/dsh-typert-protocol'
import { CONNECT_PATH, type ConnectionInteractions } from './connect-protocol.ts'
import { credentialKey } from '@deepseek-ai/dsh-credentials'
import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials'
import type {
@@ -31,6 +33,13 @@ interface BrowserCookiePayload {
readonly expiresAt: number
}
interface ApiCookiePayload extends Omit<BrowserCookiePayload, 'version'> {
readonly version: 2
readonly id: PeerId
}
type CookiePayload = BrowserCookiePayload | ApiCookiePayload
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -134,15 +143,15 @@ function signature(secret: Buffer, body: string): Buffer {
return createHmac('sha256', secret).update(body).digest()
}
function encodeCookie(payload: BrowserCookiePayload, secret: Buffer): string {
function encodeCookie(payload: CookiePayload, secret: Buffer): string {
const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), 'utf8'))
return `v1.${body}.${encodeBase64Url(signature(secret, body))}`
return `v${payload.version}.${body}.${encodeBase64Url(signature(secret, body))}`
}
function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | undefined {
function decodeCookie(value: string, secret: Buffer): CookiePayload | undefined {
const parts = value.split('.')
const [version, body, encodedSignature] = parts
if (parts.length !== 3 || version !== 'v1' || body === undefined || encodedSignature === undefined) {
if (parts.length !== 3 || (version !== 'v1' && version !== 'v2') || body === undefined || encodedSignature === undefined) {
return undefined
}
const actualSignature = decodeBase64Url(encodedSignature)
@@ -159,11 +168,15 @@ function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | und
return undefined
}
if (!isRecord(decoded)
|| decoded.version !== COOKIE_PAYLOAD_VERSION
|| (decoded.version !== COOKIE_PAYLOAD_VERSION && decoded.version !== 2)
|| version !== `v${decoded.version}`
|| typeof decoded.authority !== 'string'
|| !Number.isSafeInteger(decoded.issuedAt)
|| !Number.isSafeInteger(decoded.expiresAt)) return undefined
return decoded as unknown as BrowserCookiePayload
|| typeof decoded.issuedAt !== 'number' || !Number.isSafeInteger(decoded.issuedAt)
|| typeof decoded.expiresAt !== 'number' || !Number.isSafeInteger(decoded.expiresAt)) return undefined
const payload = { authority: decoded.authority, issuedAt: decoded.issuedAt, expiresAt: decoded.expiresAt }
if (decoded.version === COOKIE_PAYLOAD_VERSION) return { version: COOKIE_PAYLOAD_VERSION, ...payload }
if (typeof decoded.id !== 'string' || decoded.id.length === 0) return undefined
return { version: 2, ...payload, id: decoded.id as PeerId }
}
async function initializeSecret(credentials: CredentialProvider): Promise<Buffer> {
@@ -291,6 +304,49 @@ export class BrowserAuth {
return false
}
/**
* Exchange the launch token for an independent API operator; omitted interactions default to none.
* @param req - trusted GET request on /connect with at most one interactions declaration.
* @param res - response owned to completion, redirecting to the API mux on success.
* @param secure - whether the receiving listener serves TLS.
* @param publish - synchronously create the operator before exposing its signed credential.
*/
authorizeConnect(
req: ConnectionIndexRequest,
res: ConnectionIndexResponse,
secure: boolean,
publish: (interactions: ConnectionInteractions) => PeerId,
): void {
const url = new URL(req.url ?? '/', 'http://dsh.invalid')
const declarations = url.searchParams.getAll('interactions')
const interactions = declarations[0] ?? 'none'
if (declarations.length > 1 || (interactions !== 'answer' && interactions !== 'none')) {
res.writeHead(400, { 'cache-control': 'no-store', 'content-type': 'text/plain; charset=utf-8' })
res.end('interactions must be declared once as answer or none\n')
return
}
const tokens = url.searchParams.getAll(TOKEN_QUERY)
const authority = requestAudience(req.headers, secure)
if (req.method !== 'GET' || url.pathname !== CONNECT_PATH || tokens.length !== 1
|| authority === undefined || !tokenMatches(tokens.join(''), this.launchToken)) {
this.writeUnauthorized(req, res)
return
}
const issuedAt = Date.now()
const expiresAt = issuedAt + this.maxAgeMilliseconds
const id = publish(interactions)
const value = encodeCookie({ version: 2, authority, id, issuedAt, expiresAt }, this.secret)
res.writeHead(302, {
'cache-control': 'no-store',
'location': './api/remote.mux',
'referrer-policy': 'no-referrer',
'set-cookie': sessionCookie(
cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1000), secure,
),
})
res.end()
}
/**
* Verify the authority-bound browser cookie on a Host request.
* @param request - request headers carrying Host and Cookie.
@@ -298,18 +354,32 @@ export class BrowserAuth {
* @returns true only for an unexpired cookie signed by this activation's loaded secret.
*/
isAuthenticated(request: ConnectionTrustRequest, secure = false): boolean {
return this.authenticate(request, secure)?.version === COOKIE_PAYLOAD_VERSION
}
/**
* Verify an API credential without creating or reviving its operator.
* @param request - request headers carrying Host and Cookie.
* @param secure - whether the receiving listener uses TLS.
* @returns the signed operator id, or undefined for invalid, expired, or browser cookies.
*/
authenticateApi(request: ConnectionTrustRequest, secure = false): PeerId | undefined {
const payload = this.authenticate(request, secure)
return payload?.version === 2 ? payload.id : undefined
}
private authenticate(request: ConnectionTrustRequest, secure: boolean): CookiePayload | undefined {
const authority = requestAudience(request.headers, secure)
const rawCookie = header(request.headers, 'cookie')
if (authority === undefined || rawCookie === undefined) return false
if (authority === undefined || rawCookie === undefined) return undefined
const value = cookieValue(rawCookie, cookieName(authority))
if (value === undefined) return false
if (value === undefined) return undefined
const payload = decodeCookie(value, this.secret)
if (payload === undefined || payload.authority !== authority) return false
if (payload === undefined || payload.authority !== authority) return undefined
const now = Date.now()
return payload.issuedAt <= now
&& payload.expiresAt > now
&& payload.expiresAt > payload.issuedAt
&& payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds
if (payload.issuedAt > now || payload.expiresAt <= now || payload.expiresAt <= payload.issuedAt
|| payload.expiresAt - payload.issuedAt > this.maxAgeMilliseconds) return undefined
return payload
}
private writeUnauthorized(req: ConnectionIndexRequest, res: ConnectionIndexResponse): void {
+22 -2
View File
@@ -12,6 +12,11 @@ import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc
import { isLoopbackHostname } from '../loopback-hostname.ts'
import type { ClientConnectionRpc } from '../rpc.ts'
import { resolveConnectionConfig } from '../recovery-config.ts'
import type { ConnectionInteractions } from '../connect-protocol.ts'
import { openConnectionWebSocket, type ConnectionWebSocket, type ConnectionWebSocketFactory } from './socket.ts'
export type { ConnectionInteractions } from '../connect-protocol.ts'
export type { ConnectionWebSocket, ConnectionWebSocketFactory } from './socket.ts'
declare module '@deepseek-ai/cordis' {
interface Events {
@@ -102,7 +107,9 @@ export interface ClientTransportHooks {
* transport can set this; served pages never carry the global at all.
*/
ownsHost?: boolean
/** HTTP origin of a shell-owned Host when its WebSocket uses a different page origin. */
/** Authenticated WebSocket factory; omission uses the browser API and its cookie jar. */
createWebSocket?: ConnectionWebSocketFactory
/** Host base URL for desktop or Node sockets; omission uses the document base. */
streamBaseUrl?: string
}
@@ -119,6 +126,8 @@ export interface ConnectionLocation {
/** Instance-local inputs for installing a Connection service. */
export interface ConnectionInstallOptions {
/** Forwarded-event participation; omission selects answer for a page and none otherwise. */
readonly interactions?: ConnectionInteractions
/** Explicit physical carrier; omit for the browser HTTP + WebSocket carrier. */
readonly transport?: ClientTransportHooks
/** Reconnect timing overrides; omitted fields use controller defaults. */
@@ -132,6 +141,8 @@ export interface ConnectionInstallOptions {
* and reset callbacks; Connection stays independent of downstream domain state.
*/
export interface ConnectionHandle {
/** Forwarded-event participation selected at installation. */
readonly interactions: ConnectionInteractions
/**
* Whether the privileged surface is reachable: the page authority is
* loopback, the transport declares the page owns the Host
@@ -144,6 +155,12 @@ export interface ConnectionHandle {
readonly state: ConnectionStateSource
/** Generic logical RPC channels over the same Connection transport. */
readonly rpc: ClientConnectionRpc
/**
* Create a socket on the selected carrier; the channel owns framing and closing.
* @param path - absolute logical channel path, resolved beneath the Host mount.
* @returns a newly connecting socket using the installed authentication factory.
*/
openWebSocket(path: string): ConnectionWebSocket
/** Reset retry progression and replace the current attempt immediately. */
reconnect(): void
/**
@@ -200,12 +217,13 @@ function watchBrowserNetwork(controller: ConnectionController): () => void {
/**
* Install one Context-owned Connection service from explicit composition inputs.
* @param ctx - client Cordis context.
* @param options - physical carrier, reconnect timing, and page location.
* @param options - interaction participation, physical carrier, reconnect timing, and page location.
*/
export function installConnection(ctx: Context, options: ConnectionInstallOptions = {}): void {
const pageLocation = options.location
const transport = options.transport
const recovery = options.recovery ?? {}
const interactions = options.interactions ?? (pageLocation === undefined ? 'none' : 'answer')
const rpc = transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
let generationSource: ConnectionGenerationSource | undefined
let owner: ConnectionOwner | undefined
@@ -245,6 +263,8 @@ export function installConnection(ctx: Context, options: ConnectionInstallOption
publishState(undefined)
}
const handle: ConnectionHandle = {
interactions,
openWebSocket: path => openConnectionWebSocket(path, transport?.createWebSocket, transport?.streamBaseUrl),
isLoopback: transport?.ownsHost === true || pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
generation: {
getSnapshot: () => generation,
@@ -0,0 +1,66 @@
/** Physical socket selection for browser, desktop, and authenticated Node connections. */
/** Socket events consumed by the Gateway on browser and Node carriers. */
export interface ConnectionWebSocketEventMap {
readonly open: object
readonly error: object
readonly close: object
readonly message: { readonly data: unknown }
}
/** Text WebSocket operations shared by the browser API and Node carriers. */
export interface ConnectionWebSocket {
/** Standard WebSocket state, from CONNECTING (0) through CLOSED (3). */
readonly readyState: number
/** @param data - complete text frame to send. */
send(data: string): void
/**
* @param code - protocol close code.
* @param reason - peer-facing close explanation.
*/
close(code?: number, reason?: string): void
/**
* @param event - physical socket event.
* @param listener - callback for that event.
* @param options - whether the callback is removed after its first delivery.
*/
addEventListener<Event extends keyof ConnectionWebSocketEventMap>(
event: Event,
listener: (event: ConnectionWebSocketEventMap[Event]) => void,
options?: { readonly once?: boolean },
): void
/**
* @param event - physical socket event.
* @param listener - previously registered callback.
*/
removeEventListener<Event extends keyof ConnectionWebSocketEventMap>(
event: Event,
listener: (event: ConnectionWebSocketEventMap[Event]) => void,
): void
}
/**
* Create an authenticated socket; its consumer owns framing and closing.
* @param url - absolute WebSocket URL beneath the selected Host mount.
* @returns a newly connecting socket, before it emits open or failure.
*/
export type ConnectionWebSocketFactory = (url: string) => ConnectionWebSocket
/**
* Resolve a logical channel against the explicit Host or the page's document base.
* @param path - absolute logical channel path.
* @param factory - authenticated socket factory; omission uses the browser API.
* @param baseUrl - Host base URL, required outside a document.
* @returns a socket owned by the calling channel.
*/
export function openConnectionWebSocket(
path: string,
factory: ConnectionWebSocketFactory = url => new WebSocket(url),
baseUrl?: string,
): ConnectionWebSocket {
const base = baseUrl ?? (typeof document === 'undefined' ? undefined : document.baseURI)
if (base === undefined) throw new Error('connection: WebSocket carrier requires streamBaseUrl outside a document')
const url = new URL(path.slice(1), base)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return factory(url.href)
}
@@ -0,0 +1,7 @@
/** API connection inputs shared by the Host and its clients. */
/** Whether a Peer participates in forwarded human interactions. */
export type ConnectionInteractions = 'answer' | 'none'
/** Launch-token exchange route for API clients. */
export const CONNECT_PATH = '/connect'
@@ -4,8 +4,8 @@
*/
import type { IncomingMessage } from 'node:http'
import { Readable } from 'node:stream'
import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol'
import { Readable } from 'node:stream'
import type { ConnectionFetchHandler } from './rpc.ts'
/** Default carrier cap for all HTTP RPC bodies: sized for the default
+8 -1
View File
@@ -7,6 +7,7 @@ import type {} from '@deepseek-ai/dsh-credentials'
// Activates the webServer Context merge used below.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH } from './api-path.ts'
import { CONNECT_PATH } from './connect-protocol.ts'
import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts'
import { assertTrustedAuthority } from './api-request-trust.ts'
import { BrowserAuth } from './browser-auth.ts'
@@ -15,6 +16,7 @@ import { ConnectionRecoveryConfigSchema, resolveConnectionConfig, type Connectio
export type {
PeerAdmission,
ConnectionAdmission,
ConnectionFetchMethod,
ConnectionFetchHandler,
ConnectionFetchRoute,
@@ -40,6 +42,7 @@ export type { PeerId, PeerScope, Principal, RemoteInvocation } from '@deepseek-a
export { RpcId, transportError } from './rpc.ts'
export { Peer } from './peer.ts'
export type { PeerOptions } from './peer.ts'
export { CONNECT_PATH, type ConnectionInteractions } from './connect-protocol.ts'
export {
clientRequestSchema,
rpcErrorSchema,
@@ -100,7 +103,7 @@ export interface ConnectionConfig {
* an entry here. A non-canonical or non-bare authority fails plugin load.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
/** Absolute browser and API cookie lifetime in days. Default: 30. */
cookieMaxAgeDays?: number
/** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */
maxRequestBodyBytes?: number
@@ -155,6 +158,10 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise<vo
},
}
webCtx.effect(() => webCtx.webServer.register(route), 'client-connection: /api route')
webCtx.effect(() => webCtx.webServer.register({
kind: 'exact', path: CONNECT_PATH,
handler: (req, res) => { connection.authorizeConnect(req, res) },
}), 'client-connection: /connect route')
})
ctx.inject(['attachments'], (attachmentCtx) => {
assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes)
+4
View File
@@ -14,6 +14,8 @@ import type { PeerId, PeerScope, Principal } from '@deepseek-ai/dsh-typert-proto
export interface PeerOptions {
/** Caller identity every call through this Peer speaks for. */
readonly principal: Principal
/** Omission permits an event stream; non-owner Peers still cannot answer waterfalls. */
readonly interactions?: 'answer' | 'none'
}
/**
@@ -25,6 +27,7 @@ export class Peer implements PeerScope {
readonly id: PeerId = randomUUID() as PeerId
readonly ctx: Context
readonly principal: Principal
readonly interactions: 'answer' | 'none'
readonly signal: AbortSignal
private readonly scope: Scope
@@ -34,6 +37,7 @@ export class Peer implements PeerScope {
*/
constructor(owner: Context, options: PeerOptions) {
this.principal = options.principal
this.interactions = options.interactions ?? 'answer'
this.scope = createScope(owner, this)
this.ctx = this.scope.ctx
const lifetime = new AbortController()
+76 -10
View File
@@ -2,7 +2,8 @@
import { Context, Service } from '@deepseek-ai/cordis'
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import type { PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol'
import type { PeerId, PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol'
import type { ConnectionInteractions } from './connect-protocol.ts'
import {
RpcId,
type ClientRequest,
@@ -16,6 +17,7 @@ import type { BrowserAuth } from './browser-auth.ts'
import { Peer } from './peer.ts'
import type {
PeerAdmission,
ConnectionAdmission,
ConnectionIndexRequest,
ConnectionIndexResponse,
ConnectionFetchRoute,
@@ -35,6 +37,11 @@ const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
interface PeerRecord {
readonly peer: Peer
ownsSocket: boolean
}
interface ConnectionRpcInterceptor {
readonly matches: ConnectionRpcEndpointMatcher
readonly fetchHandler: ConnectionFetchHandler
@@ -63,6 +70,9 @@ declare module '@deepseek-ai/cordis' {
export class HostConnectionService extends Service implements HostConnectionHandle {
/** The Peer every authenticated browser connection shares. */
readonly browserPeer: PeerScope
private readonly peers = new Map<PeerId, PeerRecord>()
private readonly owner: Context
private disposed = false
private readonly interceptors = new Map<string, ConnectionRpcInterceptor>()
private readonly fetchRoutes = new Map<string, RegisteredFetchRoute>()
@@ -80,8 +90,14 @@ export class HostConnectionService extends Service implements HostConnectionHand
private readonly browserAuth: BrowserAuth,
) {
super(ctx, 'connection')
this.owner = ctx
this.browserPeer = new Peer(ctx, { principal: { kind: 'owner' } })
ctx.effect(() => () => this.browserPeer.dispose(), 'client-connection: browser Peer')
ctx.effect(() => async () => {
this.disposed = true
const peers = [this.browserPeer, ...Array.from(this.peers.values(), record => record.peer)]
this.peers.clear()
await Promise.all(peers.map(peer => peer.dispose()))
}, 'client-connection: Peer scopes')
this.allowsRemoteAuthorities = trustedHosts.some(isRemoteAuthority)
}
@@ -103,24 +119,74 @@ export class HostConnectionService extends Service implements HostConnectionHand
}
}
/** Apply the Host/Origin fence, then browser authentication. */
/** Apply the Host/Origin fence and require an authenticated live Peer. */
requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection {
const carrier = this.ctx.get('webServer')
if (!isTrustedApiRequest(request, this.trustedHosts, carrier?.host, carrier?.protocol ?? 'http:')) return 403
return this.browserAuth.isAuthenticated(request, carrier?.protocol === 'https:') ? undefined : 401
const admission = this.admit(request)
return 'rejection' in admission ? admission.rejection : undefined
}
/** A request that passes the fence and authentication speaks for the shared browser Peer. */
/** Browser requests share an owner Peer; API HTTP does not depend on an attached socket. */
admit(request: ConnectionTrustRequest): PeerAdmission {
const rejection = this.requestRejection(request)
return rejection === undefined ? { peer: this.browserPeer } : { rejection }
if (!this.isTrusted(request)) return { rejection: 403 }
if (this.disposed) return { rejection: 401 }
const secure = this.owner.get('webServer')?.protocol === 'https:'
if (this.browserAuth.isAuthenticated(request, secure)) return { peer: this.browserPeer }
const id = this.browserAuth.authenticateApi(request, secure)
const record = id === undefined ? undefined : this.peers.get(id)
return record === undefined ? { rejection: 401 } : { peer: record.peer }
}
/** Create one in-process Peer owned by the Context that read this service. */
createPeer(options: { readonly principal: Principal }): PeerScope {
createPeer(options: { readonly principal: Principal; readonly interactions?: ConnectionInteractions }): PeerScope {
return new Peer(this.ctx, options)
}
/** Claim API socket ownership synchronously; callers release on termination or failed upgrade. */
admitConnection(request: ConnectionTrustRequest): ConnectionAdmission {
const admission = this.admit(request)
if ('rejection' in admission) return admission
const record = this.peers.get(admission.peer.id)
if (record === undefined) return { ...admission, release: () => Promise.resolve() }
if (record.ownsSocket) return { rejection: 409 }
record.ownsSocket = true
let released: Promise<void> | undefined
return {
peer: record.peer,
release: () => {
if (released !== undefined) return released
this.peers.delete(record.peer.id)
released = record.peer.dispose()
return released
},
}
}
private isTrusted(request: ConnectionTrustRequest): boolean {
const carrier = this.owner.get('webServer')
return isTrustedApiRequest(request, this.trustedHosts, carrier?.host, carrier?.protocol ?? 'http:')
}
private publishPeer(interactions: ConnectionInteractions): PeerId {
const peer = new Peer(this.owner, { principal: { kind: 'owner' }, interactions })
this.peers.set(peer.id, { peer, ownsSocket: false })
return peer.id
}
/**
* Authenticate and publish a credential before sending the connect redirect.
* @param request - launch-token request on /connect.
* @param response - response owned to completion.
*/
authorizeConnect(request: ConnectionIndexRequest, response: ConnectionIndexResponse): void {
if (!this.isTrusted(request) || this.disposed) {
response.writeHead(this.disposed ? 401 : 403)
response.end(this.disposed ? 'unauthorized' : 'forbidden')
return
}
this.browserAuth.authorizeConnect(request, response,
this.owner.get('webServer')?.protocol === 'https:', interactions => this.publishPeer(interactions))
}
/**
* Authenticate an index request; native TLS listeners mint Secure cookies.
*/
+24 -13
View File
@@ -120,6 +120,15 @@ export type PeerAdmission =
| { readonly peer: PeerScope }
| { readonly rejection: 401 | 403 }
/** Socket admission claims API ownership until its carrier calls release, including failed upgrades. */
export type ConnectionAdmission =
| {
readonly peer: PeerScope
/** Remove and dispose an API Peer; browser releases leave the shared browser Peer intact. */
readonly release: () => Promise<void>
}
| { readonly rejection: 401 | 403 | 409 }
/**
* Handler invoked after Connection has decoded the transport envelope.
* `peer` is the Peer the carrier admitted or the in-process caller supplied.
@@ -199,10 +208,7 @@ export interface HostConnectionHandle {
readonly rpc: HostConnectionRpc
/** Exact Fetch routes for streaming or browser-native responses. */
readonly fetch: HostConnectionFetch
/**
* The Peer every authenticated browser connection shares; its principal is
* the owner and its scope lives as long as Connection.
*/
/** The owner Peer every authenticated browser connection shares. */
readonly browserPeer: PeerScope
/**
@@ -227,22 +233,27 @@ export interface HostConnectionHandle {
requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection
/**
* Admit one network request: it passes {@link requestRejection} and speaks
* for {@link browserPeer}, or it is refused with that status.
* Admit an HTTP request against its signed browser or API credential and live Peer.
* @param request - request headers from the HTTP or upgrade request.
* @returns the admitted Peer, or the rejection status.
* @returns the credential's Peer, or the rejection status.
*/
admit(request: ConnectionTrustRequest): PeerAdmission
/**
* Create an independent Peer. In-process only: no route, header, query, or
* cookie reaches it. Disposed with the Context that read `ctx.connection`,
* or earlier by its own `dispose()`.
* @param options - caller identity the Peer speaks for.
* @param options.principal - principal fixed for the Peer's lifetime.
* Create an independent in-process Peer, disposed with the Context that read `ctx.connection`.
* @param options - fixed principal and optional interaction participation (default `answer`).
* @returns a new Peer distinct from every other Peer.
*/
createPeer(options: { readonly principal: Principal }): PeerScope
createPeer(options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' }): PeerScope
/**
* Claim a physical socket for an API Peer; browser sockets share their owner Peer.
* @param request - authenticated upgrade request headers.
* @returns the Peer and idempotent release, or 409 for duplicate API ownership.
* The carrier releases on socket termination or failed upgrade; release removes
* API admission synchronously without cancelling independent HTTP calls.
*/
admitConnection(request: ConnectionTrustRequest): ConnectionAdmission
/**
* Authenticate one frontend index request, owning a token redirect or 401.
@@ -3,6 +3,7 @@
import { createHmac } from 'node:crypto'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'
import type { PeerId } from '@deepseek-ai/dsh-typert-protocol'
import { BrowserAuth } from '../src/browser-auth.ts'
import type { ConnectionIndexRequest, ConnectionIndexResponse } from '../src/rpc.ts'
import { RecordCredentials } from './browser-credentials.ts'
@@ -91,6 +92,73 @@ afterEach(() => {
vi.useRealTimers()
})
describe('API credentials', () => {
it('refuses a request without a URL before publishing an API operator', async () => {
const auth = await createAuth(new RecordCredentials())
const publish = vi.fn(() => 'api-operator' as PeerId)
const res = response()
auth.authorizeConnect({ method: 'GET', headers: { host: 'localhost' } }, res.value, false, publish)
expect(res.state).toEqual({
status: 401,
headers: { 'cache-control': 'no-store', 'content-type': 'text/plain; charset=utf-8' },
body: 'dsh web authentication required; reopen the URL printed by dsh web.\n',
})
expect(publish).not.toHaveBeenCalled()
})
it('signs the published id and rejects browser confusion, tampering, audience mismatch, and expiry', async () => {
vi.useFakeTimers()
const store = new RecordCredentials()
const auth = await createAuth(store)
const authority = '127.0.0.1:3080'
const id = 'api-operator' as PeerId
const issuedAt = Date.now()
const publish = vi.fn(() => id)
const res = response()
auth.authorizeConnect(request(auth.authenticatedUrl(`http://${authority}/connect`)), {
...res.value,
writeHead(status, headers) {
expect(publish).toHaveBeenCalledExactlyOnceWith('none')
return res.value.writeHead(status, headers)
},
}, false, publish)
const cookie = res.state.headers?.['set-cookie']?.split(';', 1)[0]
if (cookie === undefined) throw new Error('API connect did not set a cookie')
const [name, value] = cookie.split('=') as [string, string]
const payload = { version: 2, authority, id, issuedAt, expiresAt: issuedAt + 30 * 86_400_000 }
expect(JSON.parse(Buffer.from(value.split('.')[1]!, 'base64url').toString('utf8'))).toEqual(payload)
expect(auth.authenticateApi(request('/', authority, { cookie }))).toBe(id)
expect(auth.isAuthenticated(request('/', authority, { cookie }))).toBe(false)
expect(auth.authenticateApi(request('/', authority, { cookie: exchange(auth).cookie }))).toBeUndefined()
expect(auth.isAuthenticated(request('/', authority, { cookie: cookie.replace('=v2.', '=v1.') }))).toBe(false)
expect(auth.authenticateApi(request('/', authority, { cookie: `${cookie.slice(0, -1)}!` }))).toBeUndefined()
expect(auth.authenticateApi(request('/', 'localhost:3080', { cookie }))).toBeUndefined()
expect(auth.authenticateApi(request('/', '127.0.0.1:3081', { cookie }))).toBeUndefined()
expect(auth.authenticateApi(request('/', authority, { cookie }), true)).toBeUndefined()
for (const invalid of [
{ ...payload, version: 3 },
{ ...payload, authority: 'localhost:3080' },
{ ...payload, id: '' },
{ ...payload, id: 42 },
{ ...payload, id: undefined },
{ ...payload, issuedAt: issuedAt + 1 },
{ ...payload, issuedAt: 0.5 },
{ ...payload, expiresAt: 'later' },
{ ...payload, expiresAt: issuedAt - 1 },
]) {
const invalidCookie = signedCookie(store, name, invalid).replace('=v1.', '=v2.')
expect(auth.authenticateApi(request('/', authority, { cookie: invalidCookie }))).toBeUndefined()
expect(auth.isAuthenticated(request('/', authority, { cookie: invalidCookie }))).toBe(false)
}
const shorter = await createAuth(store, 1)
expect(shorter.authenticateApi(request('/', authority, { cookie }))).toBeUndefined()
vi.setSystemTime(payload.expiresAt)
expect(auth.authenticateApi(request('/', authority, { cookie }))).toBeUndefined()
})
})
describe('BrowserAuth', () => {
it('mints one process token and a persistent authority-bound cookie', async () => {
const store = new RecordCredentials()
@@ -3,10 +3,11 @@ import { EventEmitter } from 'node:events'
import { createServer, request as httpRequest } from 'node:http'
import { Readable } from 'node:stream'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { AddressInfo } from 'node:net'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'
import type { IndexInjection, WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
import {
API_PATH,
@@ -94,7 +95,11 @@ function fakeResponse(): {
return { response, state }
}
async function mounted(config?: ConnectionConfig, protocol: WebServer['protocol'] = 'http:'): Promise<{
async function mounted(
config?: ConnectionConfig,
protocol: WebServer['protocol'] = 'http:',
credentials?: CredentialProvider,
): Promise<{
ctx: Context
routes: WebRoute[]
upgrades: WebUpgradeRoute[]
@@ -104,7 +109,8 @@ async function mounted(config?: ConnectionConfig, protocol: WebServer['protocol'
const ctx = new Context()
const routes: WebRoute[] = []
const upgrades: WebUpgradeRoute[] = []
provideBrowserCredentials(ctx)
if (credentials === undefined) provideBrowserCredentials(ctx)
else ctx.provide('credentials', credentials)
ctx.provide('webServer', fakeHttpServer(routes, upgrades, '127.0.0.1', protocol) as WebServer)
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
await fiber.await()
@@ -193,9 +199,14 @@ describe('connection node half', () => {
const ctx = new Context()
provideBrowserCredentials(ctx)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(ctx.get('connection')).toBeInstanceOf(Object)
await fiber.dispose()
try {
await fiber.await()
const connection = ctx.get('connection') as HostConnectionHandle
const cookie = browserCookie(connection, 'localhost')
expect(connection.admit(fakeRequest({ host: 'localhost', cookie }))).toEqual({ peer: connection.browserPeer })
} finally {
await fiber.dispose()
}
})
it('accepts the bind address of a web carrier attached after Connection', async () => {
@@ -331,9 +342,9 @@ describe('connection node half', () => {
expect(upgrades).toHaveLength(0)
})
it('registers only the HTTP route and removes it with the fiber', async () => {
it('registers the API and connect HTTP routes and removes them with the fiber', async () => {
const { routes, upgrades, dispose } = await mounted()
expect(routes).toHaveLength(1)
expect(routes).toHaveLength(2)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
expect(upgrades).toHaveLength(0)
await dispose()
@@ -429,7 +440,7 @@ describe('connection node half', () => {
ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(routes).toHaveLength(1)
expect(routes).toHaveLength(2)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
const connection = ctx.get('connection') as HostConnectionHandle
@@ -466,7 +477,7 @@ describe('connection node half', () => {
expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null })))
.toThrow(/duplicate route/)
await remove()
expect(routes.map(candidate => candidate.path)).toEqual([API_PATH])
expect(routes.map(candidate => candidate.path)).toEqual([API_PATH, '/connect'])
await fiber.dispose()
expect(routes).toHaveLength(0)
})
@@ -673,6 +684,205 @@ describe('connection node half', () => {
})
})
describe('operator-scoped connect', () => {
async function connect(
host: Awaited<ReturnType<typeof mounted>>,
query = '',
headers: Record<string, string> = {},
): Promise<{ cookie: string; state: ReturnType<typeof fakeResponse>['state'] }> {
const token = new URL(host.connection.authenticatedUrl('http://localhost')).search
const response = fakeResponse()
await host.routes.find(route => route.path === '/connect')!.handler(
fakeRequest({ host: 'localhost', ...headers }, `/connect${token}${query}`), response.response,
)
return { cookie: response.state.headers?.['set-cookie']?.split(';', 1)[0] ?? '', state: response.state }
}
function admitted(connection: HostConnectionHandle, cookie: string): PeerScope {
const admission = connection.admit(fakeRequest({ host: 'localhost', cookie }))
if ('rejection' in admission) throw new Error(`operator refused: ${admission.rejection}`)
return admission.peer
}
it('defaults API callers to none regardless of Accept and honors explicit declarations', async () => {
const host = await mounted()
try {
for (const [query, headers, interactions, status, location] of [
['', {}, 'none', 302, './api/remote.mux'],
['&interactions=none', {}, 'none', 302, './api/remote.mux'],
['&interactions=answer', {}, 'answer', 302, './api/remote.mux'],
['', { accept: 'text/html,application/xhtml+xml' }, 'none', 302, './api/remote.mux'],
['&interactions=answer', { accept: 'text/html' }, 'answer', 302, './api/remote.mux'],
] as const) {
const result = await connect(host, query, headers)
expect(result.state).toMatchObject({ status, headers: { location, 'cache-control': 'no-store' } })
expect(admitted(host.connection, result.cookie).interactions).toBe(interactions)
}
const rootCookie = browserCookie(host.connection, 'localhost')
expect(admitted(host.connection, rootCookie).interactions).toBe('answer')
} finally { await host.dispose() }
})
it('keeps all browser cookies on the shared operator and creates independent API operators', async () => {
const host = await mounted()
try {
const cookie = browserCookie(host.connection, 'localhost')
expect(admitted(host.connection, cookie)).toBe(host.connection.browserPeer)
expect(admitted(host.connection, browserCookie(host.connection, 'localhost'))).toBe(host.connection.browserPeer)
const api = await connect(host, '', { cookie })
expect(admitted(host.connection, api.cookie)).not.toBe(host.connection.browserPeer)
const second = await connect(host, '', { cookie: api.cookie })
expect(admitted(host.connection, second.cookie)).not.toBe(admitted(host.connection, api.cookie))
const denied = fakeResponse()
expect(host.connection.authorizeIndex(fakeRequest(
{ host: 'localhost', cookie: api.cookie }, '/',
), denied.response)).toBe(false)
expect(denied.state.status).toBe(401)
} finally { await host.dispose() }
})
it('refuses invalid declarations, authentication, and untrusted connect requests without a cookie', async () => {
const host = await mounted()
try {
for (const query of ['&interactions=observe', '&interactions=', '&interactions=none&interactions=answer']) {
const result = await connect(host, query)
expect(result.state.status).toBe(400)
expect(result.cookie).toBe('')
}
for (const headers of [{ host: 'untrusted.example' }, { origin: 'http://untrusted.example' }]) {
expect((await connect(host, '', headers)).state.status).toBe(403)
}
const route = host.routes.find(route => route.path === '/connect')!
for (const request of [
fakeRequest({ host: 'localhost' }, '/connect?token=invalid'),
fakePost({ host: 'localhost' }, '/connect', {}),
]) {
const response = fakeResponse()
await route.handler(request, response.response)
expect(response.state.status).toBe(401)
expect(response.state.headers?.['set-cookie']).toBeUndefined()
}
} finally { await host.dispose() }
})
it('refuses a captured connect request after the owning fiber is disposed', async () => {
const host = await mounted()
try {
const route = host.routes.find(candidate => candidate.path === '/connect')!
const request = fakeRequest({ host: 'localhost' }, host.connection.authenticatedUrl('http://localhost/connect'))
await host.dispose()
const response = fakeResponse()
await route.handler(request, response.response)
expect(response.state).toEqual({ status: 401, body: 'unauthorized' })
expect(response.response.writableEnded).toBe(true)
} finally { await host.dispose() }
})
it('dispatches HTTP before a socket and lets an admitted call finish after socket release', async () => {
const host = await mounted()
const finish = Promise.withResolvers<undefined>()
const started = Promise.withResolvers<AbortSignal>()
try {
const first = await connect(host)
const second = await connect(host)
const peer = admitted(host.connection, first.cookie)
expect(admitted(host.connection, second.cookie).id).not.toBe(peer.id)
host.connection.rpc.intercept('/api', () => true, async (_endpoint, _payload, signal, caller) => {
expect(caller).toBe(peer)
if (_endpoint === 'fixture/held') {
started.resolve(signal)
await finish.promise
}
return { ok: true, value: { id: caller.id } }
})
const api = host.routes.find(route => route.path === '/api')!
const post = (method: string): IncomingMessage => fakePost({ host: 'localhost', cookie: first.cookie }, `/api/${method}`, {
type: 'client-request', rpcId: 'connect-test', method, payload: {},
})
const beforeSocket = fakeResponse()
await api.handler(post('fixture/now'), beforeSocket.response)
expect(JSON.parse(String(beforeSocket.state.body))).toMatchObject({ result: { ok: true, value: { id: peer.id } } })
const pendingResponse = fakeResponse()
const pending = api.handler(post('fixture/held'), pendingResponse.response)
const signal = await started.promise
const owner = host.connection.admitConnection(fakeRequest({ host: 'localhost', cookie: first.cookie }))
if ('rejection' in owner) throw new Error('first socket was refused')
expect(owner.peer).toBe(peer)
expect(host.connection.admitConnection(fakeRequest({ host: 'localhost', cookie: first.cookie })))
.toEqual({ rejection: 409 })
expect(admitted(host.connection, first.cookie)).toBe(peer)
const cleanup = vi.fn()
peer.ctx.effect(() => cleanup)
const released = owner.release()
expect(host.connection.admit(fakeRequest({ host: 'localhost', cookie: first.cookie }))).toEqual({ rejection: 401 })
expect(owner.release()).toBe(released)
await released
expect(cleanup).toHaveBeenCalledOnce()
expect(signal.aborted).toBe(false)
finish.resolve(undefined)
await pending
expect(pendingResponse.state.status).toBe(200)
const afterSocket = fakeResponse()
await api.handler(post('fixture/now'), afterSocket.response)
expect(afterSocket.state.status).toBe(401)
expect(host.connection.admitConnection(fakeRequest({ host: 'localhost', cookie: first.cookie })))
.toEqual({ rejection: 401 })
} finally { finish.resolve(undefined); await host.dispose() }
})
it('retains unused operators for arbitrary elapsed time but rejects expired cookies', async () => {
const host = await mounted({ cookieMaxAgeDays: 36_500 })
vi.useFakeTimers()
try {
const { cookie } = await connect(host)
const peer = admitted(host.connection, cookie)
await vi.advanceTimersByTimeAsync(10 * 365 * 86_400_000)
expect(admitted(host.connection, cookie)).toBe(peer)
const disposed = vi.fn()
peer.ctx.effect(() => disposed)
await vi.advanceTimersByTimeAsync(100 * 365 * 86_400_000)
expect(host.connection.admit(fakeRequest({ host: 'localhost', cookie }))).toEqual({ rejection: 401 })
expect(disposed).not.toHaveBeenCalled()
await host.dispose()
expect(disposed).toHaveBeenCalledOnce()
} finally { vi.useRealTimers(); await host.dispose() }
})
it('preserves browser tabs and restart recovery without resurrecting API operators', async () => {
const host = await mounted()
const browser = { cookie: browserCookie(host.connection, 'localhost') }
const api = await connect(host)
const browserPeer = admitted(host.connection, browser.cookie)
try {
const request = fakeRequest({ host: 'localhost', cookie: browser.cookie })
const one = host.connection.admitConnection(request)
const two = host.connection.admitConnection(request)
if ('rejection' in one || 'rejection' in two) throw new Error('browser tab was refused')
expect(two.peer).toBe(one.peer)
await one.release()
expect(admitted(host.connection, browser.cookie)).toBe(browserPeer)
await two.release()
expect(admitted(host.connection, browser.cookie)).toBe(browserPeer)
const refresh = fakeResponse()
expect(host.connection.authorizeIndex(fakeRequest({ host: 'localhost', cookie: browser.cookie }, '/'), refresh.response)).toBe(true)
const cleanup = vi.fn()
browserPeer.ctx.effect(() => cleanup)
await host.dispose()
expect(cleanup).toHaveBeenCalledOnce()
expect(host.connection.admit(request)).toEqual({ rejection: 401 })
const restarted = await mounted(undefined, 'http:', host.ctx.credentials)
try {
expect(admitted(restarted.connection, browser.cookie)).toBe(restarted.connection.browserPeer)
expect(admitted(restarted.connection, browser.cookie)).not.toBe(browserPeer)
expect(restarted.connection.admit(fakeRequest({ host: 'localhost', cookie: api.cookie }))).toEqual({ rejection: 401 })
} finally { await restarted.dispose() }
} finally { await host.dispose() }
})
})
describe('connection node half over a real HTTP server', () => {
/** Serve the registered prefix route from a real server and return its port. */
async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
@@ -0,0 +1,55 @@
/** Physical WebSocket selection and Host mount resolution. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
openConnectionWebSocket,
type ConnectionWebSocket,
type ConnectionWebSocketFactory,
} from '../src/client/socket.ts'
function socketProbe(): ConnectionWebSocket {
return {
readyState: 0,
send: vi.fn(),
close: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}
}
afterEach(() => { vi.unstubAllGlobals() })
describe('Connection WebSocket carrier', () => {
it('requires an explicit Host base outside a document before opening a socket', () => {
const factory = vi.fn<ConnectionWebSocketFactory>()
expect(typeof document).toBe('undefined')
expect(() => openConnectionWebSocket('/api/remote.mux', factory))
.toThrow('connection: WebSocket carrier requires streamBaseUrl outside a document')
expect(factory).not.toHaveBeenCalled()
})
it.each([
['http://host.example/tools/dsh/', 'ws://host.example/tools/dsh/api/remote.mux'],
['https://host.example/tools/dsh/', 'wss://host.example/tools/dsh/api/remote.mux'],
])('resolves the channel beneath the explicit Host base %s', (base, expected) => {
const socket = socketProbe()
const factory = vi.fn<ConnectionWebSocketFactory>().mockReturnValue(socket)
expect(openConnectionWebSocket('/api/remote.mux', factory, base)).toBe(socket)
expect(factory).toHaveBeenCalledExactlyOnceWith(expected)
})
it('uses the browser WebSocket and document base when no carrier is supplied', () => {
const socket = socketProbe()
const browserSocket = vi.fn(function (_url: string) { return socket })
vi.stubGlobal('document', { baseURI: 'https://browser.example/mounted/' } satisfies Pick<Document, 'baseURI'>)
vi.stubGlobal('WebSocket', browserSocket)
try {
expect(openConnectionWebSocket('/api/remote.mux')).toBe(socket)
expect(browserSocket).toHaveBeenCalledExactlyOnceWith('wss://browser.example/mounted/api/remote.mux')
} finally {
vi.unstubAllGlobals()
}
})
})
@@ -12,6 +12,8 @@
"src/client/index.ts",
"src/client/random-uuid.ts",
"src/client/rpc.ts",
"src/client/socket.ts",
"src/connect-protocol.ts",
"src/loopback-hostname.ts",
"src/recovery-config.ts",
"src/rpc.ts"
@@ -11,6 +11,7 @@
"src/browser-auth.ts",
"src/client/random-uuid.ts",
"src/client/rpc.ts",
"src/connect-protocol.ts",
"src/http-bridge.ts",
"src/index.ts",
"src/loopback-hostname.ts",
+1 -1
View File
@@ -1,3 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle(import.meta.dirname, ['lib/types/index.js'])
export default clientBundle(import.meta.dirname, ['lib/types/index.js'], { dualClient: true })
+3 -1
View File
@@ -1090,7 +1090,9 @@ export class ClientModuleRegistry extends Service {
const requested = requestUrl.pathname.slice(prefix.length)
const sourceMap = requested.endsWith('.map')
const fileName = sourceMap ? requested.slice(0, -'.map'.length) : requested
if (!CLIENT_CHUNK.test(fileName)) return undefined
// The Node Client entry and its split chunks share this directory but
// must never be served as browser factory scripts.
if (!CLIENT_CHUNK.test(fileName) || fileName === 'client.node.js' || fileName.startsWith('client.node.')) return undefined
if (resourceUrl !== chunkUrl(record.entry.id, fileName, record.entry.rev, sourceMap)) return undefined
return { record, fileName, sourceMap, resourceUrl }
}
@@ -915,6 +915,36 @@ describe('client bundle activation', () => {
expect((await routeRequest(route, url.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
})
it('loads a renamed browser export at the logical URL without exposing Node bundles as chunks', async () => {
const packageName = '@fixture/dual-client'
const legacyPath = writePackage(packageName, {
dsh: { client: { platform: 'web' } },
exports: {
'./client': {
types: './lib/types/client/index.d.ts',
node: './lib/client.node.js',
browser: './lib/client.browser.js',
default: './lib/client.browser.js',
},
'./package.json': './package.json',
},
})
mkdirSync(dirname(legacyPath), { recursive: true })
writeFileSync(join(dirname(legacyPath), 'client.browser.js'), 'module.exports = { browser: true }\n')
for (const name of ['client.node.js', 'client.node.helper.js']) {
writeFileSync(join(dirname(legacyPath), name), 'throw new Error("Node code must not reach a browser")\n')
}
const { service, route } = constructWithRoute([packageName])
const row = service.graph().entries[0]!
expect(row.url).toContain(`${packageName}/client.js`)
expect((await routeRequest(route, row.url)).body.toString('utf8')).toContain('browser: true')
for (const name of ['client.node.js', 'client.node.helper.js']) {
for (const suffix of ['', '.map']) {
expect((await routeRequest(route, chunkReference(packageName, name + suffix, row.rev))).status).toBe(404)
}
}
})
it('publishes a new chunk revision when a completed build rewrites only the entry timestamp', async () => {
const packageName = '@fixture/chunk-only-rebuild'
const clientPath = writePackage(packageName)
+37 -10
View File
@@ -104,7 +104,7 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
* `import.meta.dirname`. Its `package.json` name is the plugin id stamped into
* the __ModuleLoader__.load handoff and onto the injected style tags.
* @param libEntry - node-half entries.
* @param options - phase placement, lib overrides, companion Node configs, and optional per-file Client banner.
* @param options - phase placement, lib overrides, optional independently bundled Node Client, companion configs, and per-file Client banner.
* @returns ENV-selected tsdown config for the current build face.
* @throws {Error} when `<packageDir>/package.json` is unreadable or declares no name.
*/
@@ -118,13 +118,16 @@ export function clientBundle(
return ({ env }) => {
const face = buildFace(env?.DSH_BUILD_FACE)
const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js'
const client = clientConfig(manifest, clientEntry, options.clientBanner)
const client = clientConfig(manifest, clientEntry, options.clientBanner, options.dualClient === true)
const node = [lib, ...(options.companions ?? [])]
const clientFaces = options.dualClient === true
? [nodeClientConfig(manifest, clientEntry), client]
: [client]
if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
if (face === 'client') {
return options.hostPhase === true ? [client] : [...node, client]
return options.hostPhase === true ? clientFaces : [...node, ...clientFaces]
}
return [...node, client]
return [...node, ...clientFaces]
}
}
@@ -208,6 +211,8 @@ export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig {
interface ClientBundleOptions {
/** Emit the Node-side artifacts during the Host pass instead of the Client pass. */
readonly hostPhase?: boolean
/** Emit independent ESM `client.node.js` and browser-factory `client.browser.js` from the same Client entry. */
readonly dualClient?: boolean
/** Additional Node-side configs emitted alongside the package library. */
readonly companions?: readonly UserConfig[]
/** Overrides for the package's primary Node-side library config. */
@@ -467,7 +472,30 @@ function asyncChunkRequirePlugin(): TsdownPlugin {
}
}
function clientConfig(manifest: PackageManifest, entry: string, clientBanner?: (fileName: string) => string | undefined): UserConfig {
/** Bundle the Client entry for plain Node independently of the Host entry and browser factory. */
function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig {
const isolation = clientInputIsolation(manifest.name)
return clientLibraryConfig(manifest, [entry], {
name: `${manifest.name}/client-node`,
entry: { 'client.node': entry },
sourcemap: true,
inputOptions: { resolve: { conditionNames: ['node', 'import', 'module', 'default'] } },
plugins: [tscSourceMapPlugin(), isolation.plugin],
outputOptions: {
entryFileNames: 'client.node.js',
chunkFileNames: 'client.node.[name].js',
sourcemapExcludeSources: false,
sourcemapPathTransform: isolation.sourcePath,
},
})
}
function clientConfig(
manifest: PackageManifest,
entry: string,
clientBanner?: (fileName: string) => string | undefined,
dualClient = false,
): UserConfig {
const id = manifest.name
let requested: ReadonlySet<string> | undefined
const isRequested = (specifier: string): boolean => (requested ??= clientExternals(manifest)).has(specifier)
@@ -475,9 +503,8 @@ function clientConfig(manifest: PackageManifest, entry: string, clientBanner?: (
return {
name: `${id}/client`,
entry: { client: entry },
// Browser bundle lands next to the node half (single lib/ artifact dir;
// the entryFileNames pin keeps it exactly lib/client.js). clean must stay
// off — a default clean would wipe the node-half output emitted above.
// Browser bundle lands beside the Node artifacts. clean must stay off:
// cleaning this invocation would wipe the independently emitted Node entry.
outDir: 'lib',
format: 'cjs',
platform: 'browser',
@@ -601,10 +628,10 @@ function clientConfig(manifest: PackageManifest, entry: string, clientBanner?: (
},
}],
outputOptions: {
entryFileNames: 'client.js',
entryFileNames: dualClient ? 'client.browser.js' : 'client.js',
// The imported source basename becomes the published chunk name; package
// files lists and artifact tests pin every intentional chunk.
chunkFileNames: 'client.[name].js',
chunkFileNames: dualClient ? 'client.browser.[name].js' : 'client.[name].js',
sourcemapExcludeSources: false,
// The map is served from /plugins/<scoped-package>/client.js.map. The
// browser resolves its local sources back into URLs that mirror the
@@ -38,7 +38,10 @@ describe('experimental Inspector Host plugin', () => {
const registerUpgrade = vi.fn<(_route: WebUpgradeRoute) => () => void>(() => releaseUpgrade)
const routes: Pick<WebServer, 'register' | 'registerUpgrade'> = { register: registerRoute, registerUpgrade }
let authenticated = false
const auth: Pick<BrowserAuth, 'isAuthenticated'> = { isAuthenticated: () => authenticated }
const auth: Pick<BrowserAuth, 'isAuthenticated' | 'authenticateApi'> = {
isAuthenticated: () => authenticated,
authenticateApi: () => undefined,
}
context.provide('webServer', routes as WebServer)
const connection = new HostConnectionService(context, [], auth as BrowserAuth)
const fiber = context.plugin(
@@ -545,12 +545,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ConnectionHandle',
declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionRecoveryConfig): ConnectionLoop;\n}',
declaration: 'export interface ConnectionHandle {\n readonly interactions: ConnectionInteractions;\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n openWebSocket(path: string): ConnectionWebSocket;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionRecoveryConfig): ConnectionLoop;\n}',
},
{
name: 'ConnectionHostInfo',
declaration: 'export interface ConnectionHostInfo {\n readonly home: string;\n}',
},
{
name: 'ConnectionInteractions',
declaration: 'export type ConnectionInteractions = \'answer\' | \'none\';',
},
{
name: 'ConnectionLoop',
declaration: 'export interface ConnectionLoop {\n stop(): void;\n}',
@@ -579,6 +583,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ConnectionStateSource',
declaration: 'export interface ConnectionStateSource {\n getSnapshot(): ConnectionState | undefined;\n subscribe(listener: () => void): () => void;\n}',
},
{
name: 'ConnectionWebSocket',
declaration: 'export interface ConnectionWebSocket {\n readonly readyState: number;\n send(data: string): void;\n close(code?: number, reason?: string): void;\n addEventListener<Event extends keyof ConnectionWebSocketEventMap>(event: Event, listener: (event: ConnectionWebSocketEventMap[Event]) => void, options?: {\n readonly once?: boolean;\n }): void;\n removeEventListener<Event extends keyof ConnectionWebSocketEventMap>(event: Event, listener: (event: ConnectionWebSocketEventMap[Event]) => void): void;\n}',
},
{
name: 'ConnectionWebSocketEventMap',
declaration: 'export interface ConnectionWebSocketEventMap {\n readonly open: object;\n readonly error: object;\n readonly close: object;\n readonly message: {\n readonly data: unknown;\n };\n}',
},
{
name: 'DraftInitializationOptions',
declaration: 'export interface DraftInitializationOptions {\n readonly prompt?: string;\n readonly clearPreviousDraft?: boolean;\n}',
@@ -85,7 +85,7 @@ async function connectGatewayClient(): Promise<AbortController> {
await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
}, { home: '/fixture' }))
const connection = new AbortController()
const peer: PeerScope = { id: 'inspect-registry-owner' as PeerId, ctx, principal: { kind: 'owner' }, signal: connection.signal, dispose: () => Promise.resolve() }
const peer: PeerScope = { id: 'inspect-registry-owner' as PeerId, ctx, principal: { kind: 'owner' }, interactions: 'answer', signal: connection.signal, dispose: () => Promise.resolve() }
const stream = await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, connection.signal)
const client = stream[Symbol.asyncIterator]()
ctx.effect(() => async () => {
@@ -755,7 +755,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'readonly browserPeer: PeerScope',
description: 'The Peer every authenticated browser connection shares; its principal is the owner and its scope lives as long as Connection.',
description: 'The owner Peer every authenticated browser connection shares.',
parameters: [],
},
{
@@ -777,16 +777,22 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'admit(request: ConnectionTrustRequest): PeerAdmission',
description: 'Admit one network request: it passes requestRejection and speaks for browserPeer, or it is refused with that status.',
description: 'Admit an HTTP request against its signed browser or API credential and live Peer.',
parameters: [{ name: 'request', description: 'request headers from the HTTP or upgrade request.' }],
returns: 'the admitted Peer, or the rejection status.',
returns: 'the credential\'s Peer, or the rejection status.',
},
{
signature: 'createPeer(options: { readonly principal: Principal }): PeerScope',
description: 'Create an independent Peer. In-process only: no route, header, query, or cookie reaches it. Disposed with the Context that read `ctx.connection`, or earlier by its own `dispose()`.',
parameters: [{ name: 'options', description: '.principal - principal fixed for the Peer\'s lifetime.' }],
signature: 'createPeer(options: { readonly principal: Principal; readonly interactions?: \'answer\' | \'none\' }): PeerScope',
description: 'Create an independent in-process Peer, disposed with the Context that read `ctx.connection`.',
parameters: [{ name: 'options', description: 'fixed principal and optional interaction participation (default `answer`).' }],
returns: 'a new Peer distinct from every other Peer.',
},
{
signature: 'admitConnection(request: ConnectionTrustRequest): ConnectionAdmission',
description: 'Claim a physical socket for an API Peer; browser sockets share their owner Peer.',
parameters: [{ name: 'request', description: 'authenticated upgrade request headers.' }],
returns: 'the Peer and idempotent release, or 409 for duplicate API ownership. The carrier releases on socket termination or failed upgrade; release removes API admission synchronously without cancelling independent HTTP calls.',
},
{
signature: 'authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean',
description: 'Authenticate one frontend index request, owning a token redirect or 401. The cookie this mints is `Secure` when the mounted Web carrier serves TLS, which only that listener\'s protocol decides.',
@@ -5046,6 +5052,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ConfinedSandboxMode',
declaration: 'export type ConfinedSandboxMode = Exclude<SandboxMode, \'danger-full-access\'>;',
},
{
name: 'ConnectionAdmission',
declaration: 'export type ConnectionAdmission = {\n readonly peer: PeerScope;\n readonly release: () => Promise<void>;\n} | {\n readonly rejection: 401 | 403 | 409;\n};',
},
{
name: 'ConnectionFetchHandler',
declaration: 'export interface ConnectionFetchHandler {\n requestBodyMode(request: {\n readonly method: string;\n readonly url: URL;\n }): ConnectionRequestBodyMode;\n fetch(request: Request, peer: PeerScope): Promise<Response>;\n}',
@@ -6240,7 +6250,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PeerScope',
declaration: 'export interface PeerScope {\n readonly id: PeerId;\n readonly ctx: Context;\n readonly principal: Principal;\n readonly signal: AbortSignal;\n dispose(): Promise<void>;\n}',
declaration: 'export interface PeerScope {\n readonly id: PeerId;\n readonly ctx: Context;\n readonly principal: Principal;\n readonly signal: AbortSignal;\n readonly interactions: \'answer\' | \'none\';\n dispose(): Promise<void>;\n}',
},
{
name: 'PendingModule',
@@ -210,7 +210,7 @@ describe('subagent prompt Remote', () => {
invocation: {
request: { namespace: 'subagents', method: 'prompt', args: {} },
service: 'subagents',
peer: { id: 'subagent-peer' as PeerId, ctx, principal, signal, dispose: async () => {} },
peer: { id: 'subagent-peer' as PeerId, ctx, principal, interactions: 'answer', signal, dispose: async () => {} },
signal,
uplink: <In>(): AsyncIterable<In> => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }),
} satisfies RemoteInvocation,
@@ -234,6 +234,7 @@ export class TestClient {
...connection,
apply: (connectionCtx) => {
installConnection(connectionCtx, {
interactions: 'answer',
transport: { rpc: mock.rpc },
...(pageLocation === undefined ? {} : { location: pageLocation }),
})
+2 -2
View File
@@ -18,8 +18,8 @@
en: c479b5b665822198
zh: 1ffb789c18043baf
/deepseek-ai-dsh-typert-protocol/use-this-package/exposing-a-host-method:
en: ca7df7ea76fb09d8
zh: add884160fbbd5c7
en: 63968de5fc817d06
zh: fbe2804b2a2d70d3
/deepseek-ai-dsh-typert-protocol/use-this-package/identifying-the-caller-and-admitting-non-owner-callers:
en: 78e08ae732a8f6f4
zh: be15fb7599994fb9
+1 -1
View File
@@ -46,7 +46,7 @@ Generation turns the method into a wire endpoint under the service's namespace;
A unary method can return `Uint8Array` directly or within nested objects, arrays, tuples, optional fields, unions, and recursive types. Generation supplies optional result codec `encode()` and `decode()` functions: encoding visits only subtrees whose types can contain bytes, while decoding validates reconstructed values; Client declarations use `Uint8Array<ArrayBuffer>` at every byte position while retaining other field types. Pure JSON results pass through without Host byte detection or Client parsing. Parameters, events, and stream items remain JSON-only; runtime object cycles are unsupported.
A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream<Out, In>`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink<In>()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (the `PeerScope` its carrier names), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived: the first `bindTypertRemote()` binding in a tree, which every `TypertRemoteService` constructor makes, registers that accessor on the root. A generated Client stream method returns `RemoteStreamHandle<Out, In>`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through.
A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream<Out, In>`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink<In>()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (the `PeerScope` its carrier names), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived: the first `bindTypertRemote()` binding in a tree, which every `TypertRemoteService` constructor makes, registers that accessor on the root. Cordis's cross-copy `Context.is()` recognizes a source Context passed to a built Service, so mixed source/built compositions retain this accessor and invocation-local principals. A generated Client stream method returns `RemoteStreamHandle<Out, In>`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through.
`@Remote` options contain only `name`, `mode`, and the `resources` readers described below; input policy belongs to the consumer, not a method annotation. `method.input(options?: TypertRemoteInputOptions)` returns a cached `TypertRemoteValue<Input>`, with `strict?: boolean` as its only option. `method.input()` and `{ strict: false }` retain ordinary Remote handling: declared objects accept and strip unknown fields. `{ strict: true }` rejects undeclared fields recursively, while explicit JSON values and dictionary keys stay open and dictionary values retain their declared validation. Both named-argument envelopes reject extra arguments. Select one view (`const input = method.input({ strict: true })`) for matching `input.create().parse(raw)` and `input.jsonSchema`; this does not change Host dispatch. The [generator](<../generator/README.md#ordinary-remote-metadata>) owns publication and the paired codecs.
+1 -1
View File
@@ -46,7 +46,7 @@ export class GoalService extends TypertRemoteService {
一元方法可以直接返回 `Uint8Array`,也可以将其放在嵌套对象、数组、元组、可选字段、联合类型和递归类型中。生成器提供可选的结果 codec `encode()` 和 `decode()`:编码仅访问类型可能包含字节的子树,解码则校验还原后的值;Client 声明在每个字节位置使用 `Uint8Array<ArrayBuffer>`,同时保留其他字段类型。纯 JSON 结果不经 Host 字节识别或 Client 解析直接传递。参数、事件与流条目仍仅支持 JSON;不支持运行时对象循环。
流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream<Out, In>`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink<In>()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(其载体指明的 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`:树中第一个 `bindTypertRemote()` 绑定(每个 `TypertRemoteService` 构造函数都会建立一个)在根上注册该 accessor。生成的 Client 流方法返回 `RemoteStreamHandle<Out, In>`:在下行迭代之外提供 `send`、`end` 与 `dispose` 的句柄。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,原样透传。
流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream<Out, In>`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink<In>()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(其载体指明的 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`:树中第一个 `bindTypertRemote()` 绑定(每个 `TypertRemoteService` 构造函数都会建立一个)在根上注册该 accessor。Cordis 的跨副本 `Context.is()` 可识别传给构建产物 Service 的源码 Context,因此混合源码/构建组合仍保留此 accessor 和调用局部的 principal。生成的 Client 流方法返回 `RemoteStreamHandle<Out, In>`:在下行迭代之外提供 `send`、`end` 与 `dispose` 的句柄。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,原样透传。
`@Remote` 选项仅包含 `name`、`mode` 与下文所述的 `resources` 读取函数;输入策略由消费方选择,而非方法注解。`method.input(options?: TypertRemoteInputOptions)` 返回缓存的 `TypertRemoteValue<Input>`,唯一选项是 `strict?: boolean`。`method.input()` 与 `{ strict: false }` 保留普通 Remote 处理方式:已声明对象接受并剥离未知字段。`{ strict: true }` 递归拒绝未声明字段,而显式 JSON 值和字典键保持开放,字典值仍按声明校验。两种具名参数封装都拒绝额外参数。选择一个视图(`const input = method.input({ strict: true })`),即可通过 `input.create().parse(raw)` 与 `input.jsonSchema` 取得匹配的解析和投影;这不会改变 Host 分发。[生成器](<../generator/README.zh.md#ordinary-remote-metadata>)负责发布及配套 codec。
+1 -1
View File
@@ -238,7 +238,7 @@ export function bindTypertRemote<Service extends object>(
const namespace = options.namespace ?? serviceKey
validateName('namespace', namespace)
const ctx: unknown = Reflect.get(service, 'ctx')
if (ctx instanceof Context) provideInvocationAccessor(ctx)
if (Context.is(ctx)) provideInvocationAccessor(ctx)
return Object.freeze({ service, serviceKey, namespace })
}
+2
View File
@@ -473,6 +473,8 @@ export interface PeerScope {
readonly principal: Principal
/** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */
readonly signal: AbortSignal
/** Whether the Peer may open a forwarded-event stream; this does not grant business permissions. */
readonly interactions: 'answer' | 'none'
/**
* Tear down every registration made through `ctx`.
* @returns settles once the scope has quiesced; racing calls share one completion.
@@ -1,3 +1,4 @@
import { execFileSync } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { expect, it } from 'vitest'
@@ -8,3 +9,53 @@ it.each(['../lib/index.js', '../lib/types/remote-info.js'])(
expect(source).not.toMatch(/\b(?:from|import|require)\s*(?:\(\s*)?['"]zod(?:\/[^'"]*)?['"]/)
},
)
it('recognizes source and built Cordis Contexts in an active built RemoteAccess plugin', () => {
const script = `
import { Context as SourceContext } from '@deepseek-ai/cordis/src/index.ts'
import { Context as BuiltContext } from '@deepseek-ai/cordis'
import RemoteAccess from '@deepseek-ai/dsh-api-remote-access'
const observations = []
for (const [copy, Context] of [['source', SourceContext], ['built', BuiltContext]]) {
const root = new Context()
let observed
try {
await root.plugin({ apply(ctx) {
const access = new RemoteAccess(ctx)
const guestInvocation = {
request: { namespace: 'session', method: 'search', args: {} },
peer: { principal: { kind: 'guest' } },
}
const guest = ctx.extend({ invocation: guestInvocation })
observed = {
copy,
sameConstructor: ctx instanceof BuiltContext,
recognized: BuiltContext.is(ctx),
active: !!ctx.fiber.runtime,
accessor: Object.hasOwn(root.reflect.props, 'invocation'),
outside: ctx.invocation ?? null,
allowsOutside: access.ctx.remoteAccess.allows({ kind: 'global' }),
preservedGuest: guest.invocation === guestInvocation,
allowsGuest: guest.remoteAccess.allows({ kind: 'global' }),
}
} })
observations.push(observed)
} finally {
await root.fiber.dispose()
}
}
console.log(JSON.stringify(observations))
`
const output = execFileSync(process.execPath, ['--import', 'tsx/esm', '--input-type=module', '-e', script], {
cwd: new URL('../../../api/remote-access/', import.meta.url),
encoding: 'utf8',
timeout: 15_000,
})
expect(JSON.parse(output)).toEqual([
{ copy: 'source', sameConstructor: false, recognized: true, active: true, accessor: true,
outside: null, allowsOutside: true, preservedGuest: true, allowsGuest: false },
{ copy: 'built', sameConstructor: true, recognized: true, active: true, accessor: true,
outside: null, allowsOutside: true, preservedGuest: true, allowsGuest: false },
])
})
@@ -412,6 +412,12 @@ describe('typert-protocol Remote declarations', () => {
expect(() => { directSecond[0]!.call(reverseService) }).toThrow('conflicting invocation markers')
})
it('leaves bindings without a Cordis Context free of an invocation accessor', () => {
const service = { ctx: {} }
expect(bindTypertRemote(service, 'plain')).toEqual({ service, serviceKey: 'plain', namespace: 'plain' })
expect(Object.hasOwn(service.ctx, 'invocation')).toBe(false)
})
it('rejects ambiguous binding names', () => {
expect(() => bindTypertRemote({}, '')).toThrow('service key')
expect(() => bindTypertRemote({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace')
+4 -4
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-typert-registry/use-this-package:
en: d885ccd8f13315f1
zh: cea7c258f5f57e2d
en: eeeaecf7800bcb6b
zh: 2562380c15cb2428
/deepseek-ai-dsh-typert-registry/use-this-package/minimal-setup:
en: 1ec4b304a024bb3c
zh: f17e796c26faabab
@@ -24,8 +24,8 @@
en: 254d265a3e5b1638
zh: bfa325ec79afbc72
/deepseek-ai-dsh-typert-registry/use-this-package/registering-a-contribution:
en: 2b2f750a1b145d85
zh: d15c55c01af1755a
en: 9ca425515904d5c9
zh: b251226ecfa32002
/deepseek-ai-dsh-typert-registry/use-this-package/lookup-and-context-providers:
en: 937dfd14d9e18a5c
zh: 6cf6aa352c8825c6
+3 -1
View File
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
Mount the registry in any Host or Client composition that stores or consumes generated Typert artifacts; it provides `ctx.typert`. There is no configuration.
Mount the registry in any Host or Client composition that stores or consumes generated Typert artifacts; it provides `ctx.typert`. There is no configuration. Node resolves `/client` to the standalone ESM `lib/client.node.js` bundle for programmatic Cordis composition; browsers use the `lib/client.browser.js` loader factory through the logical `/plugins/.../client.js` URL. Both bundles use the same registry implementation, separate from the Host package root.
### Minimal setup
@@ -45,6 +45,8 @@ Invocation information is available through existing `ctx.typert.local.get/list`
Generated artifacts register through the [loader](../loader/README.md) in Loader compositions; any other owner calls `ctx.typert.register(contribution)` directly and receives the exact disposer that withdraws it. Duplicate package-face identities, schema keys, invocation ids, or endpoints reject the whole batch before anything is committed.
Harness offline integrations register an ordinary `TYPERT_REMOTE` contribution with `ctx.typert.remotes.register(contribution)` in their profile Context. `$mount(contribution)` already performs that registration, so registering and mounting the same contribution in the same registry duplicates it and fails. Keep offline metadata ownership separate from the live connection-and-mount Context. Independent offline consumers may instead read `TYPERT_REMOTE.methods` without any Registry. Registry state belongs to its Context and process, is not shared across processes, and neither registration nor querying opens a connection.
### Lookup and Context providers
Remote calls resolve Host objects and scoped Contexts through `ctx.typert.lookups` and `ctx.typert.contexts`. `registerHost()` installs the Host wire declaration and its wire-to-Context resolver, while `configureHost()` replaces only that resolver. `registerClient()` installs the bidirectional Client adapter for the same merge-declared kind. Host-to-Client event sources carry their domain identity explicitly instead of projecting it back from a Context.
+3 -1
View File
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
在存储或消费生成 Typert 产物的任何 Host 或 Client 组合中挂载本注册表;它提供 `ctx.typert`。没有配置。
在存储或消费生成 Typert 产物的任何 Host 或 Client 组合中挂载本注册表;它提供 `ctx.typert`。没有配置。Node 将 `/client` 解析为供程序化 Cordis 组合使用的独立 ESM bundle `lib/client.node.js`;浏览器通过逻辑 URL `/plugins/.../client.js` 使用 `lib/client.browser.js` loader factory。两个 bundle 使用相同的注册表实现,且与 Host 包根入口分离。
### 最小设置
@@ -45,6 +45,8 @@ kind: "package-reference"
生成产物在 Loader 组合中通过 [loader](../loader/README.zh.md) 注册;其他所有者直接调用 `ctx.typert.register(contribution)`,并获得撤销它的同一资源释放函数。重复的包与 face 组合键、schema 键、调用 id 或端点会在提交任何内容之前拒绝整个批次。
Harness 离线集成在各自的 profile Context 中通过 `ctx.typert.remotes.register(contribution)` 注册普通 `TYPERT_REMOTE` 贡献。`$mount(contribution)` 已执行该注册,因此在同一个注册表中先注册再挂载同一贡献会因重复而失败。离线元数据所有权应与建立实际连接及挂载的 Context 分离。独立离线消费方也可以直接读取 `TYPERT_REMOTE.methods`,无需任何 Registry。注册表状态归所属 Context 与进程所有,不跨进程共享;注册和查询都不会打开连接。
### Lookup 与 Context 提供方
Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host 对象与作用域 Context。`registerHost()` 安装 Host wire 声明及其 wire 到 Context 的 resolver,`configureHost()` 只替换该 resolver。`registerClient()` 为同一个 merge-declared kind 安装双向 Client adapter。Host 到 Client 的事件源显式携带领域 identity,而不从 Context 反向投影。
+5 -2
View File
@@ -20,7 +20,9 @@
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
"node": "./lib/client.node.js",
"browser": "./lib/client.browser.js",
"default": "./lib/client.browser.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
@@ -44,7 +46,8 @@
"files": [
"locale/*.json",
"lib/index.js",
"lib/client.js",
"lib/client.browser.js",
"lib/client.node.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
+1 -1
View File
@@ -1,3 +1,3 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle(import.meta.dirname, ['lib/types/index.js'])
export default clientBundle(import.meta.dirname, ['lib/types/index.js'], { dualClient: true })
@@ -442,6 +442,18 @@ it('requires the Inspector Worker, Client chunks, and mirrored DevTools resource
}
})
it('requires both physical Client entries only for opted-in packages', () => {
for (const dir of ['packages/client/connection', 'packages/api/gateway', 'packages/typert/registry']) {
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
for (const omitted of ['lib/client.node.js', 'lib/client.browser.js']) {
const files = manifest.files!.filter(file => file !== omitted)
expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files } }))
.toEqual([expect.stringContaining('package.json files must be')])
}
}
})
it('requires the standalone shortcut protocol and rejects unrelated runtime files', () => {
const dir = 'packages/client/shortcuts'
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
+12 -5
View File
@@ -252,6 +252,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str
const target = exportDefault(manifest, resource)
if (target?.startsWith('./') && target.endsWith('.json')) localeFiles.add(target.slice(2))
}
const browserClient = exportDefault(manifest, './client')
const bundle = manifest.dsh?.bundle
const bundleFiles = bundle === undefined ? [] : bundlePatchFiles(bundle).map(file => file.replace(/^\.\//, ''))
const extras = [
@@ -275,11 +276,11 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str
// Keyed on the artifact path, like ./client below.
...exportDefault(manifest, './worker') === './lib/worker.cjs' ? ['lib/worker.cjs'] : [],
...exportDefault(manifest, './worker') === './lib/worker.js' ? ['lib/worker.js'] : [],
// UI plugin packages ship their browser bundle beside the node lib
// (single-artifact ruling: dist/ retired, ./client resolves lib/client.js).
// Keyed on the artifact path, not the subpath name: a package's ./client is
// a browser-safe source channel, not a bundle.
...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [],
// Only physical browser bundles need a publication entry; a ./client
// subpath may instead expose a browser-safe emitted source tree.
...browserClient === './lib/client.js' ? ['lib/client.js'] : [],
...browserClient === './lib/client.browser.js' ? ['lib/client.browser.js'] : [],
...exportCondition(manifest, './client', 'node') === './lib/client.node.js' ? ['lib/client.node.js'] : [],
// runtime's shell-held loader subpath ships as its own bundle beside the client half.
...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [],
// A store subpath ships its own bundle (single-entry builds; no shared chunk).
@@ -334,6 +335,12 @@ function exportDefault(manifest: PackageManifest, subpath: string): string | und
return typeof target === 'string' ? target : undefined
}
/** Exact named condition target of an export entry. */
function exportCondition(manifest: PackageManifest, subpath: string, condition: string): string | undefined {
const target = exportFields(manifest.exports?.[subpath])?.[condition]
return typeof target === 'string' ? target : undefined
}
/** Whether any export's runtime default points into the tsc-emitted lib/types tree. */
function usesEmittedTreeDefaults(manifest: PackageManifest): boolean {
return Object.keys(manifest.exports ?? {}).some(subpath =>
@@ -278,6 +278,13 @@ describe('client build environment', () => {
const chunked = buildFixture(officialEnvironment)
write(join(chunked, 'packages/client/example/lib/client.pdf.js'), 'module.exports = {}\n')
expect(() => { readClientBuildRecord(chunked) }).toThrow(/artifacts differ/)
const dual = buildFixture(officialEnvironment)
write(join(dual, 'packages/client/example/lib/client.node.js'), 'export const node = true\n')
write(join(dual, 'packages/client/example/lib/client.node.helper.js'), 'export const helper = true\n')
expect(() => { readClientBuildRecord(dual) }).not.toThrow()
write(join(dual, 'packages/client/example/lib/client.browser.js'), 'module.exports = { browser: true }\n')
expect(() => { readClientBuildRecord(dual) }).toThrow(/artifacts differ/)
})
it('keeps public client values out of workflow-wide environments', () => {
+1 -1
View File
@@ -331,7 +331,7 @@ export function readClientBuildRecord(
function clientArtifactDigest(root: string): ClientArtifactDigest {
const paths = globSync([...CLIENT_ARTIFACT_PATTERNS], { cwd: root })
.map(path => path.replaceAll('\\', '/'))
.filter(path => statSync(resolve(root, path)).isFile())
.filter(path => !/\/lib\/client\.node(?:\.|$)/.test(path) && statSync(resolve(root, path)).isFile())
.sort()
if (paths.length === 0) throw new Error('complete client build produced no Vite or dynamic client artifacts')
+57 -7
View File
@@ -75,6 +75,29 @@ describe('client bundle build faces', () => {
expect(() => staticLinked(root, ['lib/types/index.js'])).toThrow(/declares no string package name/)
})
it('opts shared Clients into independent Node ESM and browser factory entries', () => {
for (const dir of ['client/connection', 'api/gateway', 'typert/registry']) {
const configs = clientBundle(packageDir(dir), ['lib/types/index.js'], { dualClient: true })(
{ env: { DSH_BUILD_FACE: 'client' } },
)
const node = configs.find(config => config.name?.endsWith('/client-node'))
const browser = configs.find(config => config.name?.endsWith('/client'))
expect(node?.entry).toEqual({ 'client.node': 'lib/types/client/index.js' })
expect(node).toMatchObject({ platform: 'node', format: ['esm'], sourcemap: true })
expect(node?.inputOptions).toMatchObject({ resolve: { conditionNames: ['node', 'import', 'module', 'default'] } })
expect(node?.outputOptions).toMatchObject({ entryFileNames: 'client.node.js' })
expect(browser?.platform).toBe('browser')
expect(browser?.outputOptions).toMatchObject({ entryFileNames: 'client.browser.js' })
expect(configs.find(config => config.platform === 'node' && config.name?.endsWith('/client'))).toBeUndefined()
}
const ordinary = clientBundle(REQUESTING_PACKAGE_DIR, ['lib/types/index.js'])(
{ env: { DSH_BUILD_FACE: 'client' } },
)
expect(ordinary.some(config => config.name?.endsWith('/client-node'))).toBe(false)
expect(ordinary.find(config => config.platform === 'browser')?.outputOptions)
.toMatchObject({ entryFileNames: 'client.js' })
})
it('watches source in development and consumes emitted JavaScript in the Client build', () => {
const bundle = clientBundle(REQUESTING_PACKAGE_DIR, ['lib/types/index.js'])
const development = bundle({ env: {} }).find(config => config.platform === 'browser')
@@ -86,15 +109,42 @@ describe('client bundle build faces', () => {
})
})
function dynamicImportFixture(prefix: string): string {
const root = mkdtempSync(join(tmpdir(), prefix))
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
const entry = join(root, 'lib/types/client/index.js')
mkdirSync(dirname(entry), { recursive: true })
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: REQUESTING_PACKAGE, type: 'module' }))
writeFileSync(entry, 'export const load = () => import("./terminal.js")\n')
writeFileSync(join(dirname(entry), 'terminal.js'), 'export const marker = "terminal"\n')
return root
}
describe('client bundle dynamic imports', () => {
it('keeps a dual Client browser chunk disjoint from its Node output', async () => {
const root = dynamicImportFixture('dsh-dual-client-chunk-')
const configs = clientBundle(root, ['lib/types/index.js'], { dualClient: true })(
{ env: { DSH_BUILD_FACE: 'client' } },
)
for (const platform of ['node', 'browser'] as const) {
const config = configs.find(config => config.name?.endsWith(platform === 'node' ? '/client-node' : '/client'))
if (config === undefined) throw new Error(`${platform} config missing`)
const builds = await build({
...config, cwd: root, config: false, tsconfig: false,
write: false, clean: false, exports: false, report: false, logLevel: 'silent',
})
try {
const names = builds.flatMap(bundle => bundle.chunks).map(chunk => chunk.fileName)
expect(names).toContain(platform === 'node' ? 'client.node.js' : 'client.browser.js')
expect(names).toContain(platform === 'node' ? 'client.node.terminal.js' : 'client.browser.terminal.js')
} finally {
for (const bundle of builds) await bundle[Symbol.asyncDispose]()
}
}
})
it('compiles import() to the module loader asynchronous operation', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-client-dynamic-import-'))
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
const entry = join(root, 'lib/types/client/index.js')
mkdirSync(dirname(entry), { recursive: true })
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: REQUESTING_PACKAGE, type: 'module' }))
writeFileSync(entry, 'export const load = () => import("./terminal.js")\n')
writeFileSync(join(dirname(entry), 'terminal.js'), 'export const marker = "terminal"\n')
const root = dynamicImportFixture('dsh-client-dynamic-import-')
const config = clientConfigs()[0]
if (config === undefined) throw new Error('client config missing')
+1
View File
@@ -868,6 +868,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
ConnectionRequestRejection: 'transport rejection status is owned by packages/client/connection/src/rpc.ts',
ConnectionTrustRequest: 'transport authentication input is owned by packages/client/connection/src/rpc.ts',
PeerAdmission: 'Peer admission outcome is owned by packages/client/connection/src/rpc.ts',
ConnectionAdmission: 'physical socket admission and release are owned by packages/client/connection/src/rpc.ts',
PeerScope: 'Peer scope contract is owned by packages/typert/protocol/src/types.ts',
RemoteAccessPolicy: 'access policy contract is owned by packages/api/remote-access/README.md',
RemoteAccessRequest: 'Gateway decision requests are owned by packages/api/remote-access/README.md',
+1 -7
View File
@@ -164,15 +164,12 @@
"35f4c7349b7474bef7d2d1c5a8beaab93bcfac1e0d002520f80207444e3f256b": 11,
"4c16575f6e4a1c435b105717d0e05ad94779455de997597797059bd1668d7f99": 1,
"53cc383237bafd28d032833825d5a99158e938f7884de5bc5ab0011d48845643": 6,
"5cbd6ad5d73374671c654d285cb43b7cd1f40691d2d33f4d58ad5c7d951314ac": 1,
"665e53c977eb2565742192bbdd1861ca453b5b4ffd88d51a58533556235f335e": 1,
"7fe58280287ff63d40e979e72329f975e11015a95e9a8eec207a5a78748d3c89": 1,
"8f28ec738a702607e9ad458a76d5c695a459fed28009b6aac835ef4789026bfb": 1,
"97b408a8e61b28b507aab6f709875826b82d6505d4b967aec56914e7f831b20b": 1,
"a25e6d8d728e1de71699956f42f62a2e38af439317082fbb0354161ead666ab6": 1,
"b121cee14635b00f7891217013e4682493cf96f676710be60722fdf8b862aea3": 1,
"b428751e788e277116156e105c127f813dc300b61c6664b08d46e6c5ccdc3ae8": 1,
"da388e50e43adcb0b1331d55e3551780c382448686fe6a9dca486937c1667df0": 1
"b121cee14635b00f7891217013e4682493cf96f676710be60722fdf8b862aea3": 1
},
"packages/api/gateway/tests/gateway.host.spec.ts": {
"344e2cbbed2a8ef8d5356a24def92ac3453ffaa2efa58f5481d59206f5b79cbc": 1,
@@ -383,9 +380,6 @@
"packages/bundle/web-app/tests/startup.spec.ts": {
"2e78263d16d54ccfd8d322bcdf1bf738bd8134e15c24063ece9a1d1b52f4ebd9": 1
},
"packages/client/connection/src/browser-auth.ts": {
"98acffd14d7c77e3412ce5a7d8e9c555b66de76eb12c14d4b22b251684b60447": 1
},
"packages/client/connection/src/rpc-schema.ts": {
"21827725b3bf3a544927f354c5443228176a36e5926a26cc8640a59ba3eca717": 1,
"afa692cdc0fd53b274f5d276d34ea54141b26bee0c5d67055a2fdb056dc398fe": 1,
+16
View File
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url'
import { once } from 'node:events'
import { execa } from 'execa'
import { afterEach, describe, expect, it } from 'vitest'
import type { Message } from 'publint'
import { isBrowserBundleFormatFalsePositive } from './publint-browser-format.ts'
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
const runner = fileURLToPath(new URL('./publint-all.ts', import.meta.url))
@@ -91,6 +93,20 @@ async function run(root: string, signal: AbortSignal) {
return completed
}
describe('publint browser format filtering', () => {
it('exempts browser factories but not the sibling Node ESM artifact', () => {
const format = (file: string): Message => ({
code: 'FILE_INVALID_FORMAT',
args: { actualFilePath: `package/lib/${file}` },
path: ['./client'],
}) as Message
expect(isBrowserBundleFormatFalsePositive(format('client.browser.js'))).toBe(true)
expect(isBrowserBundleFormatFalsePositive(format('client.js'))).toBe(true)
expect(isBrowserBundleFormatFalsePositive(format('client.node.js'))).toBe(false)
expect(isBrowserBundleFormatFalsePositive(format('client.node.helper.js'))).toBe(false)
})
})
describe('publint package runner', () => {
it('reports deadline cancellation after every owned child closes', async ({ signal }) => {
const deadline = new AbortController()
+1 -16
View File
@@ -12,6 +12,7 @@ import { publint, type Message, type PackFile } from 'publint'
import { formatMessage } from 'publint/utils'
import ts from 'typescript'
import { defaultParallelism } from './parallelism.ts'
import { isBrowserBundleFormatFalsePositive } from './publint-browser-format.ts'
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
const repositoryRoot = resolve(import.meta.dirname, '..')
@@ -177,22 +178,6 @@ function relativeImports(file: string, sourceText: string): RelativeImport[] {
return imports
}
/**
* publint reads the CommonJS interop preamble inside the prebuilt browser
* bundles and reports CJS-written-as-ESM. Node never resolves those files:
* `lib/client.js` is evaluated by the page module system as a classic script,
* and `lib/worker.js` by `new Worker(url, { type: 'module' })` — both outside
* the Node resolution publint models. Exactly that verdict on exactly those
* files is suppressed; every other publint error stays fatal.
*/
function isBrowserBundleFormatFalsePositive(message: Message): boolean {
if (message.code !== 'FILE_INVALID_FORMAT') return false
const filePath = (message.args as { actualFilePath?: string }).actualFilePath ?? ''
const exportKey = Array.isArray(message.path) ? message.path.join('/') : ''
return /(^|\/)lib\/(client|worker)\.js$/.test(filePath)
|| /(^|\/)\.\/(client|worker)$/.test(exportKey)
}
async function runPublint(target: PackageTarget): Promise<PublintResult> {
try {
const files = publicationFiles(target)
+16
View File
@@ -0,0 +1,16 @@
import type { Message } from 'publint'
/**
* Browser Client and Worker artifacts run outside Node's ESM loader, so their
* CommonJS-format verdict is false; a Node Client entry must never be exempt.
* Other publint diagnostics remain fatal.
* @param message - one publint diagnostic for a published file.
* @returns true only for the known browser script format verdict.
*/
export function isBrowserBundleFormatFalsePositive(message: Message): boolean {
if (message.code !== 'FILE_INVALID_FORMAT') return false
const filePath = (message.args as { actualFilePath?: string }).actualFilePath ?? ''
const exportKey = Array.isArray(message.path) ? message.path.join('/') : ''
return /(^|\/)lib\/(?:client(?:\.browser)?|worker)\.js$/.test(filePath)
|| filePath === '' && /(^|\/)\.\/worker$/.test(exportKey)
}
@@ -23,6 +23,7 @@ export function registerSilentClientTransport(ctx) {
id: 'snapshot-client-peer',
ctx,
principal: { kind: 'owner' },
interactions: 'answer',
signal: new AbortController().signal,
dispose: async () => {},
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2024", "ESNext.Disposable"],
"types": ["node"]
}
}