Merge remote-tracking branch 'origin/master' into fix/multiline-display-math
This commit is contained in:
commit
0c1e3adb60
205 files changed
+6463
-699
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md
|
||||
2026-08-10-remote-event-delivery.md: 1706cc525bd7707874353a6658ec2f52597044a9
|
||||
2026-08-10-remote-event-delivery.zh.md: ac79642e90791892870c8920e02867faea180b07
|
||||
2026-08-10-remote-event-delivery.md: 3eaa26fb92c40b3a20cab11d4522bcf68b8a207f
|
||||
2026-08-10-remote-event-delivery.zh.md: d41589f1b800da98342210d2e429a2b67b6c61f2
|
||||
@@ -195,3 +195,7 @@ The few required Client symbols are mirrored on the test side: `scaffold.ts` exp
|
||||
- **Capabilities deliberately omitted.** Payload projection and redaction are unsupported, scopes other than Agent are unsupported, and ordinary notifications are not replayed. Recoverable state needs a query, cursor, or opening baseline; a waterfall is replayed only while its original Host invocation remains pending.
|
||||
- **Some Client packages remain in the Host graph.** Twelve projects, including `connection`, `runtime`, and `ui-slots`, remain reachable through unsplit `directory-picker-browse`/`-native` and `api/gateway → client/connection`. They compile and no longer pull in API Remotes' Client face, so this change does not split them. Direct `dsh-client-runtime/client` imports in two chat e2e files rely on Runtime's current presence in that graph rather than a general guarantee.
|
||||
- **The package intentionally publishes no invariant companion.** A prior revision asserted delivery form on the live event bus, coupling diagnostics to the allowlist and causing Rolldown to emit a third bundle chunk omitted by the mechanically derived publication list. The Host-face `TypertForwardableEventEntry` assertion already rejects those mismatches at compile time, and the package README records why no independent runtime relation remains.
|
||||
|
||||
## Related
|
||||
|
||||
- [Remote duplex stream](2026-09-19-remote-duplex-stream.md): every Remote stream carries a Client uplink on the same logical stream; the duplex `$events` listed under its Deferred section replaces the unary `$events/result` path once it lands.
|
||||
@@ -197,3 +197,7 @@ Client 要求首项是带非空 `clientId` 与 `host.home` 的 `ready`;后续
|
||||
- **放弃的能力**:不支持投影或脱敏载荷,不支持 Agent 以外的 Scope,也不为普通通知提供重放。需要可靠恢复的状态必须拥有查询、cursor 或 opening baseline;waterfall 只重放仍处于同一次 Host 调用生命周期内的 pending request。
|
||||
- **仍有 client 包留在 host 图里**:12 个工程(`connection`、`runtime`、`ui-slots` 等)经未拆分的 `directory-picker-browse`/`-native` 与 `api/gateway → client/connection` 仍可达 host 图。它们都能编译且不再牵连 api/remotes 的 client face,因此没有阻塞本次改动;拆分那些包能减少几个,但经评估后不做。两个 chat e2e 直接引 `dsh-client-runtime/client` 依赖 `runtime` 本来就在图里——属偶然而非保证。
|
||||
- **本包不发布 invariant companion**:早先的修订曾在活事件总线上断言投递形状(`thisArg === null`、`mode === 'emit'`),这让诊断逻辑与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。Host 面的 `TypertForwardableEventEntry` 断言已在编译期拒绝这些偏离,包 README 也记录了不再存在独立运行时关系的原因。
|
||||
|
||||
## 相关
|
||||
|
||||
- [Remote 双工流](2026-09-19-remote-duplex-stream.zh.md):所有 Remote 流在同一逻辑流上承载客户端上行;其「后续」列出的双工 `$events` 落地后取代 unary `$events/result` 路径。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md
|
||||
2026-09-11-node-office-kit.md: 6cab947d7cb8d4c2c416a83f0b765813c7f98ea9
|
||||
2026-09-11-node-office-kit.zh.md: 8ed1a81922249cf0b9c1a74b06e5ce9d44696e0a
|
||||
2026-09-11-node-office-kit.md: b8e94bf6b392edf7931346400fda9ca5d3416188
|
||||
2026-09-11-node-office-kit.zh.md: 41b84f26c8150db90f09e4ae2b01abb3dddfdc6d
|
||||
@@ -14,7 +14,7 @@ The [office-to-pdf capability](../../../../packages/document/README.md) delegate
|
||||
|
||||
The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-conversion.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills.
|
||||
|
||||
The service, Remote methods, and Client registration accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches.
|
||||
The service and Remote methods accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The Client preview registers DOC, DOCX, PPT, and PPTX; XLS and XLSX use the unsupported-preview state and can open in the system default application without requesting conversion. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches.
|
||||
|
||||
A converter reuses font metadata obtained by its first conversion Worker; original font buffers and decoded glyph coverage remain conversion-local. Workers validate indexed files when reading them. Exact installed families precede configured alternatives, and complete family/style/weight/italic/width/pitch/language/code-point requests retain their distinct matches. WASM callbacks import original font files, including complete collections, into MEMFS. Native engines also retain their platform's font discovery. Neither path downloads or installs fonts; native OS-managed font memory is outside the explicit import budget. Recreating the converter refreshes its metadata after font changes.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
[平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-conversion.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。
|
||||
|
||||
服务、Remote 方法和 Client 注册接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。
|
||||
服务与 Remote 方法接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。Client 预览注册 DOC、DOCX、PPT 和 PPTX;XLS 与 XLSX 使用不支持预览的空状态,可由系统默认应用打开,并且不请求转换。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。
|
||||
|
||||
converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md
|
||||
2026-09-19-remote-duplex-stream.md: 0f1f6a575f6871d37822c5829fe4531261b8e95b
|
||||
2026-09-19-remote-duplex-stream.zh.md: d3982aa433aa3ce84b945bb5831cef8064472853
|
||||
@@ -0,0 +1,597 @@
|
||||
# Agent Note: Remote duplex stream: one kind of stream, an uplink channel, and an invocation context
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-19-remote-duplex-stream.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
### Current state
|
||||
|
||||
`dsh-api-gateway` multiplexes every Typert Remote stream over one WebSocket at the fixed path `/api/remote.mux` (`packages/api/gateway/src/stream-protocol.ts`). A Host method decorated with `@Remote({ mode: 'stream' })` that returns an `Iterable` or `AsyncIterable` becomes one Host-to-Client logical stream; the optional final `signal: AbortSignal` is the only reserved parameter, never enters the wire arguments, and is appended by the Gateway after the decoded business parameters (`packages/api/gateway/src/index.ts`). The generated method the Client receives returns a bare `AsyncIterable`.
|
||||
|
||||
There are only five wire frames:
|
||||
|
||||
| Direction | Frame | Definition |
|
||||
| --- | --- | --- |
|
||||
| Client to Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` |
|
||||
| Client to Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` |
|
||||
| Host to Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` |
|
||||
| Host to Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` |
|
||||
| Host to Client | `{ type: 'end', streamId }` | `stream-protocol.ts` |
|
||||
|
||||
The Client can send only open and cancel. The parser `parseRemoteStreamClientMessage` (`stream-protocol.ts`) throws on any other frame, and a Host connection that receives one closes the whole socket with 1008 (`stream-server.ts`). In short, the Gateway today supports only "a backend iterable sent to the frontend". Every feature that needs the frontend to send data to the backend continuously takes its own detour.
|
||||
|
||||
A Host method also has no notion of who initiated this call: `InvokeRemoteRequest = { namespace, method, args, signal }` (`packages/api/gateway/src/types.ts`) has no caller slot, `RemoteStreamOpener` has the signature `(endpoint, payload, signal)` (`stream-server.ts`), and the WebSocket checks the cookie once, at the handshake.
|
||||
|
||||
### Three unrelated uplink mechanisms
|
||||
|
||||
| Feature | Uplink path | Location | Cost |
|
||||
| --- | --- | --- | --- |
|
||||
| Web terminal keystrokes | One unary RPC `terminal.write(agent, id, attachmentId, data)` per xterm `onData`; the Client serializes them through a promise chain and caps its own byte budget | `packages/api/terminal-controller/src/client/model.ts`; Host `src/index.ts` | One HTTP round trip and one authentication per keystroke; two carriers correlated by hand through `attachmentId`; `inputFull` when the budget is exhausted |
|
||||
| File upload | A `Blob` or `ReadableStream` goes to the native `POST /api/session/uploadFileBinary` outside the Remote layer, with `duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`; route `src/index.ts` | Bypasses Typert descriptors; no type projection |
|
||||
| Answers to approvals and questions | The Host sends waterfall frames down the `$events` stream, the Client answers through a separate unary RPC `$events/result`, correlated by `clientId` plus `eventId` | `packages/api/gateway/src/client/remote-events.ts`; Host `index.ts` | A second correlation key; `index.ts` records the race this creates |
|
||||
|
||||
These three mechanisms solve the same problem: the Client wants to write into a logical stream that is already open.
|
||||
|
||||
### Concrete costs
|
||||
|
||||
- **Aligning two carriers.** The terminal uses `attachmentId` to correlate the `follow` stream with the `write` RPC and to decide who owns input. Approvals use `clientId` to correlate the `$events` stream with the `$events/result` RPC.
|
||||
- **No ordering guarantee.** A unary RPC travels over HTTP and is unordered relative to the WebSocket downlink; the terminal can preserve order only by serializing writes on the Client.
|
||||
- **Separate authentication and lookup per uplink.** Every `write(agent, id, attachmentId, data)` resolves `agent`, looks up the terminal, and validates the attachment.
|
||||
- **No EOF.** The Client cannot express "I am done writing"; a half-close needs yet another RPC.
|
||||
- **Cancellation does not cover both halves.** Cancelling the downlink stream does not cancel queued uplink writes, and vice versa.
|
||||
- **Backpressure implemented three times.** The downlink pulls the iterator at the pace of the socket write callback (`stream-server.ts`); the terminal uses a bounded follower that makes a slow consumer fail explicitly (`terminal-controller/src/stream.ts`); the uplink relies on the Client capping `maxInputBytes` itself.
|
||||
- **The documentation is already stale.** `docs/api-gateway.md:160` still says Remote handles one request and one result, a sentence that lags the stream mode the README records.
|
||||
|
||||
### Relation to background jobs
|
||||
|
||||
The process-shaped duplex stream face of background jobs (archived as a draft PR on branch `worktree/job-stdio-stream-pair-archive`) models a job as a pair of mirrored stream faces; for the browser to attach to it, one Remote stream must carry both a downlink and an uplink. It is one motivation for this Note but not in its scope: whether jobs need a dedicated read-write stream is an architecture question for the jobs line itself. This Note covers only the transport layer.
|
||||
|
||||
### Goals
|
||||
|
||||
1. The transport layer has one kind of stream, duplex by nature: downlink `item` / `end` / `error`, uplink `item` / `end`, one `streamId`, one generation.
|
||||
2. The uplink type and the downlink type are written in the same place: the method's return type.
|
||||
3. A Host method takes its uplink from the invocation context; the invocation context also carries the caller (the Peer).
|
||||
4. The Client receives a handle that can read, write, and close, not a bare `AsyncIterable`.
|
||||
5. Upper-layer protocols (snapshot, journal, any business-defined stream) stay out of the transport layer.
|
||||
|
||||
## Decision
|
||||
|
||||
### In one sentence
|
||||
|
||||
Every Remote stream accepts a Client uplink. The method's return type `RemoteStream<Out, In = never>` declares the downlink item type and the uplink item type together; the generator derives two codecs from it. A Host method takes the uplink iterator through `this.ctx.invocation.uplink<In>()` and learns the caller through `this.ctx.invocation.peer`. The generated Client method returns a `RemoteStreamHandle<Out, In>`: `for await` reads the downlink, `send` / `end` write the uplink, `dispose` closes. Reopening after disconnection, cursor resumption, and baseline validation all belong to upper-layer protocols.
|
||||
|
||||
```
|
||||
Client Host
|
||||
───────────────────────────────── ───────────────────────────────────────────
|
||||
const s = remote.job.attach(req, signal)
|
||||
│ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control)
|
||||
│ │ prepareInvocation
|
||||
│ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() }
|
||||
│ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service)
|
||||
│ s.end() → end { streamId } ───────▶ inbox.end() │ method(...args, signal)
|
||||
│ ◀─────── item { streamId, o1 } │ for await (v of this.ctx.invocation.uplink()) …
|
||||
│ for await (o of s) … ◀─────── item { streamId, o2 } │ yield o …
|
||||
│ s.dispose() → cancel { streamId } ───────▶ control.abort() │ signal aborts; uplink.return()
|
||||
│ ◀─────── end | error
|
||||
```
|
||||
|
||||
### Terms
|
||||
|
||||
| Term | Meaning |
|
||||
| --- | --- |
|
||||
| Logical stream | One call identified by one `streamId`, spanning `open` to the terminal frame |
|
||||
| Generation | One physical lifetime of a logical stream. The transport layer knows exactly one generation; disconnection is failure, and reopening belongs to the upper layer |
|
||||
| Downlink | The Host-to-Client `item` sequence, terminated by `end` or `error` |
|
||||
| Uplink | The Client-to-Host `item` sequence, terminated by `end` (half-close) or ended together with `cancel` |
|
||||
| Half-close | One direction has ended while the other remains open |
|
||||
| Item | One business value in one direction; every item is validated by the codec independently |
|
||||
| inbox | The bounded uplink queue the Host keeps per logical stream |
|
||||
| Peer | The party the connection layer admitted, one `PeerScope`; this Host has exactly one Peer, the operator |
|
||||
| Invocation context | `RemoteInvocation`: the request, receiving service, Peer, cancellation signal, and uplink entry of this call |
|
||||
|
||||
### Type signatures
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-typert-protocol
|
||||
/**
|
||||
* One Remote stream. Host face: the method returns it, and at runtime it is AsyncIterable<Out>.
|
||||
* On the Client face the generated method returns RemoteStreamHandle<Out, In>; each name has exactly one meaning.
|
||||
* In is the item type the Client may send uplink; the default never means the method reads no uplink.
|
||||
*/
|
||||
export type RemoteStream<Out, In = never> = AsyncIterable<Out> & { readonly [STREAM_UPLINK]?: In }
|
||||
|
||||
// Host
|
||||
@Remote({ mode: 'stream' })
|
||||
async *attach(request: JobAttachRequest, signal: AbortSignal): RemoteStream<JobFollowFrame, JobInputFrame> {
|
||||
const uplink = this.ctx.invocation.uplink<JobInputFrame>()
|
||||
void (async () => { for await (const frame of uplink) … })()
|
||||
yield …
|
||||
}
|
||||
|
||||
// Generated Client signature: same parameters, returns the handle
|
||||
attach(request: JobAttachRequest, signal?: AbortSignal): RemoteStreamHandle<JobFollowFrame, JobInputFrame>
|
||||
|
||||
// Client usage
|
||||
const stream = remote.job.attach(req, signal)
|
||||
for await (const frame of stream) …
|
||||
stream.send(frame) // typed as JobInputFrame
|
||||
stream.end()
|
||||
stream.dispose()
|
||||
```
|
||||
|
||||
`RemoteStream` is an alias rather than an interface, deliberately: if the transport layer later takes a Node stream form, only the alias definition and the adapters at both ends change; method signatures, descriptors, and upper-layer protocols stay untouched.
|
||||
|
||||
### Host face
|
||||
|
||||
```text
|
||||
/** The context of this Remote call. A method reads it through this.ctx.invocation. */
|
||||
export interface RemoteInvocation {
|
||||
readonly request: {
|
||||
readonly namespace: string
|
||||
readonly method: string
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
}
|
||||
/** Cordis service key of the receiving service. */
|
||||
readonly service: string
|
||||
/** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */
|
||||
readonly peer: PeerScope
|
||||
/** Carrier cancellation: Client cancel, socket closure, uplink failure. */
|
||||
readonly signal: AbortSignal
|
||||
/**
|
||||
* The uplink items of this call. Can be taken once; the second call throws. With an uplink codec
|
||||
* in the descriptor each item is decoded to In; without one, items are delivered as unknown with
|
||||
* only a JSON-safety check.
|
||||
* The iteration ends after the Client's end; when the method ends the downlink, the Gateway calls
|
||||
* its return() and drops unconsumed items.
|
||||
* The generic In is only the caller's type assertion; runtime decodes per the descriptor and does not cross-check.
|
||||
*/
|
||||
uplink<In = unknown>(): AsyncIterable<In>
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** The Remote call this Context was derived for; undefined on a Context not derived from a Remote call. */
|
||||
readonly invocation: RemoteInvocation | undefined
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
1. Any `mode: 'stream'` method may call `this.ctx.invocation.uplink()`; a unary method may too, but its stream ends when the method returns, so uplink items are readable only while the method runs.
|
||||
2. While `uplink()` has not been called, uplink items accumulate in the inbox until the limit is exceeded (see Backpressure) or the stream ends and they are dropped. This is deliberate: a method that does not read the uplink need not know the uplink exists.
|
||||
3. When `In` defaults to `never`, the descriptor has no uplink codec; the Client handle's `send` has type `never`, so nothing can be sent at compile time. If frames still arrive at runtime (from a hand-built client), they are delivered as `unknown`.
|
||||
4. `this.ctx.invocation` is `undefined` on a Context not derived from a Remote call; a method that reads it handles the optional as TypeScript requires, or asserts it on the method's first line. The accessor for that property is registered once when the `TypertRemoteService` base class is constructed, so any composition that installs a Remote service can read it.
|
||||
|
||||
#### How the invocation context reaches the method
|
||||
|
||||
The mechanism relies on two existing Cordis behaviors:
|
||||
|
||||
- `ctx.extend(meta)` is `Object.create(parent)` plus own properties (`vendor/cordis/src/context.ts:99-107`): no registration, no fiber, nothing to release, so deriving one per call costs nothing.
|
||||
- `ctx.get(service)` returns the `createTraceable(ctx, service)` proxy (`vendor/cordis/src/reflect.ts:233-234`, `utils.ts:117-125`). Reading the `ctx` property returns the accessing Context directly (`utils.ts:175`); reading a method wraps it in `createShadowMethod`, whose `apply` replaces `this` with a shadow receiver whose `ctx` is `ctx.extend({ [symbols.shadow]: origin })` (`utils.ts:149-163`).
|
||||
- The Gateway therefore writes `receiverContext.extend({ invocation }).get(descriptor.service)` to obtain a per-call view and calls `Reflect.apply(method, callReceiver, args)`; inside the method body, `this.ctx.invocation` reads this call through the prototype chain. The deferred execution of an async generator body is unaffected, because `this` was bound to the shadow object at call time.
|
||||
|
||||
### PeerScope
|
||||
|
||||
```text
|
||||
/** The opaque identity of one Peer. */
|
||||
export type PeerId = Branded<'PeerId'>
|
||||
|
||||
/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */
|
||||
export interface PeerScope {
|
||||
readonly id: PeerId
|
||||
readonly ctx: Context
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
This Host has exactly one Peer: the operator. `@deepseek-ai/dsh-client-connection` creates it with `createScope(connectionCtx, peer)` when the service applies, the same mechanism an Agent uses to create its own scope: the Peer object is the ScopeKey, `peer.ctx` carries connection-lifetime effects, and `dispose()` runs with the Connection's release and quiets the fiber. It is exposed as `connection.operator`.
|
||||
|
||||
| Member | Semantics |
|
||||
| --- | --- |
|
||||
| `connection.operator: PeerScope` | The only Peer; lives and dies with the Connection |
|
||||
| `connection.admit(request): PeerAdmission` | Runs the existing `requestRejection`: returns `{ rejection: 401 \| 403 }` when refused, otherwise `{ peer: operator }`. Future admitters plug in here |
|
||||
|
||||
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. When `InvokeRemoteRequest.peer?` or the `peer` parameter of `RemoteStreamOpener` is absent, the answer is the operator, so existing in-process callers change nothing; in a composition without a Connection, the operator stand-in the Gateway builds for itself is likewise created through `createScope`, with the same `dispose()` contract. Looking up a Peer by id, associating a carrier with a second Peer, and Peer open and close events all wait until the first consumer that needs multiple Peers appears.
|
||||
|
||||
The Peer carries no access model: who the Peer is and what it may do are attached by business plugins on their own through `peer.ctx`.
|
||||
|
||||
### Client face
|
||||
|
||||
```text
|
||||
/** The handle a generated method returns on the Client face. A different name from the Host's RemoteStream, exported from the same entry point. */
|
||||
export interface RemoteStreamHandle<Out, In> extends AsyncIterable<Out> {
|
||||
/** Send one uplink item. Throws on a value that is not lossless JSON, after termination, or after end. */
|
||||
send(item: In): void
|
||||
/** Half-close the uplink: sends the end frame. Idempotent. */
|
||||
end(): void
|
||||
/** Cancel the whole logical stream: sends the cancel frame (when no terminal frame has arrived); the downlink iterator ends quietly. */
|
||||
dispose(): void
|
||||
}
|
||||
```
|
||||
|
||||
- The handle represents one generation. When the carrier is lost, `for await` fails with `RemoteStreamCarrierError` and the handle ends there; whether to reopen, and with which arguments, belongs to the upper-layer protocol.
|
||||
- `send` is synchronous: it runs the `isRemoteJsonValue` check before enqueuing and throws synchronously on a value that is not lossless JSON. The browser's `WebSocket.send` has no write callback, so throttling sends would be meaningless; the Host-side inbox limit is the only backpressure point.
|
||||
- When a terminal frame (`end` / `error`) arrives, the mux client stops the pump and closes the uplink queue immediately on the receive path, so later `send` / `end` throw without waiting for the consumer's next read.
|
||||
- `dispose()` calls `return()` on the carrier iterator, so the `cancel` frame is sent even when nobody is reading the downlink; afterwards the handle's `next()` ends immediately and buffered items are dropped. A consumer that `break`s out of `for await` early is equivalent to `dispose()`.
|
||||
- The `$stream` supervisor, `RemoteSnapshotStream`, and `RemoteJournalStream` are upper-layer tools outside the transport layer. The object their `open` factories receive is the handle itself, with unchanged `for await` behavior; exposing `send` to these upper-layer protocols is deferred.
|
||||
|
||||
### Wire frames
|
||||
|
||||
```text
|
||||
export type RemoteStreamClientMessage =
|
||||
| { readonly type: 'open'; readonly streamId: string; readonly endpoint: string; readonly payload: unknown }
|
||||
| { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } // new: uplink item
|
||||
| { readonly type: 'end'; readonly streamId: string } // new: uplink half-close
|
||||
| { readonly type: 'cancel'; readonly streamId: string }
|
||||
|
||||
/** Frames sent by the Host are unchanged. */
|
||||
export type RemoteStreamServerMessage =
|
||||
| { readonly type: 'item'; readonly streamId: string; readonly value?: unknown }
|
||||
| { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure }
|
||||
| { readonly type: 'end'; readonly streamId: string }
|
||||
```
|
||||
|
||||
Parsing rules: `item` has exactly the keys `type` and `streamId` plus an optional `value`, and `value` must be a lossless JSON value (`isRemoteJsonValue`); `end` has exactly the keys `type` and `streamId`. Any other frame is a frame-format violation, and the socket closes with 1008. No binary frames are introduced.
|
||||
|
||||
### Logical stream state machine
|
||||
|
||||
#### Host side
|
||||
|
||||
```
|
||||
open frame
|
||||
(none) ───────────────────▶ opening ──── opener resolves ────▶ running
|
||||
│ │
|
||||
│ item frame: inbox.push │ item frame: inbox.push
|
||||
│ end frame: inbox.end │ end frame: inbox.end (uplink half-close)
|
||||
│ │ method yield: sends item frame
|
||||
│ cancel frame / socket close ────▶│ cancel frame / socket close: control.abort
|
||||
▼ ▼
|
||||
aborted ◀───────────────────────── finished (sends end or error frame)
|
||||
```
|
||||
|
||||
`item` frames that arrive during `opening` enter the inbox: `receive()` is synchronous and creates the `ActiveStream` and its inbox while handling the `open` frame, whereas the opener is asynchronous. Items the Client sends immediately after `open` are not lost.
|
||||
|
||||
#### Client side
|
||||
|
||||
```
|
||||
call generated method ──▶ await socket ──▶ send open ──▶ handle usable
|
||||
│ send(v): sends item; end(): sends end
|
||||
│ downlink: inbox.next() → yield; end → ends; error → throws
|
||||
│ downlink terminates first: send/end throw
|
||||
│ dispose or caller signal abort: sends cancel (when no terminal frame has arrived)
|
||||
```
|
||||
|
||||
### Half-close and termination
|
||||
|
||||
| Uplink | Downlink | Result |
|
||||
| --- | --- | --- |
|
||||
| Client `end` | Still open | The Host's `uplink()` iteration ends; the method keeps producing. This is the stdin EOF form |
|
||||
| Still open | The Host method finishes and sends `end` | The Client handle terminates, and later `send` / `end` throw; the Host calls `return()` on the `uplink` iterator and drops unconsumed items |
|
||||
| Still open | The Host method throws and sends `error` | Same as above; the Client downlink fails with `RemoteError` |
|
||||
| Client `dispose` or signal abort | Any | Sends `cancel`; the Host runs `control.abort()`, `cancellableStream` closes `uplink` first and then calls `return()` on the method iterator; no terminal frame is sent |
|
||||
| Socket closes | Any | The Host aborts every stream and awaits `done`; on the Client every stream fails with `RemoteStreamCarrierError` |
|
||||
| `item` received after `end` | Any | The stream fails with a `gateway/protocol` error frame and aborts; the socket stays open |
|
||||
| `item` / `end` with an unknown `streamId` | Any | Ignored, like `cancel`: after the Host ends a stream and deletes its id, uplink frames still in flight from the Client are normal and must not take down the other streams on the same socket |
|
||||
|
||||
### Ordering guarantees
|
||||
|
||||
Frames on one socket arrive in total order: a logical stream's `open`, `item`…, `end` reach the Host in send order, and uplink items enter `uplink()` in the order of `send`. There is no cross-direction ordering guarantee between uplink and downlink; a protocol that needs request-response pairing carries its own sequence numbers.
|
||||
|
||||
### Backpressure
|
||||
|
||||
WebSocket has no per-stream window like HTTP/2: one connection is one serial write chain, and a slow stream holds up the other streams on the same connection. This property of the downlink is unchanged. For the uplink:
|
||||
|
||||
**A bounded inbox plus whole-stream failure.** The Host meters each logical stream's inbox in UTF-8 bytes of its frames, with the limit set by the Gateway config `streamInboxBytes` (default 262144) and applied to every stream. On overflow the whole logical stream fails with `gateway/uplink-overflow`: the downlink breaks with it, an `error` frame goes to the Client, and the `uplink()` iterator ends with the same error. No data is discarded; a method that does not read its uplink while the Client keeps sending is a usage error, and the failure exposes it explicitly.
|
||||
|
||||
Credit frames wait until a consumer that needs sustained high-volume uplink appears; the inbox limit is the initial credit, and adding them is incremental.
|
||||
|
||||
### Security and validation
|
||||
|
||||
| Item | Rule |
|
||||
| --- | --- |
|
||||
| Authentication | Same as today: `connection.requestRejection(req)` at upgrade; descriptor and lookup-parameter validation at open. Uplink items belong to an already authenticated stream and are not authenticated again |
|
||||
| Peer | Admitted at upgrade as one `PeerScope`; `invocation.peer` of every stream on that socket is that Peer |
|
||||
| Item validation | With an uplink codec, every item is decoded strictly, and a failure fails the whole stream with the existing `gateway/input-invalid` (details `{ endpoint, field: 'uplink' }`); without a codec, only JSON-safety validation applies |
|
||||
| Frame size | A single frame keeps the `maxPayload` of `ws`; the inbox limit bounds the accumulated total |
|
||||
|
||||
### In-process and worker carriers
|
||||
|
||||
| Carrier | Change |
|
||||
| --- | --- |
|
||||
| `ClientConnectionRpc.open` (`packages/client/connection/src/rpc.ts:245-250`) and `RpcStreamOpen` (`client/rpc.ts:21-25`) | Append an optional `uplink?: AsyncIterable<unknown>`; in process there are no frames and no inbox, the caller's iterator is the source of `uplink()` directly, and backpressure is the pace of the iterator itself |
|
||||
| Worker tunnel (`packages/experimental/webworker-runtime`) | The page adds `stream-uplink-item` / `stream-uplink-end` frames; `serveStream` assembles them into an `uplink` handed to `seams.openStream`. Same-origin trusted boundary: no inbox limit, and items after `end` are dropped |
|
||||
| `remote-mock` (`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable<unknown>`; `rpc.open` passes it through |
|
||||
|
||||
An in-process carrier with no `peer` means the operator.
|
||||
|
||||
### Relation to `$events`
|
||||
|
||||
`$events` is a stream on a reserved endpoint; with uplink frames, waterfall answers can become uplink items on the same `streamId`, `clientId` becomes redundant because `streamId` already identifies the generation, and `$events/result` disappears together with the race at `index.ts:527-529`. Out of scope for this Note; see Deferred.
|
||||
|
||||
## Type and wire definitions
|
||||
|
||||
### typert protocol (`packages/typert/protocol/src`)
|
||||
|
||||
```text
|
||||
// types.ts
|
||||
export type RemoteStream<Out, In = never> = AsyncIterable<Out> & { readonly [STREAM_UPLINK]?: In }
|
||||
export type PeerId = Branded<'PeerId'>
|
||||
export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise<void> }
|
||||
export interface RemoteInvocation { … } // see Host face
|
||||
declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } }
|
||||
|
||||
export interface InvocationDescriptor {
|
||||
// existing fields unchanged; mode still has only 'stream'
|
||||
readonly mode?: 'stream'
|
||||
/** Uplink item codec, generated from In of the return type RemoteStream<Out, In>; absent when In is never. */
|
||||
readonly uplink?: { readonly codec: TypertCodec }
|
||||
readonly cancellation?: { readonly parameter: 'signal' }
|
||||
readonly result: TypertCodec
|
||||
}
|
||||
```
|
||||
|
||||
The `Remote` decorator, `RemoteMethodOptions`, and `RemoteMethodMarker` recognize only `mode: 'stream'`.
|
||||
|
||||
### typert generator (`packages/typert/generator/src`)
|
||||
|
||||
- `model.ts`: `InvocationModel.uplink?: { boundary: RemoteBoundaryModel }`.
|
||||
- `analyzer.ts` `remoteResultType`: for `mode: 'stream'`, the accepted return-type wrappers are `Iterable<Out>`, `AsyncIterable<Out>`, and `RemoteStream<Out, In?>`. `RemoteStream` is recognized the same way as the standard library's `AsyncIterable`: by symbol name plus declaring file (`types.ts` of `@deepseek-ai/dsh-typert-protocol`). The first type argument is the downlink item; when the second is present and is not `never`, an `uplink` boundary is generated under the key `${endpoint}:uplink`.
|
||||
- `emitter.ts`: the descriptor literal emits `uplink: { codec }`; the generated Client signature returns `RemoteStreamHandle<Out, In>`.
|
||||
- The parameter loop recognizes no parameter named `uplink`.
|
||||
|
||||
### Two names, one entry point
|
||||
|
||||
A Host method declares a stream with `RemoteStream<Out, In>`; the Client holds a `RemoteStreamHandle<Out, In>`. Both are exported from the main entry point of `@deepseek-ai/dsh-typert-protocol`, and the handle interface has no Host dependency. The generated Client contract writes the return type as `RemoteStreamHandle<Out, In>`. One name has one meaning: the `RemoteStream` that Client code obtains from the main entry point is always the declaration type and is never confused with the handle.
|
||||
|
||||
### Gateway Host (`packages/api/gateway/src`)
|
||||
|
||||
```text
|
||||
// types.ts
|
||||
export interface InvokeRemoteRequest {
|
||||
readonly namespace: string
|
||||
readonly method: string
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/** Uplink items; absent is equivalent to an iterator that ends immediately. */
|
||||
readonly uplink?: AsyncIterable<unknown>
|
||||
/** The Peer that initiated the call; absent means the operator. */
|
||||
readonly peer?: PeerScope
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface TypertGatewayWireStream {
|
||||
open: (endpoint: string, payload: unknown, uplink: AsyncIterable<unknown>, peer: PeerScope | undefined, signal: AbortSignal) => Promise<AsyncIterable<unknown>>
|
||||
failure: (error: unknown) => RemoteStreamFailure
|
||||
}
|
||||
|
||||
// stream-server.ts
|
||||
export type RemoteStreamOpener = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
uplink: AsyncIterable<unknown>,
|
||||
peer: PeerScope,
|
||||
control: AbortController,
|
||||
) => Promise<AsyncIterable<unknown>>
|
||||
|
||||
// index.ts Config addition
|
||||
/** Uplink inbox limit per logical stream, in UTF-8 bytes of frames (default 262144). */
|
||||
readonly streamInboxBytes?: number
|
||||
```
|
||||
|
||||
`prepareInvocation`:
|
||||
|
||||
```text
|
||||
const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint)))
|
||||
const signal = request.signal ?? NEVER_ABORTED_SIGNAL
|
||||
const invocation = new GatewayInvocation(
|
||||
{ namespace: request.namespace, method: request.method, args: request.args },
|
||||
descriptor.service,
|
||||
request.peer ?? this.operatorPeer(),
|
||||
signal,
|
||||
new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control),
|
||||
)
|
||||
const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object
|
||||
if (descriptor.cancellation !== undefined) args.push(signal)
|
||||
```
|
||||
|
||||
`GatewayInvocation.uplink()` returns the decoder on the first call and throws on the second. `UplinkDecoder` is a hand-written iterator: with a codec it runs `decode(codec, value, endpoint, 'uplink')` per item and, on failure, calls `control.abort(failure)` and throws; without a codec it applies only the `isRemoteJsonValue` check. The `finally` of `cancellableStream` closes the decoder first (`invocation.close()`) and then awaits the method iterator's `return()`, so a method blocked on `uplink.next()` can exit. The mode branches are the existing two, unary and stream. SRC fallback: the descriptor has no `uplink` codec, and `uplink()` delivers `unknown`.
|
||||
|
||||
### Gateway Host mux (`stream-server.ts`)
|
||||
|
||||
```text
|
||||
interface ActiveStream { readonly control: AbortController; readonly inbox: UplinkInbox; done: Promise<void> }
|
||||
|
||||
/** Bounded uplink queue; iterated as the source of uplink(). */
|
||||
class UplinkInbox implements AsyncIterable<unknown>, AsyncIterator<unknown> {
|
||||
constructor(maxBytes: number, onOverflow: (error: RemoteError) => void)
|
||||
push(value: unknown, frameBytes: number): void // push after ended fails the stream with gateway/protocol; overflow calls onOverflow
|
||||
end(): void // idempotent
|
||||
fail(error: unknown): void // ends the iterator with an error on cancel or carrier closure
|
||||
next() / return() // hand-written: next races failure, return releases immediately
|
||||
}
|
||||
```
|
||||
|
||||
`receive(text)` dispatch:
|
||||
|
||||
```
|
||||
open : exists → throw (socket 1008); otherwise create { control, inbox, done } and start the pump
|
||||
item : missing → ignore; otherwise inbox.push(value, bytes)
|
||||
end : missing → ignore; otherwise inbox.end()
|
||||
cancel : missing → ignore; otherwise control.abort(new Error('Remote stream cancelled'))
|
||||
```
|
||||
|
||||
`pump` passes the inbox and the socket's Peer to the opener and calls `inbox.fail(...)` in its `finally`. An abort whose reason is a `RemoteError` is sent by the pump as an `error` frame (a failure raised by the Gateway or the mux); an abort whose reason is a plain `Error` (cancel, socket closure) sends no terminal frame.
|
||||
|
||||
### Gateway Client (`packages/api/gateway/src/client`)
|
||||
|
||||
- `prepareInvocation` strips no uplink parameter; the positional parameters are the business parameters plus the optional `signal`.
|
||||
- `invokeStream` returns a `RemoteStreamHandle`: it holds an uplink queue internally, `send` enqueues and a pump sends the `item` frames, `end` sends the `end` frame, and `dispose` aborts the generation signal. The downlink is still the iterator that `openRemoteStream` returns.
|
||||
- `RemoteStreamMuxClient.open(endpoint, payload, signal, uplink?)`: after sending the `open` frame it starts the uplink pump, which sends one `item` per item and `end` when the source ends; when the downlink terminates or the signal aborts, it stops the pump and calls the source's `return()` (without awaiting, so a keyboard-style generator cannot hang it); when the source throws, it calls `inbox.fail(error)`, the downlink fails with that error, and `finally` sends `cancel` under the existing rules. The pump checks `this.socket === socket`, so an old generation never sends frames on a new socket.
|
||||
- In-process carrier: `connection.rpc.open(channel, endpoint, payload, signal, uplink)`.
|
||||
|
||||
## Placement
|
||||
|
||||
| Package | Carries |
|
||||
| --- | --- |
|
||||
| `dsh-typert-protocol` (`types.ts`, `index.ts`) | `RemoteStream`, `RemoteStreamHandle`, `PeerId`, `PeerScope`, `RemoteInvocation`, the `ctx.invocation` declaration merge, `InvocationDescriptor.uplink`; the decorator recognizes only `mode: 'stream'`; `TypertRemoteService` registers the `invocation` accessor when constructed |
|
||||
| `dsh-typert-registry` | Load-time validation that `uplink.codec` is a valid strict codec |
|
||||
| `dsh-typert-generator` (`model.ts`, `analyzer.ts`, `emitter.ts`) | `remoteResultType` recognizes `RemoteStream<Out, In>` and generates the `uplink` boundary; the descriptor emits `uplink: { codec }`; the generated Client signature returns `RemoteStreamHandle<Out, In>`; the parameter loop accepts only business parameters and a final `signal` |
|
||||
| `dsh-client-connection` (`operator-peer.ts`, `rpc.ts`, `rpc-host.ts`, `index.ts`) | The operator `PeerScope`; `connection.operator` and `admit`; the `/api` and upgrade routes obtain the Peer through `admit`; the optional `uplink` of `rpc.open` |
|
||||
| `dsh-api-gateway` Host (`stream-protocol.ts`, `stream-server.ts`, `types.ts`, `index.ts`) | `item` / `end` frames; `UplinkInbox`; `Config.streamInboxBytes`; `GatewayInvocation` and `extend({ invocation })`; `UplinkDecoder`; `handleUpgrade(req, socket, head, peer)`; `operatorPeer()` |
|
||||
| `dsh-api-gateway` Client (`client/index.ts`, `client/stream-client.ts`) | `invokeStream` returns the handle; the uplink pump is driven by the handle's queue |
|
||||
| `dsh-webworker-runtime`, `dsh-remote-mock` | The worker tunnel's two uplink frames and the `serveStream` assembly; `StreamHandle.uplink`; the direct proxy returns the real handle |
|
||||
| Documentation | The READMEs of gateway, the api group, connection, protocol, and generator; `docs/api-gateway`; the type-equiv blocks of `docs/subsystems/typert`; the config and Cordis catalogs |
|
||||
|
||||
## Key sequences
|
||||
|
||||
### Open, uplink, half-close, downlink end
|
||||
|
||||
```
|
||||
Client caller mux client mux server gateway Host method
|
||||
│ attach(req) │ │ │ │
|
||||
│───────────────────▶│ open { id, ep, payload } ──────────────────▶│ create inbox │
|
||||
│ s.send(v1) │ item { id, v1 } ───────────────────────────▶│ inbox.push(v1) │
|
||||
│ │ │ │ prepareInvocation │
|
||||
│ │ │ │ extend({ invocation }) │
|
||||
│ │ │ │── method(req, signal) ─▶│
|
||||
│ │ │ │ │ uplink() → v1
|
||||
│ s.send(v2) │ item { id, v2 } ───────────────────────────▶│ inbox.push(v2) │ → v2
|
||||
│ s.end() │ end { id } ────────────────────────────────▶│ inbox.end() │ iteration ends
|
||||
│◀── yield o1 ───────│◀────────────────────── item { id, o1 } ─────│◀── yield o1 ────────────│
|
||||
│◀── ends ───────────│◀────────────────────── end { id } ──────────│◀── method returns ──────│ gateway calls uplink.return()
|
||||
```
|
||||
|
||||
### Client dispose
|
||||
|
||||
```
|
||||
Client caller mux client mux server gateway Host method
|
||||
│ s.dispose() │ │ │ │
|
||||
│───────────────────▶│ stop pump; source return() (not awaited) │ │
|
||||
│◀── iterator ends ──│ cancel { id } ─────────────────────────────▶│ control.abort() │
|
||||
│ │ │ │ cancellableStream: invocation.close() closes uplink
|
||||
│ │ │ │ iterator.return() ─────▶│ finally cleanup
|
||||
│ │ │ no terminal frame │ │
|
||||
```
|
||||
|
||||
### inbox overflow
|
||||
|
||||
```
|
||||
Client caller mux client mux server gateway / Host method
|
||||
│ s.send(vN) │ item { id, vN } ─────▶│ inbox.push: bytes > streamInboxBytes │
|
||||
│ │ │ onOverflow → control.abort(RemoteError uplink-overflow)
|
||||
│ │ │ │ uplink() ends with that error; method iterator return()
|
||||
│◀── throw ──────────│◀──────────────────────│ error { id, uplink-overflow }: the pump sends an error frame because the reason is a RemoteError
|
||||
```
|
||||
|
||||
## Boundary matrix
|
||||
|
||||
| Event | opening | running | Uplink ended | Downlink terminated |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `item` received | Buffered in the inbox | Into the inbox | The stream fails with `gateway/protocol` | Ignored |
|
||||
| `end` received | inbox.end | inbox.end | Idempotent | Ignored |
|
||||
| `cancel` received | abort | abort | abort | Ignored |
|
||||
| inbox overflow | The stream fails with `uplink-overflow` | Same | Impossible | Impossible |
|
||||
| Decode failure | Impossible | The stream fails with `input-invalid` | Same | Impossible |
|
||||
| Host method finishes | Impossible | Sends `end`; `uplink.return()` | Sends `end` | Already sent |
|
||||
| Host method throws | Sends `error` | Sends `error`; `uplink.return()` | Sends `error` | Already sent |
|
||||
| Socket closes | abort, await done | Same | Same | Nothing |
|
||||
| Client `send` | Queued, sent after open | Sent | Throws | Throws |
|
||||
| Client `end` | Recorded, sent after open | Sent | Idempotent | Ignored |
|
||||
| Client-side downlink ends first | Impossible | Stop the pump; source `return()`; no `end` sent | Nothing | Nothing |
|
||||
|
||||
## Error codes
|
||||
|
||||
| Code | When |
|
||||
| --- | --- |
|
||||
| `gateway/input-invalid` | Existing: a parameter codec fails to decode; new: an uplink item fails to decode |
|
||||
| `gateway/uplink-overflow` | New: the inbox exceeds `streamInboxBytes` |
|
||||
| `gateway/protocol` | New: an `item` after `end` |
|
||||
| `gateway/cancelled` | Existing: `signal` aborted |
|
||||
|
||||
New codes are registered in `packages/api/gateway/src/remote-error-codes.ts` and in the README's error-code section.
|
||||
|
||||
## Minimal echo method
|
||||
|
||||
`packages/api/gateway/tests/echo-stream.host.spec.ts` runs the same method through the WebSocket carrier and through the in-process carrier:
|
||||
|
||||
```text
|
||||
class EchoService extends TypertRemoteService {
|
||||
constructor(ctx: Context) { super(ctx, 'echo', { namespace: 'echo' }) }
|
||||
|
||||
@Remote({ mode: 'stream' })
|
||||
async *echo(prefix: string, signal: AbortSignal): RemoteStream<string, string> {
|
||||
for await (const item of this.ctx.invocation!.uplink<string>()) {
|
||||
signal.throwIfAborted()
|
||||
yield `${prefix}${item}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Client
|
||||
const stream = remote.echo.echo('> ')
|
||||
stream.send('a'); stream.send('b'); stream.end()
|
||||
const replies: string[] = []
|
||||
for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
```
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A separate `mode: 'duplex'` plus a reserved parameter `uplink: AsyncIterable<In>` alongside `signal`.** Special logic everywhere: the analyzer must recognize a second reserved parameter and its position rules, the generated signature gains a parameter, load time must validate `duplex ⟺ uplink`, and because the mux does not know the method mode, the Gateway must reject uplink frames on behalf of `'stream'` methods. Duplex is an inherent property of the transport-layer stream, not a mode of the method: the carrier already has `streamId`, `open`, and `cancel`; the uplink adds just two more frames, which any stream can receive and which are simply not sent when unused.
|
||||
|
||||
**Declaring the uplink type in a decorator option, `@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`.** A string reference to a type name is not checked by the compiler and breaks on rename. The return-type alias puts both types in one compiler-visible place.
|
||||
|
||||
**Passing a schema at runtime: `uplink(schema)`.** Bypasses the generator, the Client loses type projection, and it splits from the "strict codec per item" system. The codec still comes from the generator; the generic in the method is only an assertion.
|
||||
|
||||
**Having the analyzer cross-check the hand-written generic of `uplink<In>()` against the return type.** A business-specific check whose mistakes the caller bears, consistent with the other hand-written assertions in the repository; not added.
|
||||
|
||||
**A transport-layer handle with reopening, cursors, and a `reopen` hook.** Reopening after disconnection belongs to the upper-layer protocol: a job resumes through an `opened` frame carrying `from` and a client that remembers `next` and calls again; snapshot and journal each have their own anchors and classification. The transport layer knows one generation.
|
||||
|
||||
**Putting the Client's `send` on the `AsyncIterable` the generated method returns.** Hanging methods on an `AsyncIterable` is unconventional; the handle type `RemoteStreamHandle` extends it explicitly.
|
||||
|
||||
**Copying the Access mechanism.** Access control is business semantics, served by `vouch` and the `remote/invoke` waterfall; this Note only needs "whose stream is this", and `PeerScope` suffices.
|
||||
|
||||
**Keeping unary RPC for the uplink.** One HTTP round trip, one authentication, and one lookup per uplink; two carriers with no ordering relation that only Client-side serialization can order; no EOF; cancellation that does not cover both halves; a hand-made correlation key per feature.
|
||||
|
||||
**A native HTTP request body stream.** A one-shot request body with no association to the downlink stream, bypassing Typert descriptors, and browser support for full-duplex fetch cannot be relied on.
|
||||
|
||||
**One dedicated WebSocket per duplex stream.** Loses the authentication, heartbeat, and multiplexing shared with the existing downlink streams, and every feature manages its own socket lifecycle.
|
||||
|
||||
**WebTransport or HTTP/2 bidirectional streams.** They have per-stream flow control, but support on the browser and Node sides, proxy traversal, and the Electron carrier are all immature; adding two frames to the existing mux is orders of magnitude cheaper than replacing the transport layer.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Bought**: one stream with two directions, so interactive scenarios such as terminal keystrokes, approval answers, and background-job stdin no longer each build their own "one stream plus one unary"; a Host method knows its caller; the uplink and downlink types are declared in one place, the return type; uplink items come from the browser and are validated strictly per item at the Host, while downlink items are Host-produced typed values that pass straight through; the generated output and wire frames of existing `AsyncIterable<Out>` methods and unary methods are completely unchanged.
|
||||
- **`this.ctx.invocation` is `undefined` outside a Remote call**, so a method that reads it must handle the optional; a service method invoked only directly in process correctly reads `undefined`.
|
||||
- **The inbox limit applies to every stream**: a method that does not read its uplink fails as a whole when it receives many uplink frames. This is a deliberate explicit failure, recorded in the README.
|
||||
- **The operator is the only Peer**, and `admit` is the only admission point; the first consumer that needs a second Peer must add opening, association, and events in `dsh-client-connection`.
|
||||
- **No cross-direction ordering guarantee between uplink and downlink**; a protocol that needs request-response pairing carries its own sequence numbers.
|
||||
- **Upper-layer protocols do not yet expose `send`**: consumers of `$stream`, snapshot, and journal use only the downlink today; the object they receive is already the handle.
|
||||
- **The worker tunnel behaves differently from the WebSocket carrier**: no inbox limit, and items after `end` are dropped, because page to worker is a same-origin trusted boundary.
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/api/gateway/tests/echo-stream.host.spec.ts`: the same echo method passes through the WebSocket carrier and through the in-process carrier.
|
||||
- Gateway Host: exact-key validation in frame parsing; `UplinkInbox` buffering during opening, `item` after `end`, overflow, ignored unknown `streamId`, and cleanup on socket closure; `UplinkDecoder` delivery with and without a codec and abort on decode failure; `uplink()` taken only once; the `extend({ invocation })` view and the origin of `peer`; `cancellableStream` closing the uplink before `return()`.
|
||||
- Gateway Client: the handle's `send` (JSON validation, queued before open, throws after termination), idempotent `end`, `dispose` driving the `cancel` frame and ending later reads; the pump's three exits; the pump stopping as soon as a terminal frame arrives.
|
||||
- typert: the analyzer's handling of `RemoteStream<Out, In>`, `RemoteStream<Out>`, `never`, and the existing `AsyncIterable<Out>`; decorator options; registry load-time validation.
|
||||
- connection: the three outcomes of `admit`, 401, 403, and the operator; the operator reaching handlers through the bridge and the shared handler; release with the Connection.
|
||||
- remote-mock and the worker tunnel: uplink pass-through; the direct proxy returning the real handle.
|
||||
- Existing stream and unary method tests pass without changed expectations.
|
||||
|
||||
## Deferred
|
||||
|
||||
- `$stream` / snapshot / journal expose the handle's `send` to upper-layer protocols.
|
||||
- Web terminal: one `attach` stream replaces `follow` plus `write` plus `resize`, and `attachmentId` retires.
|
||||
- Duplex `$events`: waterfall answers become uplink items on the same `streamId`, and `$events/result` and `clientId` retire.
|
||||
- Credit frames: wait for a consumer that needs sustained high-volume uplink; the inbox limit is the initial credit.
|
||||
- The `streamInboxBytes` default of 262144 is far more than keyboard and stdin need; if the first consumer is terminal paste, it may be aligned with the `maxInputBytes` of `terminal-controller`.
|
||||
- A Host-initiated half-close frame (the Host stops reading the uplink but keeps writing the downlink): no consumer; not added.
|
||||
- `uplink()` on unary methods: it can read items that arrive while the method runs, semantically valid but of doubtful use; allowed, noted in the README.
|
||||
|
||||
## Related
|
||||
|
||||
- [Remote event delivery](2026-08-10-remote-event-delivery.md): the unary `$events/result` answer path; the duplex `$events` item under Deferred partially supersedes it once it lands.
|
||||
- [Session history and event transport](2026-08-18-session-history-and-event-transport.md): the `$stream()` supervisor and the journal / snapshot protocols above it, which this note leaves unchanged.
|
||||
- [Web sidebar terminal](../feature/2026-09-09-web-sidebar-terminal.md): the unary terminal `write` and `attachmentId`; the `attach` stream under Deferred replaces it once it lands.
|
||||
@@ -0,0 +1,597 @@
|
||||
# Agent Note: Remote 双工流:一种流、上行通道与调用上下文
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-19-remote-duplex-stream.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
### 现状
|
||||
|
||||
`dsh-api-gateway` 在一条 WebSocket 上复用全部 Typert Remote 流,路径固定为 `/api/remote.mux`(`packages/api/gateway/src/stream-protocol.ts`)。一个 Host 方法用 `@Remote({ mode: 'stream' })` 修饰并返回 `Iterable` 或 `AsyncIterable`,就成为一条 Host 到 Client 的逻辑流;可选的末位 `signal: AbortSignal` 是唯一的保留参数,不进线路参数,由网关在解码后的业务参数之后追加(`packages/api/gateway/src/index.ts`)。客户端拿到的生成方法返回一个裸 `AsyncIterable`。
|
||||
|
||||
线路帧只有五种:
|
||||
|
||||
| 方向 | 帧 | 定义 |
|
||||
| --- | --- | --- |
|
||||
| Client 到 Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` |
|
||||
| Client 到 Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` |
|
||||
| Host 到 Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` |
|
||||
| Host 到 Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` |
|
||||
| Host 到 Client | `{ type: 'end', streamId }` | `stream-protocol.ts` |
|
||||
|
||||
客户端能发的只有打开与取消。解析器 `parseRemoteStreamClientMessage`(`stream-protocol.ts`)对其他任何帧抛错,Host 连接收到后以 1008 关闭整个 socket(`stream-server.ts`)。结论:网关今天只支持"后端 iterable 发到前端"。需要前端向后端持续发数据的功能,各自绕路。
|
||||
|
||||
Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteRequest = { namespace, method, args, signal }`(`packages/api/gateway/src/types.ts`)没有调用者槽位,`RemoteStreamOpener` 的签名是 `(endpoint, payload, signal)`(`stream-server.ts`),WebSocket 只在握手时校一次 cookie。
|
||||
|
||||
### 三套各自为政的上行机制
|
||||
|
||||
| 功能 | 上行方式 | 位置 | 代价 |
|
||||
| --- | --- | --- | --- |
|
||||
| Web 终端按键 | 每次 xterm `onData` 一个 unary RPC `terminal.write(agent, id, attachmentId, data)`,客户端用 promise 链串行化并自限字节预算 | `packages/api/terminal-controller/src/client/model.ts`;Host `src/index.ts` | 一次按键一次 HTTP 往返与鉴权;两条载体靠 `attachmentId` 手工关联;满了报 `inputFull` |
|
||||
| 文件上传 | `Blob` 或 `ReadableStream` 走 Remote 层之外的原生 `POST /api/session/uploadFileBinary`,`duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`;路由 `src/index.ts` | 不经 Typert 描述符,没有类型投影 |
|
||||
| 审批与提问的回答 | Host 在 `$events` 流上下发 waterfall 帧,Client 用一个独立的 unary RPC `$events/result` 回答,靠 `clientId` 加 `eventId` 关联 | `packages/api/gateway/src/client/remote-events.ts`;Host `index.ts` | 第二套关联键;`index.ts` 记录了由此产生的竞态 |
|
||||
|
||||
这三套机制解决的是同一个问题:一条已经打开的逻辑流,客户端想往里写。
|
||||
|
||||
### 具体代价
|
||||
|
||||
- **两条载体对齐**。终端要用 `attachmentId` 把 `follow` 流与 `write` RPC 关联起来,并用它决定谁拥有输入权。审批要用 `clientId` 把 `$events` 流与 `$events/result` RPC 关联起来。
|
||||
- **没有顺序保证**。unary RPC 走 HTTP,与 WebSocket 下行互不排序;终端只能在客户端串行化写入来自保顺序。
|
||||
- **每次上行单独鉴权与查找**。`write(agent, id, attachmentId, data)` 每次解析 `agent`、查终端、校验 attachment。
|
||||
- **没有 EOF**。客户端无法表达"我这边写完了",半关闭要再加一个 RPC。
|
||||
- **取消不覆盖两半**。取消下行流不会取消排队中的上行写;反之亦然。
|
||||
- **背压各写一套**。下行按 socket 写回调节拍拉迭代器(`stream-server.ts`);终端用有界 follower 让慢消费者显式失败(`terminal-controller/src/stream.ts`);上行靠客户端自限 `maxInputBytes`。
|
||||
- **文档已经过时**。`docs/api-gateway.md:160` 仍写着 Remote 只处理一请求一结果,这一句已经落后于 README 记录的 stream 模式。
|
||||
|
||||
### 与后台 job 的关系
|
||||
|
||||
后台 job 的进程形双工流面(已存档为草稿 PR,分支 `worktree/job-stdio-stream-pair-archive`)把 job 建成一对镜像的流面,浏览器要接它就需要一条既下行又上行的 Remote 流。它是本 Note 的动机之一,但不是本 Note 的范围:job 要不要一种专用的读写流,是 jobs 那条线自己的架构问题。本 Note 只覆盖传输层。
|
||||
|
||||
### 目标
|
||||
|
||||
1. 传输层只有一种流,天生双工:下行 `item` / `end` / `error`,上行 `item` / `end`,一个 `streamId`,一个代际。
|
||||
2. 上行类型与下行类型写在同一个位置:方法的返回类型。
|
||||
3. Host 方法通过调用上下文取上行;调用上下文同时携带调用者(Peer)。
|
||||
4. 客户端拿到的不再是裸 `AsyncIterable`,而是一个能读、能写、能关的句柄。
|
||||
5. 上层协议(snapshot、journal、任何业务自定义的流)不进传输层。
|
||||
|
||||
## 决定
|
||||
|
||||
### 一句话
|
||||
|
||||
所有 Remote 流都允许客户端上行。方法的返回类型 `RemoteStream<Out, In = never>` 同时声明下行项与上行项的类型;生成器从中生成两个 codec。Host 方法通过 `this.ctx.invocation.uplink<In>()` 取上行迭代器,通过 `this.ctx.invocation.peer` 知道调用者。客户端生成方法返回 `RemoteStreamHandle<Out, In>`:`for await` 读下行,`send` / `end` 写上行,`dispose` 关。断线重开、游标续传、基线校验都是上层协议的事。
|
||||
|
||||
```
|
||||
Client Host
|
||||
───────────────────────────────── ───────────────────────────────────────────
|
||||
const s = remote.job.attach(req, signal)
|
||||
│ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control)
|
||||
│ │ prepareInvocation
|
||||
│ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() }
|
||||
│ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service)
|
||||
│ s.end() → end { streamId } ───────▶ inbox.end() │ method(...args, signal)
|
||||
│ ◀─────── item { streamId, o1 } │ for await (v of this.ctx.invocation.uplink()) …
|
||||
│ for await (o of s) … ◀─────── item { streamId, o2 } │ yield o …
|
||||
│ s.dispose() → cancel { streamId } ───────▶ control.abort() │ signal aborts; uplink.return()
|
||||
│ ◀─────── end | error
|
||||
```
|
||||
|
||||
### 术语
|
||||
|
||||
| 术语 | 含义 |
|
||||
| --- | --- |
|
||||
| 逻辑流 | 一个 `streamId` 标识的一次调用,跨越 `open` 到终止帧 |
|
||||
| 代际 | 一条逻辑流的一次物理生命期。传输层只认一个代际;断线即失败,重开是上层的事 |
|
||||
| 下行 | Host 到 Client 的 `item` 序列,以 `end` 或 `error` 终止 |
|
||||
| 上行 | Client 到 Host 的 `item` 序列,以 `end` 终止(半关闭),或随 `cancel` 一起结束 |
|
||||
| 半关闭 | 一个方向已结束而另一个方向仍开放 |
|
||||
| 项 | 一个方向上的一个业务值;每项独立经 codec 校验 |
|
||||
| inbox | Host 侧每逻辑流一个的有界上行队列 |
|
||||
| Peer | 连接层被接纳的一方,一个 `PeerScope`;本 Host 只有操作者一个 Peer |
|
||||
| 调用上下文 | `RemoteInvocation`:本次调用的请求、接收服务、Peer、取消信号与上行入口 |
|
||||
|
||||
### 类型签名
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-typert-protocol
|
||||
/**
|
||||
* One Remote stream. Host face: the method returns it, and at runtime it is AsyncIterable<Out>.
|
||||
* On the Client face the generated method returns RemoteStreamHandle<Out, In>; each name has exactly one meaning.
|
||||
* In is the item type the Client may send uplink; the default never means the method reads no uplink.
|
||||
*/
|
||||
export type RemoteStream<Out, In = never> = AsyncIterable<Out> & { readonly [STREAM_UPLINK]?: In }
|
||||
|
||||
// Host
|
||||
@Remote({ mode: 'stream' })
|
||||
async *attach(request: JobAttachRequest, signal: AbortSignal): RemoteStream<JobFollowFrame, JobInputFrame> {
|
||||
const uplink = this.ctx.invocation.uplink<JobInputFrame>()
|
||||
void (async () => { for await (const frame of uplink) … })()
|
||||
yield …
|
||||
}
|
||||
|
||||
// Generated Client signature: same parameters, returns the handle
|
||||
attach(request: JobAttachRequest, signal?: AbortSignal): RemoteStreamHandle<JobFollowFrame, JobInputFrame>
|
||||
|
||||
// Client usage
|
||||
const stream = remote.job.attach(req, signal)
|
||||
for await (const frame of stream) …
|
||||
stream.send(frame) // typed as JobInputFrame
|
||||
stream.end()
|
||||
stream.dispose()
|
||||
```
|
||||
|
||||
`RemoteStream` 是别名而不是接口,是刻意的:将来若把传输层做成 Node 流形态,只换别名的定义与两端的适配,方法签名、描述符、上层协议都不动。
|
||||
|
||||
### Host 面
|
||||
|
||||
```text
|
||||
/** The context of this Remote call. A method reads it through this.ctx.invocation. */
|
||||
export interface RemoteInvocation {
|
||||
readonly request: {
|
||||
readonly namespace: string
|
||||
readonly method: string
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
}
|
||||
/** Cordis service key of the receiving service. */
|
||||
readonly service: string
|
||||
/** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */
|
||||
readonly peer: PeerScope
|
||||
/** Carrier cancellation: Client cancel, socket closure, uplink failure. */
|
||||
readonly signal: AbortSignal
|
||||
/**
|
||||
* The uplink items of this call. Can be taken once; the second call throws. With an uplink codec
|
||||
* in the descriptor each item is decoded to In; without one, items are delivered as unknown with
|
||||
* only a JSON-safety check.
|
||||
* The iteration ends after the Client's end; when the method ends the downlink, the Gateway calls
|
||||
* its return() and drops unconsumed items.
|
||||
* The generic In is only the caller's type assertion; runtime decodes per the descriptor and does not cross-check.
|
||||
*/
|
||||
uplink<In = unknown>(): AsyncIterable<In>
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** The Remote call this Context was derived for; undefined on a Context not derived from a Remote call. */
|
||||
readonly invocation: RemoteInvocation | undefined
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
1. 任何 `mode: 'stream'` 方法都可以调用 `this.ctx.invocation.uplink()`;unary 方法也可以,但它的流在方法返回时结束,上行项只在方法运行期间可读。
|
||||
2. `uplink()` 未被调用时,上行项在 inbox 里攒着,直到超限(见背压)或流结束被丢弃。这是刻意的:不读上行的方法不用知道上行存在。
|
||||
3. `In` 缺省为 `never` 时描述符没有 uplink codec;客户端句柄的 `send` 类型为 `never`,编译期就不能发。运行时若仍有帧到达(手工构造的客户端),按 `unknown` 交付。
|
||||
4. `this.ctx.invocation` 在非 Remote 调用的 Context 上是 `undefined`;方法内读它时按 TS 的可选处理,或在方法第一行断言。该属性的 accessor 由 `TypertRemoteService` 基类构造时注册一次,任何装有 Remote 服务的组合都能读它。
|
||||
|
||||
#### 调用上下文如何到达方法
|
||||
|
||||
机制依赖 Cordis 的两条既有行为:
|
||||
|
||||
- `ctx.extend(meta)` 是 `Object.create(parent)` 加自有属性(`vendor/cordis/src/context.ts:99-107`),无注册、无 fiber、不需释放,每次调用派生一个是零成本的。
|
||||
- `ctx.get(service)` 返回 `createTraceable(ctx, service)` 代理(`vendor/cordis/src/reflect.ts:233-234`,`utils.ts:117-125`)。读 `ctx` 属性直接返回访问方的 Context(`utils.ts:175`);读方法时用 `createShadowMethod` 包一层,`apply` 时把 `this` 换成 shadow receiver,shadow 上的 `ctx` 是 `ctx.extend({ [symbols.shadow]: origin })`(`utils.ts:149-163`)。
|
||||
- 网关因此写 `receiverContext.extend({ invocation }).get(descriptor.service)` 取一个调用视图,`Reflect.apply(method, callReceiver, args)`;方法体里 `this.ctx.invocation` 沿原型链读到本次调用。async generator 体延后执行不受影响,因为 `this` 在调用时已绑定为 shadow 对象。
|
||||
|
||||
### PeerScope
|
||||
|
||||
```text
|
||||
/** The opaque identity of one Peer. */
|
||||
export type PeerId = Branded<'PeerId'>
|
||||
|
||||
/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */
|
||||
export interface PeerScope {
|
||||
readonly id: PeerId
|
||||
readonly ctx: Context
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
本 Host 只有一个 Peer:操作者。`@deepseek-ai/dsh-client-connection` 在服务 apply 时用 `createScope(connectionCtx, peer)` 建它,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,`dispose()` 随 connection 释放让 fiber 静默。它以 `connection.operator` 暴露。
|
||||
|
||||
| 成员 | 语义 |
|
||||
| --- | --- |
|
||||
| `connection.operator: PeerScope` | 唯一的 Peer,随 connection 生灭 |
|
||||
| `connection.admit(request): PeerAdmission` | 过既有的 `requestRejection`:被拒返回 `{ rejection: 401 \| 403 }`,否则返回 `{ peer: operator }`。将来的接纳方在这里接入 |
|
||||
|
||||
网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener,并在 `peer.ctx.effect` 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。`InvokeRemoteRequest.peer?` 与 `RemoteStreamOpener` 的 `peer` 形参缺席时按操作者作答,现有进程内调用方零改动;没有 connection 的组合里网关自建的操作者替身同样经 `createScope` 建立,`dispose()` 契约一致。按 id 查 Peer、把载体关联到第二个 Peer、Peer 的开关事件,都等第一个需要多 Peer 的消费者出现时再加。
|
||||
|
||||
Peer 不携带访问模型:Peer 是谁、能做什么,由业务插件通过 `peer.ctx` 自行附加。
|
||||
|
||||
### Client 面
|
||||
|
||||
```text
|
||||
/** The handle a generated method returns on the Client face. A different name from the Host's RemoteStream, exported from the same entry point. */
|
||||
export interface RemoteStreamHandle<Out, In> extends AsyncIterable<Out> {
|
||||
/** Send one uplink item. Throws on a value that is not lossless JSON, after termination, or after end. */
|
||||
send(item: In): void
|
||||
/** Half-close the uplink: sends the end frame. Idempotent. */
|
||||
end(): void
|
||||
/** Cancel the whole logical stream: sends the cancel frame (when no terminal frame has arrived); the downlink iterator ends quietly. */
|
||||
dispose(): void
|
||||
}
|
||||
```
|
||||
|
||||
- 句柄代表一个代际。载体丢失时 `for await` 以 `RemoteStreamCarrierError` 失败,句柄到此结束;要不要重开、带什么参数重开,是上层协议的事。
|
||||
- `send` 是同步的:入队前做 `isRemoteJsonValue` 校验,非无损 JSON 值同步抛错。浏览器 `WebSocket.send` 没有写回调,发送节流没有意义;Host 侧的 inbox 上限是唯一的背压点。
|
||||
- 收到终止帧(`end` / `error`)时,mux 客户端在收帧路径上立即停泵并关闭上行队列,之后 `send` / `end` 抛错,不等消费者下一次读。
|
||||
- `dispose()` 调用载体迭代器的 `return()`,让 `cancel` 帧在无人读下行时也发出;之后句柄的 `next()` 直接结束,缓冲项丢弃。消费者提前 `break` 出 `for await` 等价于 `dispose()`。
|
||||
- `$stream` 监督器、`RemoteSnapshotStream`、`RemoteJournalStream` 是上层工具,不在传输层内。它们的 `open` 工厂拿到的对象现在就是句柄,`for await` 行为不变;把 `send` 透出到这些上层协议是后续的事。
|
||||
|
||||
### 线路帧
|
||||
|
||||
```text
|
||||
export type RemoteStreamClientMessage =
|
||||
| { readonly type: 'open'; readonly streamId: string; readonly endpoint: string; readonly payload: unknown }
|
||||
| { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } // new: uplink item
|
||||
| { readonly type: 'end'; readonly streamId: string } // new: uplink half-close
|
||||
| { readonly type: 'cancel'; readonly streamId: string }
|
||||
|
||||
/** Frames sent by the Host are unchanged. */
|
||||
export type RemoteStreamServerMessage =
|
||||
| { readonly type: 'item'; readonly streamId: string; readonly value?: unknown }
|
||||
| { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure }
|
||||
| { readonly type: 'end'; readonly streamId: string }
|
||||
```
|
||||
|
||||
解析规则:`item` 精确键 `type` `streamId` 加可选 `value`,`value` 须为无损 JSON 值(`isRemoteJsonValue`);`end` 精确键 `type` `streamId`。其余形状是帧格式违规,socket 以 1008 关闭。不引入二进制帧。
|
||||
|
||||
### 逻辑流状态机
|
||||
|
||||
#### Host 侧
|
||||
|
||||
```
|
||||
open frame
|
||||
(none) ───────────────────▶ opening ──── opener resolves ────▶ running
|
||||
│ │
|
||||
│ item frame: inbox.push │ item frame: inbox.push
|
||||
│ end frame: inbox.end │ end frame: inbox.end (uplink half-close)
|
||||
│ │ method yield: sends item frame
|
||||
│ cancel frame / socket close ────▶│ cancel frame / socket close: control.abort
|
||||
▼ ▼
|
||||
aborted ◀───────────────────────── finished (sends end or error frame)
|
||||
```
|
||||
|
||||
`opening` 期间到达的 `item` 帧进入 inbox:`receive()` 是同步的,`open` 帧处理时就创建 `ActiveStream` 与 inbox,而 opener 是异步的。客户端在 `open` 之后立刻发的项不会丢。
|
||||
|
||||
#### Client 侧
|
||||
|
||||
```
|
||||
call generated method ──▶ await socket ──▶ send open ──▶ handle usable
|
||||
│ send(v): sends item; end(): sends end
|
||||
│ downlink: inbox.next() → yield; end → ends; error → throws
|
||||
│ downlink terminates first: send/end throw
|
||||
│ dispose or caller signal abort: sends cancel (when no terminal frame has arrived)
|
||||
```
|
||||
|
||||
### 半关闭与结束语义
|
||||
|
||||
| 上行 | 下行 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 客户端 `end` | 仍开放 | Host 的 `uplink()` 迭代结束;方法继续产出。这是 stdin EOF 的形状 |
|
||||
| 仍开放 | Host 方法结束,发 `end` | 客户端句柄终止,之后 `send` / `end` 抛错;Host 调用 `uplink` 迭代器的 `return()`,未消费项丢弃 |
|
||||
| 仍开放 | Host 方法抛错,发 `error` | 同上;客户端下行以 `RemoteError` 失败 |
|
||||
| 客户端 `dispose` 或 signal 中止 | 任意 | 发 `cancel`;Host `control.abort()`,`cancellableStream` 先关 `uplink`,再调方法迭代器的 `return()`;不发终止帧 |
|
||||
| socket 关闭 | 任意 | Host 中止全部流并等待 `done`;客户端每条流以 `RemoteStreamCarrierError` 失败 |
|
||||
| `end` 之后又收到 `item` | 任意 | 该流以 `gateway/protocol` 错误帧失败并中止;socket 不关闭 |
|
||||
| 未知 `streamId` 的 `item` / `end` | 任意 | 忽略,与 `cancel` 相同:Host 结束流并删除 id 后,客户端在途的上行帧属正常现象,不能连累同一 socket 上的其他流 |
|
||||
|
||||
### 顺序保证
|
||||
|
||||
同一条 socket 上的帧全序到达:一条逻辑流的 `open`、`item`…、`end` 按发送顺序到达 Host;上行项进入 `uplink()` 的顺序就是 `send` 的顺序。上行与下行之间没有跨方向顺序保证;需要请求应答配对的协议自带序号。
|
||||
|
||||
### 背压
|
||||
|
||||
WebSocket 没有 HTTP/2 那样的每流窗口,一个连接一条串行写链,慢流会拖住同连接的其他流。下行的这一性质不变。上行:
|
||||
|
||||
**有界 inbox 加整流失败。** Host 每条逻辑流的 inbox 按帧的 UTF-8 字节计量,上限为网关配置 `streamInboxBytes`(默认 262144),对所有流生效。超限时整条逻辑流以 `gateway/uplink-overflow` 失败:下行一起断,向客户端发 `error` 帧,`uplink()` 迭代器以同一错误结束。数据不丢弃;一个方法不读上行而客户端持续发,是用法错误,由失败显式暴露。
|
||||
|
||||
credit 帧留待需要持续大流量上行的消费者出现时再加;inbox 上限就是初始授信,加法是增量。
|
||||
|
||||
### 安全与校验
|
||||
|
||||
| 项 | 规则 |
|
||||
| --- | --- |
|
||||
| 鉴权 | 与今天相同:升级时 `connection.requestRejection(req)`;打开时描述符与查找参数校验。上行项属于一条已鉴权的流,不重复鉴权 |
|
||||
| Peer | 升级时接纳为一个 `PeerScope`,该 socket 上所有流的 `invocation.peer` 都是它 |
|
||||
| 项校验 | 有 uplink codec 时逐项严格解码,失败即整条流以既有的 `gateway/input-invalid` 失败(details `{ endpoint, field: 'uplink' }`);无 codec 时只做 JSON 安全校验 |
|
||||
| 帧大小 | 单帧沿用 `ws` 的 `maxPayload`;inbox 上限限制累计量 |
|
||||
|
||||
### 进程内与 worker 载体
|
||||
|
||||
| 载体 | 改为 |
|
||||
| --- | --- |
|
||||
| `ClientConnectionRpc.open`(`packages/client/connection/src/rpc.ts:245-250`)与 `RpcStreamOpen`(`client/rpc.ts:21-25`) | 追加可选 `uplink?: AsyncIterable<unknown>`;进程内没有帧也没有 inbox,调用方迭代器直接作为 `uplink()` 的源,背压就是迭代器本身的节拍 |
|
||||
| worker 隧道(`packages/experimental/webworker-runtime`) | 页面新增 `stream-uplink-item` / `stream-uplink-end` 帧;`serveStream` 把它们组装成 `uplink` 交给 `seams.openStream`。同源可信边界:没有 inbox 上限,`end` 之后的项丢弃 |
|
||||
| `remote-mock`(`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable<unknown>`;`rpc.open` 透传 |
|
||||
|
||||
进程内载体的 `peer` 缺席即操作者。
|
||||
|
||||
### 与 `$events` 的关系
|
||||
|
||||
`$events` 是一条保留端点的流;有了上行帧,waterfall 的回答可以成为同一 `streamId` 上的上行项,`clientId` 因 `streamId` 已标识代际而多余,`$events/result` 与 `index.ts:527-529` 的竞态一起消失。不在本 Note 范围,见「后续」。
|
||||
|
||||
## 类型与线路定义
|
||||
|
||||
### typert protocol(`packages/typert/protocol/src`)
|
||||
|
||||
```text
|
||||
// types.ts
|
||||
export type RemoteStream<Out, In = never> = AsyncIterable<Out> & { readonly [STREAM_UPLINK]?: In }
|
||||
export type PeerId = Branded<'PeerId'>
|
||||
export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise<void> }
|
||||
export interface RemoteInvocation { … } // see Host face
|
||||
declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } }
|
||||
|
||||
export interface InvocationDescriptor {
|
||||
// existing fields unchanged; mode still has only 'stream'
|
||||
readonly mode?: 'stream'
|
||||
/** Uplink item codec, generated from In of the return type RemoteStream<Out, In>; absent when In is never. */
|
||||
readonly uplink?: { readonly codec: TypertCodec }
|
||||
readonly cancellation?: { readonly parameter: 'signal' }
|
||||
readonly result: TypertCodec
|
||||
}
|
||||
```
|
||||
|
||||
`Remote` 修饰器、`RemoteMethodOptions`、`RemoteMethodMarker` 恢复为只认 `mode: 'stream'`。
|
||||
|
||||
### typert generator(`packages/typert/generator/src`)
|
||||
|
||||
- `model.ts`:`InvocationModel.uplink?: { boundary: RemoteBoundaryModel }`。
|
||||
- `analyzer.ts` `remoteResultType`:对 `mode: 'stream'`,接受的返回类型包装器为 `Iterable<Out>`、`AsyncIterable<Out>`、`RemoteStream<Out, In?>`。识别 `RemoteStream` 的方式与识别标准库 `AsyncIterable` 相同:符号名加声明所在文件(`@deepseek-ai/dsh-typert-protocol` 的 `types.ts`)。第一个类型参数是下行项,第二个存在且不是 `never` 时生成 `uplink` boundary,键名 `${endpoint}:uplink`。
|
||||
- `emitter.ts`:描述符字面量输出 `uplink: { codec }`;生成的 Client 签名返回 `RemoteStreamHandle<Out, In>`。
|
||||
- 参数循环不再识别任何名为 `uplink` 的参数。
|
||||
|
||||
### 两个名字,一个入口
|
||||
|
||||
Host 方法用 `RemoteStream<Out, In>` 声明一条流;Client 持有的是 `RemoteStreamHandle<Out, In>`。两者都从 `@deepseek-ai/dsh-typert-protocol` 主入口导出,句柄接口没有任何 Host 依赖。生成的 Client 契约把返回类型写成 `RemoteStreamHandle<Out, In>`。一个名字只有一个含义:Client 代码从主入口拿到的 `RemoteStream` 永远是声明类型,不会与句柄混淆。
|
||||
|
||||
### 网关 Host(`packages/api/gateway/src`)
|
||||
|
||||
```text
|
||||
// types.ts
|
||||
export interface InvokeRemoteRequest {
|
||||
readonly namespace: string
|
||||
readonly method: string
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/** Uplink items; absent is equivalent to an iterator that ends immediately. */
|
||||
readonly uplink?: AsyncIterable<unknown>
|
||||
/** The Peer that initiated the call; absent means the operator. */
|
||||
readonly peer?: PeerScope
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface TypertGatewayWireStream {
|
||||
open: (endpoint: string, payload: unknown, uplink: AsyncIterable<unknown>, peer: PeerScope | undefined, signal: AbortSignal) => Promise<AsyncIterable<unknown>>
|
||||
failure: (error: unknown) => RemoteStreamFailure
|
||||
}
|
||||
|
||||
// stream-server.ts
|
||||
export type RemoteStreamOpener = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
uplink: AsyncIterable<unknown>,
|
||||
peer: PeerScope,
|
||||
control: AbortController,
|
||||
) => Promise<AsyncIterable<unknown>>
|
||||
|
||||
// index.ts Config addition
|
||||
/** Uplink inbox limit per logical stream, in UTF-8 bytes of frames (default 262144). */
|
||||
readonly streamInboxBytes?: number
|
||||
```
|
||||
|
||||
`prepareInvocation`:
|
||||
|
||||
```text
|
||||
const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint)))
|
||||
const signal = request.signal ?? NEVER_ABORTED_SIGNAL
|
||||
const invocation = new GatewayInvocation(
|
||||
{ namespace: request.namespace, method: request.method, args: request.args },
|
||||
descriptor.service,
|
||||
request.peer ?? this.operatorPeer(),
|
||||
signal,
|
||||
new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control),
|
||||
)
|
||||
const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object
|
||||
if (descriptor.cancellation !== undefined) args.push(signal)
|
||||
```
|
||||
|
||||
`GatewayInvocation.uplink()` 第一次调用返回解码器,第二次抛错。`UplinkDecoder` 是手写迭代器:有 codec 时逐项 `decode(codec, value, endpoint, 'uplink')`,失败即 `control.abort(failure)` 并抛出;无 codec 时只做 `isRemoteJsonValue` 校验。`cancellableStream` 的 `finally` 先关闭解码器(`invocation.close()`)再等待方法迭代器的 `return()`,阻塞在 `uplink.next()` 上的方法得以退出。unary 与 stream 的模式分支恢复为今天的两分支。SRC 回退:描述符无 `uplink` codec,`uplink()` 交付 `unknown`。
|
||||
|
||||
### 网关 Host mux(`stream-server.ts`)
|
||||
|
||||
```text
|
||||
interface ActiveStream { readonly control: AbortController; readonly inbox: UplinkInbox; done: Promise<void> }
|
||||
|
||||
/** Bounded uplink queue; iterated as the source of uplink(). */
|
||||
class UplinkInbox implements AsyncIterable<unknown>, AsyncIterator<unknown> {
|
||||
constructor(maxBytes: number, onOverflow: (error: RemoteError) => void)
|
||||
push(value: unknown, frameBytes: number): void // push after ended fails the stream with gateway/protocol; overflow calls onOverflow
|
||||
end(): void // idempotent
|
||||
fail(error: unknown): void // ends the iterator with an error on cancel or carrier closure
|
||||
next() / return() // hand-written: next races failure, return releases immediately
|
||||
}
|
||||
```
|
||||
|
||||
`receive(text)` 分派:
|
||||
|
||||
```
|
||||
open : exists → throw (socket 1008); otherwise create { control, inbox, done } and start the pump
|
||||
item : missing → ignore; otherwise inbox.push(value, bytes)
|
||||
end : missing → ignore; otherwise inbox.end()
|
||||
cancel : missing → ignore; otherwise control.abort(new Error('Remote stream cancelled'))
|
||||
```
|
||||
|
||||
`pump` 把 inbox 与该 socket 的 Peer 传给 opener;`finally` 里 `inbox.fail(...)`。以 `RemoteError` 为 reason 的中止由 pump 作为 `error` 帧发出(网关或 mux 发起的失败);以普通 `Error` 为 reason 的中止(cancel、socket 关闭)不发终止帧。
|
||||
|
||||
### 网关 Client(`packages/api/gateway/src/client`)
|
||||
|
||||
- `prepareInvocation` 不再剥离任何上行参数;位置参数仍是业务参数加可选 `signal`。
|
||||
- `invokeStream` 返回 `RemoteStreamHandle`:内部持有一个上行队列,`send` 入队并由泵发 `item` 帧,`end` 发 `end` 帧,`dispose` 中止代际 signal。下行仍是 `openRemoteStream` 返回的迭代器。
|
||||
- `RemoteStreamMuxClient.open(endpoint, payload, signal, uplink?)`:在发出 `open` 帧后启动上行泵,逐项发 `item`,源结束发 `end`;下行终止或 signal 中止时停泵并调用源的 `return()`(不等待,避免键盘类生成器卡死);源抛错时 `inbox.fail(error)`,下行以该错误失败,`finally` 按既有规则发 `cancel`。泵检查 `this.socket === socket`,旧代际不往新 socket 上发帧。
|
||||
- 进程内载体:`connection.rpc.open(channel, endpoint, payload, signal, uplink)`。
|
||||
|
||||
## 落点
|
||||
|
||||
| 包 | 承载 |
|
||||
| --- | --- |
|
||||
| `dsh-typert-protocol`(`types.ts`、`index.ts`) | `RemoteStream`、`RemoteStreamHandle`、`PeerId`、`PeerScope`、`RemoteInvocation`、`ctx.invocation` 声明合并、`InvocationDescriptor.uplink`;修饰器只认 `mode: 'stream'`;`TypertRemoteService` 构造时注册 `invocation` accessor |
|
||||
| `dsh-typert-registry` | 加载校验 `uplink.codec` 为有效的严格 codec |
|
||||
| `dsh-typert-generator`(`model.ts`、`analyzer.ts`、`emitter.ts`) | `remoteResultType` 识别 `RemoteStream<Out, In>` 并生成 `uplink` boundary;描述符输出 `uplink: { codec }`;生成的 Client 签名返回 `RemoteStreamHandle<Out, In>`;参数循环只认业务参数与末位 `signal` |
|
||||
| `dsh-client-connection`(`operator-peer.ts`、`rpc.ts`、`rpc-host.ts`、`index.ts`) | 操作者 `PeerScope`;`connection.operator` 与 `admit`;`/api` 与升级路由经 `admit` 取 Peer;`rpc.open` 的可选 `uplink` |
|
||||
| `dsh-api-gateway` Host(`stream-protocol.ts`、`stream-server.ts`、`types.ts`、`index.ts`) | `item` / `end` 帧;`UplinkInbox`;`Config.streamInboxBytes`;`GatewayInvocation` 与 `extend({ invocation })`;`UplinkDecoder`;`handleUpgrade(req, socket, head, peer)`;`operatorPeer()` |
|
||||
| `dsh-api-gateway` Client(`client/index.ts`、`client/stream-client.ts`) | `invokeStream` 返回句柄;上行泵由句柄的队列驱动 |
|
||||
| `dsh-webworker-runtime`、`dsh-remote-mock` | worker 隧道的两种上行帧与 `serveStream` 组装;`StreamHandle.uplink`;直接代理返回真句柄 |
|
||||
| 文档 | gateway、api 组、connection、protocol、generator 的 README;`docs/api-gateway`;`docs/subsystems/typert` 的 type-equiv 块;config 与 Cordis 目录 |
|
||||
|
||||
## 关键流程时序
|
||||
|
||||
### 打开、上行、半关闭、下行结束
|
||||
|
||||
```
|
||||
Client caller mux client mux server gateway Host method
|
||||
│ attach(req) │ │ │ │
|
||||
│───────────────────▶│ open { id, ep, payload } ──────────────────▶│ create inbox │
|
||||
│ s.send(v1) │ item { id, v1 } ───────────────────────────▶│ inbox.push(v1) │
|
||||
│ │ │ │ prepareInvocation │
|
||||
│ │ │ │ extend({ invocation }) │
|
||||
│ │ │ │── method(req, signal) ─▶│
|
||||
│ │ │ │ │ uplink() → v1
|
||||
│ s.send(v2) │ item { id, v2 } ───────────────────────────▶│ inbox.push(v2) │ → v2
|
||||
│ s.end() │ end { id } ────────────────────────────────▶│ inbox.end() │ iteration ends
|
||||
│◀── yield o1 ───────│◀────────────────────── item { id, o1 } ─────│◀── yield o1 ────────────│
|
||||
│◀── ends ───────────│◀────────────────────── end { id } ──────────│◀── method returns ──────│ gateway calls uplink.return()
|
||||
```
|
||||
|
||||
### 客户端 dispose
|
||||
|
||||
```
|
||||
Client caller mux client mux server gateway Host method
|
||||
│ s.dispose() │ │ │ │
|
||||
│───────────────────▶│ stop pump; source return() (not awaited) │ │
|
||||
│◀── iterator ends ──│ cancel { id } ─────────────────────────────▶│ control.abort() │
|
||||
│ │ │ │ cancellableStream: invocation.close() closes uplink
|
||||
│ │ │ │ iterator.return() ─────▶│ finally cleanup
|
||||
│ │ │ no terminal frame │ │
|
||||
```
|
||||
|
||||
### inbox 超限
|
||||
|
||||
```
|
||||
Client caller mux client mux server gateway / Host method
|
||||
│ s.send(vN) │ item { id, vN } ─────▶│ inbox.push: bytes > streamInboxBytes │
|
||||
│ │ │ onOverflow → control.abort(RemoteError uplink-overflow)
|
||||
│ │ │ │ uplink() ends with that error; method iterator return()
|
||||
│◀── throw ──────────│◀──────────────────────│ error { id, uplink-overflow }: the pump sends an error frame because the reason is a RemoteError
|
||||
```
|
||||
|
||||
## 边界矩阵
|
||||
|
||||
| 事件 | opening | running | 上行已 end | 下行已终止 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 收到 `item` | 入 inbox 缓冲 | 入 inbox | 流以 `gateway/protocol` 失败 | 忽略 |
|
||||
| 收到 `end` | inbox.end | inbox.end | 幂等 | 忽略 |
|
||||
| 收到 `cancel` | abort | abort | abort | 忽略 |
|
||||
| inbox 超限 | 流以 `uplink-overflow` 失败 | 同 | 不可能 | 不可能 |
|
||||
| 解码失败 | 不可能 | 流以 `input-invalid` 失败 | 同 | 不可能 |
|
||||
| Host 方法结束 | 不可能 | 发 `end`;`uplink.return()` | 发 `end` | 已发 |
|
||||
| Host 方法抛错 | 发 `error` | 发 `error`;`uplink.return()` | 发 `error` | 已发 |
|
||||
| socket 关闭 | abort,等 done | 同 | 同 | 无事 |
|
||||
| Client `send` | 入队,open 后发 | 发 | 抛错 | 抛错 |
|
||||
| Client `end` | 记录,open 后发 | 发 | 幂等 | 忽略 |
|
||||
| Client 侧下行先结束 | 不可能 | 停泵;源 `return()`;不发 `end` | 无事 | 无事 |
|
||||
|
||||
## 错误码
|
||||
|
||||
| 错误码 | 何时 |
|
||||
| --- | --- |
|
||||
| `gateway/input-invalid` | 既有:参数 codec 解码失败;新增:上行项解码失败 |
|
||||
| `gateway/uplink-overflow` | 新增:inbox 超过 `streamInboxBytes` |
|
||||
| `gateway/protocol` | 新增:`end` 之后的 `item` |
|
||||
| `gateway/cancelled` | 既有:`signal` 中止 |
|
||||
|
||||
新码登记在 `packages/api/gateway/src/remote-error-codes.ts` 与 README 的错误码一节。
|
||||
|
||||
## 最小 echo 方法
|
||||
|
||||
`packages/api/gateway/tests/echo-stream.host.spec.ts` 让同一个方法分别穿过 WebSocket 载体与进程内载体:
|
||||
|
||||
```text
|
||||
class EchoService extends TypertRemoteService {
|
||||
constructor(ctx: Context) { super(ctx, 'echo', { namespace: 'echo' }) }
|
||||
|
||||
@Remote({ mode: 'stream' })
|
||||
async *echo(prefix: string, signal: AbortSignal): RemoteStream<string, string> {
|
||||
for await (const item of this.ctx.invocation!.uplink<string>()) {
|
||||
signal.throwIfAborted()
|
||||
yield `${prefix}${item}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Client
|
||||
const stream = remote.echo.echo('> ')
|
||||
stream.send('a'); stream.send('b'); stream.end()
|
||||
const replies: string[] = []
|
||||
for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
```
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**独立的 `mode: 'duplex'` 加一个与 `signal` 同级的保留参数 `uplink: AsyncIterable<In>`。** 每一处都是特殊逻辑:analyzer 要认第二个保留参数及其位置规则,生成签名多一个参数,加载期要校验 `duplex ⟺ uplink`,mux 不知道方法模式所以网关要替 `'stream'` 方法拒绝上行帧。双工是传输层流的固有性质,不是方法的一种模式:载体本来就有 `streamId`、`open`、`cancel`,上行只是再加两种帧,任何流都能收,不用就不发。
|
||||
|
||||
**用装饰器选项声明上行类型,`@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`。** 字符串引用类型名不受编译器约束,重命名即断。返回类型别名把两个类型写在同一个编译器可见的位置。
|
||||
|
||||
**运行时传 schema:`uplink(schema)`。** 不经生成器,客户端失去类型投影,与"每项严格 codec"的体系分裂。codec 的来源仍是生成器,方法里的泛型只是断言。
|
||||
|
||||
**analyzer 交叉校验 `uplink<In>()` 手填的泛型与返回类型一致。** 业务特化的校验,写错后果由调用方承担,与仓库其他处手填断言一致;不加。
|
||||
|
||||
**传输层句柄带重开、游标、`reopen` 钩子。** 断线重开是上层协议的事:job 的续传是 `opened` 帧带 `from`、客户端记住 `next` 再调一次;snapshot 与 journal 各有自己的锚点与分类。传输层只认一个代际。
|
||||
|
||||
**客户端 `send` 放在生成方法返回的 `AsyncIterable` 上。** `AsyncIterable` 上挂方法不常规;句柄类型 `RemoteStreamHandle` 显式扩展它。
|
||||
|
||||
**照搬 Access 机制。** 访问控制是业务语义,`vouch` 与 `remote/invoke` waterfall 为它服务;本 Note 只需要"这条流是谁的",`PeerScope` 就够。
|
||||
|
||||
**继续用 unary RPC 做上行。** 每次上行一次 HTTP 往返、一次鉴权、一次查找;两条载体没有顺序关系,只能靠客户端串行化;没有 EOF;取消不覆盖两半;每个功能各造一套关联键。
|
||||
|
||||
**原生 HTTP 请求体流。** 一次性请求体,与下行流没有关联,不经 Typert 描述符,浏览器对全双工 fetch 的支持不可依赖。
|
||||
|
||||
**每条双工流一个独立 WebSocket。** 失去与现有下行流共享的鉴权、心跳与多路复用,每个功能要自己管一条 socket 的生命周期。
|
||||
|
||||
**WebTransport 或 HTTP/2 双向流。** 有每流流控,但浏览器与 Node 两侧的支持、代理穿透与 Electron 载体都不成熟;在现有 mux 上加两种帧比换传输层便宜几个量级。
|
||||
|
||||
## 后果
|
||||
|
||||
- **买到的**:一条流两个方向,终端按键、审批回答、后台任务 stdin 这类交互式场景不再各造一套"一条流加一个 unary";Host 方法知道调用者;上行类型与下行类型在返回类型里一处声明,上行项来自浏览器,在 Host 逐项严格校验,下行项是 Host 自产的类型值,直接透传;既有 `AsyncIterable<Out>` 方法与 unary 方法的生成物与线路帧完全不变。
|
||||
- **`this.ctx.invocation` 在非 Remote 调用下是 `undefined`**,方法读它要处理可选;一个只被进程内直接调用的服务方法读到 `undefined` 是正确行为。
|
||||
- **inbox 上限对所有流生效**:一个不读上行的方法收到大量上行帧会整流失败。这是刻意的显式失败,README 记明。
|
||||
- **Peer 只有操作者一个**,`admit` 是唯一的接纳点;第一个需要第二个 Peer 的消费者要在 `dsh-client-connection` 加上打开、关联与事件。
|
||||
- **上行与下行之间没有跨方向顺序保证**;需要请求应答配对的协议自带序号。
|
||||
- **上层协议尚未透出 `send`**:`$stream`、snapshot、journal 的消费者今天只用下行;它们拿到的对象已经是句柄。
|
||||
- **worker 隧道与 WebSocket 载体行为不同**:没有 inbox 上限,`end` 之后的项丢弃,因为页面到 worker 是同源可信边界。
|
||||
|
||||
## 测试
|
||||
|
||||
- `packages/api/gateway/tests/echo-stream.host.spec.ts`:同一个 echo 方法分别穿过 WebSocket 载体与进程内载体。
|
||||
- 网关 Host:帧解析的精确键校验;`UplinkInbox` 的 opening 缓冲、`end` 后 `item`、超限、未知 `streamId` 忽略、socket 关闭时的收尾;`UplinkDecoder` 有无 codec 两种交付与解码失败中止;`uplink()` 只能取一次;`extend({ invocation })` 视图与 `peer` 的来源;`cancellableStream` 先关 uplink 再 `return()`。
|
||||
- 网关 Client:句柄的 `send`(JSON 校验、open 前入队、终止后抛错)、`end` 幂等、`dispose` 驱动 `cancel` 帧并结束后续读取;泵的三种退出;终止帧到达即停泵。
|
||||
- typert:analyzer 对 `RemoteStream<Out, In>`、`RemoteStream<Out>`、`never` 与既有 `AsyncIterable<Out>` 的处理;修饰器选项;registry 加载校验。
|
||||
- connection:`admit` 的 401、403 与操作者三种结果;操作者经桥与共享处理器到达;随 connection 释放。
|
||||
- remote-mock、worker 隧道:上行透传;直接代理返回真句柄。
|
||||
- 既有 stream 与 unary 方法的测试不改期望值即通过。
|
||||
|
||||
## 后续
|
||||
|
||||
- `$stream` / snapshot / journal 把句柄的 `send` 透出给上层协议。
|
||||
- Web 终端:一条 `attach` 流取代 `follow` 加 `write` 加 `resize`,`attachmentId` 退场。
|
||||
- 双工 `$events`:waterfall 的回答成为同一 `streamId` 上的上行项,`$events/result` 与 `clientId` 退场。
|
||||
- credit 帧:等需要持续大流量上行的消费者出现;inbox 上限就是初始授信。
|
||||
- `streamInboxBytes` 默认值 262144 对键盘与 stdin 远超需要;若第一个消费者是终端粘贴,可能与 `terminal-controller` 的 `maxInputBytes` 对齐。
|
||||
- Host 发起的半关闭帧(Host 不再读上行但继续写下行):没有消费者,不加。
|
||||
- unary 方法的 `uplink()`:能读到方法运行期间到达的项,语义成立但用处存疑;允许,README 注明。
|
||||
|
||||
## 相关
|
||||
|
||||
- [Remote 事件投递](2026-08-10-remote-event-delivery.zh.md):`$events/result` 的 unary 回答路径;「后续」里的双工 `$events` 落地后部分取代它。
|
||||
- [会话历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md):`$stream()` 监督器与其上的 journal / snapshot 协议,本 Note 不改它们。
|
||||
- [Web 侧边栏终端](../feature/2026-09-09-web-sidebar-terminal.zh.md):终端按键的 unary `write` 与 `attachmentId`;「后续」里的 `attach` 流落地后取代它。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.md
|
||||
2026-09-20-session-open-read-coordination.md: a4907df1d7dd38e4c532c38fb6ff10362a4a3898
|
||||
2026-09-20-session-open-read-coordination.zh.md: 759a45d8c52a62ea7a0b30ceed4003f8804a42c3
|
||||
@@ -0,0 +1,88 @@
|
||||
# Agent Note: Session opening cache identity and read ordering
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-20-session-open-read-coordination.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Opening a historical Session can make `session.follow`, composer catalog prewarming, and RPCs with Agent parameters access the same cold log concurrently. `skills/list` requests a complete observation itself; `commands/list`, `goals/get`, `fileReferences/list`, and `sessionReferenceResolver/candidates` can initiate resume through the Agent lookup during parameter resolution. Auxiliary requests that do not wait for the history opening snapshot compete with the main conversation for reading, parsing, and projection computation instead of reusing completed results.
|
||||
|
||||
Reuse of completed observations has a separate problem: Cordis can return different proxy objects for accesses to the same service. Comparing the object references returned by `ctx.get('sessionPersistence')` can mistake one underlying service for different instances. Removing instance identity checks entirely is also unsafe because persistence revisions are comparable only within the corresponding service instance and Session identity.
|
||||
|
||||
Subagent navigation combines two needs: displaying a conversation at a known address, and discovering its parent or descendant catalogs. Refreshing parent projections before navigating through an existing parent-child address adds prerequisite work and can reject an address that the Host could validate and open when the Client catalog cache is absent.
|
||||
|
||||
## Decision
|
||||
|
||||
### Identify the cache producer with a service-owned Symbol
|
||||
|
||||
`SessionPersistence.identity` is a `Symbol('sessionPersistence')` created once per service instance. Proxy access preserves that value, while a replacement service instance receives a different value. The prepared observation cache looks up the Session id and compares both `persistence.identity` and `stat().revision`; the same instance and revision can reuse a completed preparation.
|
||||
|
||||
This identity exists only within the process and does not enter Session headers, events, or persisted files. Existing cache capacity, leases, live precedence, and revision invalidation remain unchanged. This fix does not merge Promises for unfinished cold reads; stable identity prevents false invalidation after completion, not duplicate concurrent work across all callers.
|
||||
|
||||
### Auxiliary RPCs wait for the current conversation's initial open to succeed
|
||||
|
||||
The Client uses the existing `sessions.using()` at the actual RPC call site, rather than only delaying prewarm hooks. Menu candidates, explicit reference queries, and later catalog refreshes therefore use the same waiting entry point. Each operation first checks that its target already has a Client binding, so background prewarming does not reopen a closed Session.
|
||||
|
||||
| Client consumer | RPC after waiting | Temporary reference source |
|
||||
| --- | --- | --- |
|
||||
| Skill catalog | `skills/list` | `skillCatalog` |
|
||||
| Command catalog | `commands/list` | `commandCatalog` |
|
||||
| Goal activation state | `goals/get` | `goalActivation` |
|
||||
| `@` file and Session candidates | `fileReferences/list`, `sessionReferenceResolver/candidates` | Shared `referenceCandidates` |
|
||||
|
||||
`sessions.using()` waits for `reference.ready`, then for the operation's returned Promise, and finally releases its reference. `ready` means that the initial `Session.open()` attempt has settled, not that it succeeded; these consumers also require `openState === 'open'` and send no auxiliary RPC after a failed open. The Goal reader additionally checks that its captured binding has not been replaced and logs rejected reads without clearing existing display state.
|
||||
|
||||
Skills retain the shared catalog request's own cancellation signal; `@` queries use the current candidate request's signal for the history wait and both RPCs. Unquoted `@` queries still fetch files and Sessions concurrently after waiting, while quoted paths still query files only. Commands and Goals gain no new cancellation protocol. These references retain Client data and follow streams, grant no new Host Agent authority, and do not prohibit normal Agent resume after the opening snapshot.
|
||||
|
||||
### Conversations use follow; catalog branches retain explicit reads
|
||||
|
||||
The main conversation's opening `follow` snapshot already carries that Session's complete projection baseline. Later changes to shared projection values notify subscribers, so switching Sessions or opening a menu does not require another read of the same baseline.
|
||||
|
||||
- Main-view navigation does not separately refresh the selected Session's projections; restoring a saved subagent address does not prewarm its parent.
|
||||
- The Sidebar retains the target child directly from its complete parent-child address, without making a parent-catalog read a prerequisite for opening the conversation.
|
||||
- The header dropdown's `changeOpen` only changes presentation state and does not refresh `rootSessionId`. Child expansion and failed-read retries retain explicit refreshes under the UI callback name `refreshProjection`; the underlying `sessions.refreshProjections` API is not renamed.
|
||||
- Team navigation uses the existing Lead and roster member ids to open `{ parentSessionId, childSessionId, mode: 'continuable' }` directly. It neither refreshes the parent catalog nor requires the target to be present in the Client catalog; member-role and main-view ownership checks, plus the UI restrictions on provisioning and failed members, remain unchanged.
|
||||
|
||||
A known address does not waive authorization checks. When opening child history, the Host still checks the target's own header parent and origin, plus the mode and descriptor ownership in its own `subagent` projection. Later continuation remains subject to the existing direct-parent authorization rules.
|
||||
|
||||
## Implementation scope
|
||||
|
||||
- Session formats, migrations, the Host observation's `all | none` policy, and the response fields of `session.projections` are unchanged.
|
||||
- There is no global cold-read singleflight, and Agent lookups are not universally replaced with read-only lookups.
|
||||
- `SessionManager.handleConnected()` still refreshes previously requested catalogs in bulk; this decision does not guarantee one cold read on reconnect.
|
||||
- Tool-side `listDescendants` can still read child Sessions on cache misses, and expanding a child node in the Client can still cold-read that node. Ordinary main-session opening is not equivalent to every descendant-enumeration scenario.
|
||||
- `@` Session candidates still enumerate headers and obtain names from live projections or projection caches, falling back to ids on misses. Existing ranking, the default limit of 50, and direct-subagent grouping remain unchanged; discovery does not recursively traverse `subagentCatalog`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Remove persistence-instance comparison.** Rejected. A matching Session id and revision cannot establish that two persistence services provide the same data; replacing a service must still invalidate old preparations.
|
||||
|
||||
**Unwrap Cordis proxies in callers or cache service objects per Context.** Rejected. The cache needs the producer's identity, not the identity of an object returned by a particular service access. A per-instance Symbol expresses that requirement without propagating proxy implementation details.
|
||||
|
||||
**Add shared singleflight for every cold read.** Outside this implementation. It could merge more concurrent calls, but would require separate rules for independent waiter cancellation, leases, and task ownership during service replacement. It also would not replace the ordering requirement that auxiliary RPCs must not resume an Agent ahead of the opening snapshot.
|
||||
|
||||
**Delay only warm hooks, or merely await the extra projection request.** Rejected. Explicit menu queries could still bypass warm hooks, and serializing a redundant projection request does not remove its work. The wait belongs at the actual auxiliary RPC entry point; reads already supplied by follow are removed.
|
||||
|
||||
**Keep Team's parent-catalog presence check.** Rejected. The Team roster already provides member identity, and the existing Host history entry point can validate an explicit address. Requiring a Client catalog cache hit for navigation mistakes data that has not loaded for a child that does not exist.
|
||||
|
||||
## Consequences
|
||||
|
||||
An ordinary main Session completes its own history and projection read before auxiliary operations reuse the completed observation or attached state. The Client's direct-child list consumes the main Session's catalog without reading each child's log merely to display its row. Catalog and conversation opening are separate operations, so navigating to a known child no longer adds parent-projection prewarming.
|
||||
|
||||
Auxiliary catalogs become available after the conversation's opening snapshot, and operations retain temporary Client references until they finish. This wait covers only the current Client generation's initial open, not readiness after every underlying follow replacement. Reconnect and exceptional retry scenarios still require their own verification.
|
||||
|
||||
A missing header root catalog remains a specific coverage gap: if it belongs to an unopened parent and no other source supplies the catalog, opening the dropdown alone does not fetch it, and the current UI may continue to display a loading notice. This implementation adds neither a new parent-catalog loading entry point nor a shared unknown-state interaction protocol.
|
||||
|
||||
## Verification and known gaps
|
||||
|
||||
During local navigation to a long historical main Session, the user observed repeated `readColdSessionLog` calls converge to one using temporary timers and call stacks, and verified direct teammate navigation. This result describes that operation's cold-read helper invocation count, not every underlying file I/O, every reconnect scenario, or a single unit of end-to-end cost. Private Session content and identities from diagnosis are not test fixtures.
|
||||
|
||||
Focused TypeScript builds and bundles were run for affected packages during development; the complete change has no gate, behavior-test, or assembled Web snapshot verification result. Existing tests have only partial callback renames and still contain old fixture names, root-menu-opening refresh expectations, Sidebar parent-refresh prerequisites, and Team rejection of missing catalogs. These are known verification gaps, not passing checks.
|
||||
|
||||
## Relationship to existing records
|
||||
|
||||
- [Session observations and projection-owned Client state](../architecture/2026-08-25-session-observations-and-projection-owned-client-state.md) retains ownership of read cuts and shared values. This record adds cache-producer identity and opening-snapshot consumption order; the older claim of sharing unfinished cold loads does not match the current reader and is not evidence that this change implements singleflight.
|
||||
- [Client Session references, reference sources, and UI state](../architecture/2026-09-15-client-session-references.md) retains ownership of `retain`, `ready`, `using`, and explicit-address semantics. This record limits temporary references to four classes of auxiliary reads as independent asynchronous operations, rather than adding references to every UI action.
|
||||
- [Web subagent catalogs consume shared projections](../simplification/2026-09-08-web-subagent-catalog-projections.md) retains ownership of the generic projection store, explicit catalog reads, and control updates. This implementation applies its rule that opening a conversation uses the follow baseline, without replacing the catalog mechanism.
|
||||
- [Web subagent catalogs and human continuation](../feature/2026-07-27-web-subagent-conversations.md) retains ownership of presentation and continuation authorization. This record changes only prerequisite navigation reads and root-menu refresh timing, not the child's own Host validation; the older description of interactive loading for missing root catalogs must be reconciled with the coverage gap recorded here.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Agent Note: 会话首帧读取的缓存身份与调用时序
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-20-session-open-read-coordination.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
打开历史会话时,`session.follow`、输入框目录预热和依赖 Agent 参数的 RPC 可能同时访问同一份冷日志。`skills/list` 自行请求完整 observation;`commands/list`、`goals/get`、`fileReferences/list` 与 `sessionReferenceResolver/candidates` 则可以在参数解析阶段经 Agent lookup 发起恢复。辅助请求不等待历史首帧,会与主对话争用读取、解析和 projection 计算,而不是复用已经完成的结果。
|
||||
|
||||
完成后的 observation 复用还有一个独立问题:Cordis 对同一服务的访问可以返回不同代理对象。以 `ctx.get('sessionPersistence')` 返回对象的引用相等性判断服务身份,会把同一底层服务误判成另一个实例。另一方面,彻底去掉实例身份检查也不安全,因为持久化 revision 只在对应服务实例和 Session 身份内可比较。
|
||||
|
||||
子会话导航混合了两种需求:展示一个已知地址的对话,以及发现该会话的父目录或后代目录。已有父子地址的导航再次刷新 parent projection,既增加前置工作,也会在目录缓存缺席时拒绝一个本可由 Host 校验并打开的地址。
|
||||
|
||||
## 决策
|
||||
|
||||
### 用服务自身的 Symbol 标识缓存生产者
|
||||
|
||||
`SessionPersistence.identity` 是每个服务实例创建一次的 `Symbol('sessionPersistence')`。代理访问保留这个值,服务被替换后新实例获得不同值。Prepared observation cache 按 Session id 查找,并同时比较 `persistence.identity` 与 `stat().revision`;相同实例、相同 revision 可以复用已完成的 preparation。
|
||||
|
||||
该身份只存在于进程内,不进入 Session header、事件或持久化文件。现有缓存容量、lease、live 优先级和 revision 失效规则不变。这个修复不增加尚未完成的冷读取之间的 Promise 合并;稳定身份解决的是完成后的误失效,不是所有调用方的并发去重。
|
||||
|
||||
### 辅助 RPC 等待当前对话的首次打开成功
|
||||
|
||||
Client 在实际 RPC 发送位置使用既有 `sessions.using()`,不只延迟预热钩子。菜单候选、显式引用查询和后续目录刷新因而经过相同的等待入口。每个操作先确认目标已经有 Client binding,不因后台预热重新打开一个已经关闭的会话。
|
||||
|
||||
| Client 消费方 | 等待后的 RPC | 临时引用来源 |
|
||||
| --- | --- | --- |
|
||||
| Skill 目录 | `skills/list` | `skillCatalog` |
|
||||
| Command 目录 | `commands/list` | `commandCatalog` |
|
||||
| Goal 激活状态 | `goals/get` | `goalActivation` |
|
||||
| `@` 文件与会话候选 | `fileReferences/list`、`sessionReferenceResolver/candidates` | 共用 `referenceCandidates` |
|
||||
|
||||
`sessions.using()` 先等待 `reference.ready`,再等待操作返回的 Promise,最后释放自己的引用。`ready` 表示首次 `Session.open()` 尝试已结算,不保证成功;这些消费方还检查 `openState === 'open'`,失败时不发送辅助 RPC。Goal 读方还确认捕获的 binding 未被替代,并记录读取拒绝而不清除已有展示状态。
|
||||
|
||||
Skill 保留共享目录请求自己的取消信号;`@` 查询将当前候选请求的信号用于历史等待和两个 RPC。未加引号的 `@` 在等待后仍并行查询文件和会话,加引号的路径仍只查询文件。Command 与 Goal 不新增取消协议。这里的引用持有 Client 数据和跟随流,不赋予 Host Agent 新权限,也不禁止首帧之后发生正常 Agent 恢复。
|
||||
|
||||
### 对话使用 follow,目录分支保留显式读取
|
||||
|
||||
主对话的 `follow` 首帧已经携带该 Session 的完整 projection baseline。后续共享 projection 值变化会通知订阅者,不需要在切换会话或打开菜单时再次读取相同 baseline。
|
||||
|
||||
- 主视图切换不再额外刷新所选 Session 的 projection;恢复保存的子会话地址不再预先刷新其 parent。
|
||||
- 侧栏从完整父子地址直接保留目标子会话,不把父目录读取作为打开对话的前提。
|
||||
- Header 下拉菜单的 `changeOpen` 只改变展示状态,不刷新 `rootSessionId`。展开子节点和失败重试仍调用显式刷新,UI 回调命名为 `refreshProjection`;底层 `sessions.refreshProjections` API 不改名。
|
||||
- Team 点击使用已有 lead 与 roster 成员 id,直接打开 `{ parentSessionId, childSessionId, mode: 'continuable' }`。它不刷新 parent catalog,也不要求目标预先出现在 Client catalog 中;成员角色、主视图持有检查及 UI 对 provisioning、failed 成员的限制保持不变。
|
||||
|
||||
已知地址不等于免除授权校验。Host 在打开子会话历史时,仍检查目标自身 header 的 parent、origin,以及自身 `subagent` projection 的 mode 和描述符归属。后续 continuation 仍受直接 parent 的既有授权规则约束。
|
||||
|
||||
## 实现边界
|
||||
|
||||
- 没有修改 Session 格式、迁移、Host observation 的 `all | none` 策略或 `session.projections` 返回字段。
|
||||
- 没有实现全局 cold-read singleflight,也没有把所有 Agent lookup 改成只读 lookup。
|
||||
- 没有删除 `SessionManager.handleConnected()` 对此前请求目录的批量刷新;本决策不保证重连只有一次冷读。
|
||||
- 工具侧 `listDescendants` 的缓存缺失仍可能读取子会话;前端展开一个子节点也仍可能冷读该节点。普通主会话首开不等于所有后代枚举场景。
|
||||
- `@` 会话候选仍枚举 header,并从 live projection 或 projection cache 取名称;缓存缺失回退到 id。原有排序、默认 50 条上限和直接 subagent 分组规则不变,没有改成沿 `subagentCatalog` 递归发现。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**取消 persistence 实例比较。** 不采用。同一个 Session id 和 revision 不能证明两个持久化服务提供同一份数据;服务替换仍必须使旧 preparation 失效。
|
||||
|
||||
**在调用方解包 Cordis 代理或按 Context 缓存服务对象。** 不采用。缓存需要的是生产者身份,而不是某次服务访问返回对象的身份;每实例 Symbol 足够表达这个要求,不需要传播代理实现细节。
|
||||
|
||||
**为所有冷读增加统一 singleflight。** 未纳入本次实现。它可以合并更多并发调用,但需要另外定义独立等待方取消、lease 和服务替换期间的任务归属;它也不能代替“辅助 RPC 不抢在首帧前恢复 Agent”的时序要求。
|
||||
|
||||
**只延迟 warm,或只给额外 projection 请求补 await。** 不采用。显式菜单查询仍能绕过 warm;串行执行一个重复 projection 请求也没有删除重复工作。等待放在实际辅助 RPC 入口,已有 follow 能提供的读取直接取消。
|
||||
|
||||
**保留 Team 的父目录存在性检查。** 不采用。Team roster 已给出成员身份,显式地址可以由现有 Host 历史入口验证;把 Client catalog 缓存命中作为导航前提,会把数据尚未加载误当成子会话不存在。
|
||||
|
||||
## 后果
|
||||
|
||||
普通主会话先完成自己的历史和 projection 读取,辅助操作再复用已完成的 observation 或已挂载状态。前端直接子代列表消费主会话的 catalog,不为显示每一行读取对应子会话正文。目录与对话的打开入口分开,导航到一个已知子会话不再附带 parent projection 预热。
|
||||
|
||||
代价是辅助目录首次可用时间晚于对话首帧;操作还会持有临时 Client 引用直到结束。该等待只对应当前 Client generation 的首次打开,不是每次底层 follow 重建的就绪屏障。所有重连和异常重试场景仍需各自验证。
|
||||
|
||||
Header 根目录缺失仍是明确的覆盖缺口:若它属于未打开的 parent,且没有其他来源提供 catalog,仅打开下拉菜单不会补读;现有 UI 仍可能显示加载提示。本次没有增加新的父目录加载入口,也没有实现统一的 unknown 交互协议。
|
||||
|
||||
## 验证与已知缺口
|
||||
|
||||
用户在本地长历史主会话切换流程中,通过临时 `readColdSessionLog` 计时和调用栈观察到重复调用收敛为一次,并验证了 teammate 直接导航。这个结果只描述该操作的冷读辅助函数调用次数,不代表所有底层文件 I/O、全部重连场景或端到端性能都只有一次成本。诊断期间的私有 Session 内容和身份不作为测试 fixture。
|
||||
|
||||
开发期对相关包执行过定向 TypeScript 构建与打包;尚无这批完整变更的门禁、行为测试或组装 Web 快照验证结果。现有测试只完成部分回调改名,仍有旧 fixture 名称、打开根菜单即刷新、侧栏先刷新 parent,以及 Team 拒绝缺失 catalog 的预期需要调整。这些是已知验证缺口,不是已经通过的检查。
|
||||
|
||||
## 与既有记录的关系
|
||||
|
||||
- [Session observation 与 projection 所有的客户端状态](../architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md)继续拥有读取切面和共享值的职责划分。本记录补充缓存生产者身份与首帧消费顺序;旧记录中“包括未完成冷加载的共享”描述与当前 reader 实现不一致,不能作为本次 singleflight 已实现的依据。
|
||||
- [Client Session 引用、引用来源与 UI 状态](../architecture/2026-09-15-client-session-references.zh.md)继续拥有 `retain`、`ready`、`using` 与显式地址语义。本记录限定四类辅助读取作为独立异步操作持有临时引用,不把额外引用扩展到所有 UI 动作。
|
||||
- [Web 子代理目录消费共享 projection](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md)继续拥有通用 projection store、显式目录读取和 control 更新。本次实现落实“打开会话使用 follow baseline”的规则,不替换整个目录机制。
|
||||
- [Web subagent 目录与用户继续交互](../feature/2026-07-27-web-subagent-conversations.zh.md)继续拥有展示和 continuation 授权。本记录只调整导航前置读取及根菜单刷新时机,不取消子会话自身的 Host 校验;旧记录对缺失根目录交互加载的描述需与这里的覆盖缺口一起核对。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
|
||||
2026-09-09-web-sidebar-terminal.md: 5ed920d744f847cc97b08645592f0be221f18b8d
|
||||
2026-09-09-web-sidebar-terminal.zh.md: c3ceb0214c2f4db42b2fefb8b65619e198d9dc7e
|
||||
2026-09-09-web-sidebar-terminal.md: 09be4a142f615750799e01106eac7a9b52cbad48
|
||||
2026-09-09-web-sidebar-terminal.zh.md: c2ac1468756ee08c713855979119662390ffeadb
|
||||
@@ -49,3 +49,7 @@ A kept-open terminal retains a process and bounded screen memory. Reload restore
|
||||
The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals.
|
||||
|
||||
The [two-hour unattended-terminal reclamation](2026-09-14-unattended-browser-terminal-reclamation.md) adds an idle grace period after all frontend holders disconnect, preserving busy or uncertain work.
|
||||
|
||||
## Related
|
||||
|
||||
- [Remote duplex stream](../architecture/2026-09-19-remote-duplex-stream.md): the transport-layer uplink under which one `attach` stream replaces the unary `write` and `resize` calls and the `attachmentId` correlation; that terminal change is listed under its Deferred section.
|
||||
@@ -49,3 +49,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
|
||||
Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。
|
||||
|
||||
[无人连接终端的两小时回收](2026-09-14-unattended-browser-terminal-reclamation.zh.md)在全部前端持有者断开后设置空闲宽限期,保留忙碌或状态不确定的工作。
|
||||
|
||||
## 相关
|
||||
|
||||
- [Remote 双工流](../architecture/2026-09-19-remote-duplex-stream.zh.md):传输层上行,让一条 `attach` 流取代 unary `write`、`resize` 与 `attachmentId` 关联;该终端改动列在其「后续」中。
|
||||
@@ -260,7 +260,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
|
||||
const restoredAdd = await addTab.count()
|
||||
expect(restoredFilesClose).toBe(1)
|
||||
expect(restoredAdd).toBe(1)
|
||||
const preview = column.locator('[data-document-preview]')
|
||||
const preview = column.locator('[data-textpreview-url]')
|
||||
const openFile = openPreviewFile.bind(undefined, column, filesTab, preview)
|
||||
const viewer = preview.locator('[data-document-viewer-menu]')
|
||||
const body = preview.locator('[data-textpreview-body]')
|
||||
@@ -696,7 +696,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
|
||||
|
||||
const officeMenus: number[] = []
|
||||
const configurationGuide = 'Read failed: Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness.'
|
||||
for (const extension of ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) {
|
||||
for (const extension of ['doc', 'docx', 'ppt', 'pptx']) {
|
||||
await openFile(`unavailable.${extension}`)
|
||||
expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0)
|
||||
await preview.getByText(configurationGuide, { exact: true }).waitFor({ timeout: 15_000 })
|
||||
@@ -707,12 +707,23 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
|
||||
await successShot(page, 'office-unavailable')
|
||||
sections.push([
|
||||
'## Office unavailable', '',
|
||||
`- DOC, DOCX, XLS, XLSX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`,
|
||||
`- DOC, DOCX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`,
|
||||
`- Guidance: ${configurationGuide}`,
|
||||
'- Binary text shown: false',
|
||||
'- Plain-text option and viewer picker: hidden',
|
||||
].join('\n'))
|
||||
|
||||
const spreadsheetStates: string[] = []
|
||||
for (const extension of ['xls', 'xlsx']) {
|
||||
await openFile(`unavailable.${extension}`)
|
||||
const spreadsheet = column.locator('[data-textpreview-state="unsupported"]')
|
||||
await spreadsheet.waitFor({ timeout: 15_000 })
|
||||
expect(await spreadsheet.getByText('Preview is not available for this file type yet.', { exact: true }).count()).toBe(1)
|
||||
await spreadsheet.locator('[data-open-path-unpreviewable]').waitFor({ timeout: 15_000 })
|
||||
spreadsheetStates.push(`${extension.toUpperCase()}: unsupported / Open in default app`)
|
||||
}
|
||||
sections.push(['## Spreadsheet preview', '', `- ${spreadsheetStates.join('\n- ')}`].join('\n'))
|
||||
|
||||
await openFile('notes.unknown')
|
||||
const plainLines = preview.locator('[data-textpreview-line]')
|
||||
await expect.poll(() => plainLines.count()).toBe(2)
|
||||
@@ -810,7 +821,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
|
||||
await column.locator('[data-files-reload]').click()
|
||||
const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
|
||||
const preview = column.locator('[data-document-preview]')
|
||||
const preview = column.locator('[data-textpreview-url]')
|
||||
await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'chinese.docx', exact: true }).click()
|
||||
expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0)
|
||||
const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true })
|
||||
@@ -885,30 +896,36 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
`- Document top inset: ${topInset}px`,
|
||||
].join('\n'), MODE)
|
||||
await successShot(page, 'office-docx')
|
||||
for (const extension of ['doc', 'xls', 'xlsx', 'ppt', 'pptx']) {
|
||||
for (const extension of ['doc', 'ppt', 'pptx']) {
|
||||
await openPreviewFile(column, filesTab, preview, `chinese.${extension}`)
|
||||
await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 })
|
||||
await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档')
|
||||
if (['doc', 'xls', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0)
|
||||
if (['doc', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0)
|
||||
await successShot(page, `office-${extension}`)
|
||||
}
|
||||
expect(convert).toHaveBeenCalledTimes(6)
|
||||
expect(convert).toHaveBeenCalledTimes(4)
|
||||
for (const extension of ['xls', 'xlsx']) {
|
||||
await openPreviewFile(column, filesTab, preview, `chinese.${extension}`)
|
||||
await preview.getByText('Preview is not available for this file type yet.', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
expect(await preview.getByRole('img', { name: 'PDF page 1', exact: true }).count()).toBe(0)
|
||||
}
|
||||
expect(convert).toHaveBeenCalledTimes(4)
|
||||
await openPreviewFile(column, filesTab, preview, 'chinese.docx')
|
||||
await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
|
||||
await preview.getByRole('button', { name: 'Read the file again', exact: true }).click()
|
||||
await expect.poll(() => convert.mock.calls.length).toBe(7)
|
||||
await expect.poll(() => convert.mock.calls.length).toBe(5)
|
||||
await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
|
||||
await openPreviewFile(column, filesTab, preview, 'renamed.docx')
|
||||
await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
expect(await preview.locator('[data-textpreview-line]').count()).toBe(0)
|
||||
await successShot(page, 'office-invalid')
|
||||
expect(convert).toHaveBeenCalledTimes(8)
|
||||
for (const extension of ['doc', 'xls', 'ppt']) {
|
||||
expect(convert).toHaveBeenCalledTimes(6)
|
||||
for (const extension of ['doc', 'ppt']) {
|
||||
await openPreviewFile(column, filesTab, preview, `renamed.${extension}`)
|
||||
await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
expect(await preview.locator('[data-textpreview-line]').count()).toBe(0)
|
||||
}
|
||||
expect(convert).toHaveBeenCalledTimes(11)
|
||||
expect(convert).toHaveBeenCalledTimes(8)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally { convert.mockRestore() }
|
||||
})
|
||||
|
||||
@@ -26,3 +26,4 @@
|
||||
- switch "Developer tools"
|
||||
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
|
||||
- button "Queue"
|
||||
- text: "Current version: {{version}}"
|
||||
@@ -26,3 +26,4 @@
|
||||
- switch "Developer tools"
|
||||
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
|
||||
- button "Queue"
|
||||
- text: "Current version: {{version}}"
|
||||
@@ -26,3 +26,4 @@
|
||||
- switch "开发者工具"
|
||||
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
|
||||
- button "排队发送"
|
||||
- text: 当前版本:{{version}}
|
||||
@@ -0,0 +1,18 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "{{workspace}}" [expanded]
|
||||
- treeitem "New Session" [selected]
|
||||
- treeitem "{{workspace}} 1min"
|
||||
- treeitem "{{workspace}} 1min"
|
||||
- treeitem "{{workspace}} 1min"
|
||||
- treeitem "{{workspace}} 1min"
|
||||
- treeitem "{{workspace}} 1min"
|
||||
- treeitem "{{workspace}} Session actions for {{workspace}} Archive session Pin session":
|
||||
- text: {{workspace}}
|
||||
- button "Session actions for {{workspace}}"
|
||||
- button "Archive session"
|
||||
- button "Pin session"
|
||||
- treeitem "{{workspace}} 1min"
|
||||
- treeitem "{{workspace}} 1min"
|
||||
- treeitem "{{workspace}} 1min"
|
||||
- treeitem "{{workspace}} 1min"
|
||||
- button "Show 6 more sessions"
|
||||
@@ -9,4 +9,4 @@
|
||||
- treeitem "{{workspace}} 1min"
|
||||
- treeitem "{{workspace}} 1min"
|
||||
- treeitem "{{workspace}} 1min"
|
||||
- button "Show 1 more sessions"
|
||||
- button "Show 11 more sessions"
|
||||
@@ -29,6 +29,8 @@ const PLUGIN_INSTANCES_EXPECTED = join(SNAPSHOT_DIR, 'plugin-instances.expected.
|
||||
const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md')
|
||||
const PLUGIN_ROW_SELECTOR = '[data-plugin-scope="preset"] [data-plugin-entry="tool-subagent"]'
|
||||
const MODE = webSnapshotMode()
|
||||
const { version } = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as { version: string }
|
||||
const versionCapture = { replacements: [[version, '{{version}}']] as const }
|
||||
|
||||
describe('web e2e: settings modal and General preferences', () => {
|
||||
let scaffold: WebScaffold
|
||||
@@ -93,8 +95,9 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1)
|
||||
await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true)
|
||||
await page.unroute('**/api/settings/openSettingsDocument')
|
||||
await dialog.getByText(`当前版本:${version}`, { exact: true }).waitFor()
|
||||
// Golden of the freshly opened dialog (default zh, General active).
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, versionCapture)
|
||||
await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)
|
||||
// Section switch: aria-current moves (the Models page itself has its own scenario file).
|
||||
await dialog.getByRole('button', { name: '模型' }).click()
|
||||
@@ -647,6 +650,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
// ...and the attribute follows that switch, in the assembled app.
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.lang), { timeout: 5_000 }).toBe('en')
|
||||
expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
|
||||
await enDialog.getByText(`Current version: ${version}`, { exact: true }).waitFor()
|
||||
await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
|
||||
@@ -756,7 +760,8 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
// Golden of the English fallback dialog — the visible output this change
|
||||
// produces. The zh golden above covers the detected-locale surface, so
|
||||
// the pair pins both directions of the resolution.
|
||||
const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd)
|
||||
await dialog.getByText(`Current version: ${version}`, { exact: true }).waitFor()
|
||||
const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd, versionCapture)
|
||||
await compareOrRefreshGolden(DIALOG_EN_EXPECTED, snapshot, MODE)
|
||||
expect(frTripwire.pageErrors).toEqual([])
|
||||
expect(frTripwire.warnings).toEqual([])
|
||||
@@ -780,7 +785,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor()
|
||||
expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0)
|
||||
const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd)
|
||||
const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd, versionCapture)
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'dialog-no-browser.expected.md'), snapshot, MODE)
|
||||
expect(browserConsole.pageErrors).toEqual([])
|
||||
expect(browserConsole.warnings).toEqual([])
|
||||
|
||||
@@ -22,7 +22,7 @@ const EXPECTED_DIR = fileURLToPath(new URL('./expected/workspace-new-session-fol
|
||||
const SIDEBAR_EXPECTED = join(EXPECTED_DIR, 'sidebar.expected.md')
|
||||
const SEED = fileURLToPath(new URL('../../../snapshots/web/message-feedback-protocol/session.v3.jsonl', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const EXISTING_SESSION_COUNT = 6
|
||||
const EXISTING_SESSION_COUNT = 16
|
||||
|
||||
describe('web e2e: blank New Session folding quota', () => {
|
||||
let scaffold: WebScaffold
|
||||
@@ -72,7 +72,7 @@ describe('web e2e: blank New Session folding quota', () => {
|
||||
await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 15_000 }).toBe(7)
|
||||
expect(await sidebar.getByText('New Session', { exact: true }).count()).toBe(1)
|
||||
expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(6)
|
||||
const showMore = sidebar.getByRole('button', { name: 'Show 1 more sessions' })
|
||||
const showMore = sidebar.getByRole('button', { name: 'Show 11 more sessions' })
|
||||
await showMore.waitFor({ timeout: 15_000 })
|
||||
await compareOrRefreshGolden(
|
||||
SIDEBAR_EXPECTED,
|
||||
@@ -81,11 +81,20 @@ describe('web e2e: blank New Session folding quota', () => {
|
||||
)
|
||||
|
||||
await showMore.click()
|
||||
await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 10_000 }).toBe(8)
|
||||
expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(7)
|
||||
await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 10_000 }).toBe(12)
|
||||
expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(11)
|
||||
await compareOrRefreshGolden(
|
||||
join(EXPECTED_DIR, 'first-batch.expected.md'),
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
await sidebar.getByRole('button', { name: 'Show 6 more sessions' }).click()
|
||||
await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(17)
|
||||
await sidebar.getByRole('button', { name: 'Show 1 more sessions' }).click()
|
||||
await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(18)
|
||||
await sidebar.getByRole('button', { name: 'Show less' }).click()
|
||||
await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(7)
|
||||
await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md'])
|
||||
await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/api-gateway.md
|
||||
api-gateway.md: aa8339219a563901d5d132f4f401325ca30a3d53
|
||||
api-gateway.zh.md: 99b2d9866d38e520880e5d13115c2e6370fca474
|
||||
api-gateway.md: 52e91c48104ac8a8a46ad27f2b329ca72d6abc06
|
||||
api-gateway.zh.md: 9f062eaa6e6618354afa9ed1101e9cf6ed67d465
|
||||
+4
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](api-gateway.zh.md)
|
||||
|
||||
This is the current-state reference for the Typert API Gateway. It describes how business services declare unary Remote methods, how the build generates Host and Client contracts, and how calls reuse the Connection RPC and `/api` route. Session events, incremental data, and other streaming protocols are outside this document's scope; they may use the same Connection but do not use Remote method descriptors.
|
||||
This is the current-state reference for the Typert API Gateway. It describes how business services declare unary and stream Remote methods, how the build generates Host and Client contracts, and how calls reuse the Connection RPC and `/api` route. Session events, incremental data, and other streaming protocols are outside this document's scope; they may use the same Connection but do not use Remote method descriptors.
|
||||
|
||||
## Programming model
|
||||
|
||||
@@ -55,6 +55,8 @@ export class GoalService extends TypertRemoteService {
|
||||
|
||||
Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`.
|
||||
|
||||
`@Remote({ mode: 'stream' })` marks a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream<Out, In>`: the Gateway delivers each yielded item, a value the Host method produced, over its multiplexed `/api/remote.mux` WebSocket or an in-process carrier, and the generated Client method returns a `RemoteStreamHandle<Out, In>` that iterates the items and exposes `send`, `end`, and `dispose` for the Client-to-Host uplink of the same logical stream. The second type argument of `RemoteStream<Out, In>` declares the uplink item type; the Gateway validates each item the Client sends, because it arrives from the browser, with the generated `In` codec before the Host method reads it through `this.ctx.invocation.uplink<In>()`. The uplink enters neither `args` nor the parameter list; the [Gateway README](../packages/api/gateway/README.md) owns the frame, half-close, cancellation, and inbox-bound contracts.
|
||||
|
||||
The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.<namespace>` and `agentCtx.remote.<namespace>`. Each namespace is a traced Cordis child Service registered as `remote.<namespace>`; the Client assembly mounts contributions through `ctx.remote.$mount()`, and the namespace unloads after its last method is withdrawn. Dependency declarations belong to the actual caller: only a business package that reads `ctx.remote.<namespace>` or `agentCtx.remote.<namespace>` declares both `remote` and `remote.<namespace>` in its own `inject`; assemblies that only mount contributions and higher-level runtimes that do not call that namespace do not declare the namespace dependency on the business package's behalf. When an `@Remote` method has exactly one lookup parameter and a same-named `TypertContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface.
|
||||
|
||||
```ts ignore-check
|
||||
@@ -156,7 +158,7 @@ The running Client watcher consumes these generated files when it rebundles. If
|
||||
|
||||
## Boundaries
|
||||
|
||||
Remote handles only unary method calls with one request and one result. Session event streams, pagination, incremental reduce, projection, and entity substreams require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors.
|
||||
Remote handles unary method calls with one request and one result, and stream methods whose items flow Host → Client while the Client's items reach the running Host method through `this.ctx.invocation.uplink()`. Session event streams, pagination, incremental reduce, projection, and entity substreams still require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors.
|
||||
|
||||
The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. A feature that needs a streamed or browser-native response registers an exact Connection Fetch route instead of defining a Remote method.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](api-gateway.md) | 中文
|
||||
|
||||
本文是 Typert API Gateway 的当前状态参考。它描述业务服务如何声明一元 Remote 方法、构建如何生成 Host 与 Client 约定,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。
|
||||
本文是 Typert API Gateway 的当前状态参考。它描述业务服务如何声明一元与流式 Remote 方法、构建如何生成 Host 与 Client 约定,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。
|
||||
|
||||
## 编程模型
|
||||
|
||||
@@ -55,6 +55,8 @@ export class GoalService extends TypertRemoteService {
|
||||
|
||||
Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。
|
||||
|
||||
`@Remote({ mode: 'stream' })` 标记返回 `Iterable`、`AsyncIterable` 或 `RemoteStream<Out, In>` 的方法:Gateway 把每个产出的项(Host 方法产出的值)经其多路复用的 `/api/remote.mux` WebSocket 或进程内载体投递,Client 生成的方法返回一个 `RemoteStreamHandle<Out, In>`,它迭代这些项,并为同一条逻辑流上从 Client 到 Host 的上行提供 `send`、`end` 与 `dispose`。`RemoteStream<Out, In>` 的第二个类型参数声明上行项类型;Gateway 在 Host 方法通过 `this.ctx.invocation.uplink<In>()` 读取之前,用生成的 `In` codec 校验 Client 发送的每一项——它们来自浏览器。上行既不进入 `args` 也不进入参数列表;帧、半关闭、取消与 inbox 上限的约定由 [Gateway README](../packages/api/gateway/README.zh.md) 拥有。
|
||||
|
||||
Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.<namespace>` 和 `agentCtx.remote.<namespace>`。每个 namespace 都是注册为 `remote.<namespace>` 的可追踪 Cordis 子服务;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,最后一个方法撤回后该 namespace 随即卸载。依赖声明归实际调用方所有:只有读取 `ctx.remote.<namespace>` 或 `agentCtx.remote.<namespace>` 的业务包才在自己的 `inject` 中同时声明 `remote` 与 `remote.<namespace>`;只负责挂载 contribution 的 assembly,以及不调用该 namespace 的上层运行时,不代业务包声明 namespace 依赖。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypertContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用接口。
|
||||
|
||||
```ts ignore-check
|
||||
@@ -156,7 +158,7 @@ pnpm run build:lib
|
||||
|
||||
## 边界
|
||||
|
||||
Remote 只处理有单个请求与单个结果的一元方法调用。会话事件流、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。
|
||||
Remote 处理单请求单结果的一元方法调用,以及项沿 Host → Client 方向流动、而 Client 的项经 `this.ctx.invocation.uplink()` 到达正在运行的 Host 方法的流方法。会话事件流、分页、增量 reduce、projection 和实体子流仍需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。
|
||||
|
||||
API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。需要流式或浏览器原生响应的功能注册精确的 Connection Fetch 路由,而不定义 Remote 方法。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: 460d7a69837cdc43fe089b164b4d889b7e696650
|
||||
config-catalog.zh.md: 46b6a74511c89e806416770318a8244a94a45964
|
||||
config-catalog.md: 2927b510976631695978f46ed3c012641b4d73a9
|
||||
config-catalog.zh.md: 6d57dbe759fee5eaea9e755731fcf22666e8f0f3
|
||||
@@ -194,10 +194,12 @@ Requires: `typert`
|
||||
export interface Config {
|
||||
/** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */
|
||||
readonly websocketHeartbeatIntervalMs?: number
|
||||
/** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */
|
||||
readonly streamInboxBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts)
|
||||
Source: [`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-session-controller"></a>
|
||||
|
||||
@@ -428,7 +430,7 @@ export interface ConnectionRecoveryConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/client/connection/src/index.ts:87`](../packages/client/connection/src/index.ts)
|
||||
Source: [`packages/client/connection/src/index.ts:90`](../packages/client/connection/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-client-hmr"></a>
|
||||
|
||||
|
||||
@@ -196,10 +196,12 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.zh.md)
|
||||
export interface Config {
|
||||
/** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */
|
||||
readonly websocketHeartbeatIntervalMs?: number
|
||||
/** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */
|
||||
readonly streamInboxBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts)
|
||||
来源:[`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-session-controller"></a>
|
||||
|
||||
@@ -430,7 +432,7 @@ export interface ConnectionRecoveryConfig {
|
||||
}
|
||||
```
|
||||
|
||||
来源: [`packages/client/connection/src/index.ts:87`](../packages/client/connection/src/index.ts)
|
||||
来源: [`packages/client/connection/src/index.ts:90`](../packages/client/connection/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-client-hmr"></a>
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: 0fa3e7ffaae73a714bcffc6b7f8cd69e24f5e0c4
|
||||
event-producer-consumer.zh.md: 9f14a99ad80959be9963821100a6228700eca2b8
|
||||
event-producer-consumer.md: 619103ab6f813fc9ea915cc4f6bf5b6ae585f8cb
|
||||
event-producer-consumer.zh.md: 00a13ca2aa785dd253263f154627e6dd8accacc6
|
||||
@@ -30,7 +30,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`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `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:62`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - |
|
||||
| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:65`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - |
|
||||
| `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../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:386`](../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:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `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:62`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - |
|
||||
| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:65`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - |
|
||||
| `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../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:386`](../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:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: fdf66c998a1c108cbb32f273c1b40f6a1e4e08b9
|
||||
module-graph.zh.md: 980c7a342aff850a41d33c720a9bdec81a98c114
|
||||
module-graph.md: 684f82c87648bbd6fa8a1e7f45405a2c2d4d9885
|
||||
module-graph.zh.md: cf3bca4f97c840a052399bd3765e7cf11270c396
|
||||
+13
-10
@@ -414,16 +414,11 @@ flowchart TD
|
||||
pkg_credentials --> pkg_invariants
|
||||
pkg_experimental_inspector --> pkg_client_modules
|
||||
pkg_experimental_inspector --> pkg_host_webserver
|
||||
pkg_experimental_webworker_runtime --> pkg_client_connection
|
||||
pkg_experimental_webworker_runtime --> pkg_client_modules
|
||||
pkg_experimental_webworker_runtime --> pkg_host_webserver
|
||||
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
|
||||
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
|
||||
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
|
||||
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
|
||||
pkg_host_directory_picker_auto --> pkg_host_webserver
|
||||
pkg_host_frontend_static --> pkg_client_connection
|
||||
pkg_host_frontend_static --> pkg_host_webserver
|
||||
pkg_anonymous_user_id --> pkg_brand
|
||||
pkg_anonymous_user_id --> pkg_home_paths
|
||||
pkg_lsp --> pkg_brand
|
||||
@@ -433,6 +428,7 @@ flowchart TD
|
||||
pkg_storage_json --> pkg_storage
|
||||
pkg_storage_sqlite --> pkg_storage
|
||||
pkg_subprocess --> pkg_http_proxy
|
||||
pkg_remote_mock --> pkg_typert_protocol
|
||||
pkg_typert_loader --> pkg_typert_registry
|
||||
pkg_session --> pkg_scope
|
||||
pkg_system_prompt --> pkg_invariants
|
||||
@@ -451,6 +447,7 @@ flowchart TD
|
||||
pkg_api_terminal_controller --> pkg_subprocess
|
||||
pkg_attachment_local --> pkg_attachment
|
||||
pkg_attachment_local --> pkg_home_paths
|
||||
pkg_client_connection --> pkg_scope
|
||||
pkg_client_file_upload --> pkg_scope
|
||||
pkg_authorization --> pkg_credentials
|
||||
pkg_authorization --> pkg_invariants
|
||||
@@ -468,9 +465,15 @@ flowchart TD
|
||||
pkg_spill --> pkg_brand
|
||||
pkg_spill --> pkg_llm
|
||||
pkg_spill --> pkg_session
|
||||
pkg_api_gateway --> pkg_client_connection
|
||||
pkg_app_boot --> pkg_home_paths
|
||||
pkg_app_boot --> pkg_launch_environment
|
||||
pkg_app_boot --> pkg_system_prompt
|
||||
pkg_experimental_webworker_runtime --> pkg_client_connection
|
||||
pkg_experimental_webworker_runtime --> pkg_client_modules
|
||||
pkg_experimental_webworker_runtime --> pkg_host_webserver
|
||||
pkg_host_frontend_static --> pkg_client_connection
|
||||
pkg_host_frontend_static --> pkg_host_webserver
|
||||
pkg_persona --> pkg_system_prompt
|
||||
pkg_sandbox --> pkg_llm
|
||||
pkg_sandbox --> pkg_session
|
||||
@@ -1322,14 +1325,12 @@ flowchart TD
|
||||
| [`util-workspace-path`](../packages/util/workspace-path) | `util` | — |
|
||||
| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — |
|
||||
| [`llm`](../packages/llm/llm) | `llm` | — |
|
||||
| [`api-gateway`](../packages/api/gateway) | `api` | — |
|
||||
| [`api-workspace-files`](../packages/api/workspace-files) | `api` | — |
|
||||
| [`cmdline`](../packages/boot/cmdline) | `boot` | — |
|
||||
| [`acp-app`](../packages/bundle/acp-app) | `bundle` | — |
|
||||
| [`base`](../packages/bundle/base) | `bundle` | — |
|
||||
| [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — |
|
||||
| [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | — |
|
||||
| [`client-hmr`](../packages/client/hmr) | `client` | — |
|
||||
| [`client-locale`](../packages/client/locale) | `client` | — |
|
||||
| [`client-modules`](../packages/client/modules) | `client` | — |
|
||||
@@ -1401,7 +1402,6 @@ flowchart TD
|
||||
| [`storage`](../packages/storage/storage) | `storage` | — |
|
||||
| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — |
|
||||
| [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — |
|
||||
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — |
|
||||
| [`typert-generator`](../packages/typert/generator) | `typert` | — |
|
||||
| [`typert-protocol`](../packages/typert/protocol) | `typert` | — |
|
||||
| [`typert-registry`](../packages/typert/registry) | `typert` | — |
|
||||
@@ -1412,15 +1412,14 @@ flowchart TD
|
||||
| [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) |
|
||||
| [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) |
|
||||
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
|
||||
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) |
|
||||
| [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) |
|
||||
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) |
|
||||
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
|
||||
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
|
||||
| [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
@@ -1431,6 +1430,7 @@ flowchart TD
|
||||
| [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) |
|
||||
| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) |
|
||||
| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) |
|
||||
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
|
||||
@@ -1440,7 +1440,10 @@ flowchart TD
|
||||
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) |
|
||||
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
|
||||
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) |
|
||||
| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) |
|
||||
|
||||
+13
-10
@@ -416,16 +416,11 @@ flowchart TD
|
||||
pkg_credentials --> pkg_invariants
|
||||
pkg_experimental_inspector --> pkg_client_modules
|
||||
pkg_experimental_inspector --> pkg_host_webserver
|
||||
pkg_experimental_webworker_runtime --> pkg_client_connection
|
||||
pkg_experimental_webworker_runtime --> pkg_client_modules
|
||||
pkg_experimental_webworker_runtime --> pkg_host_webserver
|
||||
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
|
||||
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
|
||||
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
|
||||
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
|
||||
pkg_host_directory_picker_auto --> pkg_host_webserver
|
||||
pkg_host_frontend_static --> pkg_client_connection
|
||||
pkg_host_frontend_static --> pkg_host_webserver
|
||||
pkg_anonymous_user_id --> pkg_brand
|
||||
pkg_anonymous_user_id --> pkg_home_paths
|
||||
pkg_lsp --> pkg_brand
|
||||
@@ -435,6 +430,7 @@ flowchart TD
|
||||
pkg_storage_json --> pkg_storage
|
||||
pkg_storage_sqlite --> pkg_storage
|
||||
pkg_subprocess --> pkg_http_proxy
|
||||
pkg_remote_mock --> pkg_typert_protocol
|
||||
pkg_typert_loader --> pkg_typert_registry
|
||||
pkg_session --> pkg_scope
|
||||
pkg_system_prompt --> pkg_invariants
|
||||
@@ -453,6 +449,7 @@ flowchart TD
|
||||
pkg_api_terminal_controller --> pkg_subprocess
|
||||
pkg_attachment_local --> pkg_attachment
|
||||
pkg_attachment_local --> pkg_home_paths
|
||||
pkg_client_connection --> pkg_scope
|
||||
pkg_client_file_upload --> pkg_scope
|
||||
pkg_authorization --> pkg_credentials
|
||||
pkg_authorization --> pkg_invariants
|
||||
@@ -470,9 +467,15 @@ flowchart TD
|
||||
pkg_spill --> pkg_brand
|
||||
pkg_spill --> pkg_llm
|
||||
pkg_spill --> pkg_session
|
||||
pkg_api_gateway --> pkg_client_connection
|
||||
pkg_app_boot --> pkg_home_paths
|
||||
pkg_app_boot --> pkg_launch_environment
|
||||
pkg_app_boot --> pkg_system_prompt
|
||||
pkg_experimental_webworker_runtime --> pkg_client_connection
|
||||
pkg_experimental_webworker_runtime --> pkg_client_modules
|
||||
pkg_experimental_webworker_runtime --> pkg_host_webserver
|
||||
pkg_host_frontend_static --> pkg_client_connection
|
||||
pkg_host_frontend_static --> pkg_host_webserver
|
||||
pkg_persona --> pkg_system_prompt
|
||||
pkg_sandbox --> pkg_llm
|
||||
pkg_sandbox --> pkg_session
|
||||
@@ -1324,14 +1327,12 @@ flowchart TD
|
||||
| [`util-workspace-path`](../packages/util/workspace-path) | `util` | — |
|
||||
| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — |
|
||||
| [`llm`](../packages/llm/llm) | `llm` | — |
|
||||
| [`api-gateway`](../packages/api/gateway) | `api` | — |
|
||||
| [`api-workspace-files`](../packages/api/workspace-files) | `api` | — |
|
||||
| [`cmdline`](../packages/boot/cmdline) | `boot` | — |
|
||||
| [`acp-app`](../packages/bundle/acp-app) | `bundle` | — |
|
||||
| [`base`](../packages/bundle/base) | `bundle` | — |
|
||||
| [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — |
|
||||
| [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | — |
|
||||
| [`client-hmr`](../packages/client/hmr) | `client` | — |
|
||||
| [`client-locale`](../packages/client/locale) | `client` | — |
|
||||
| [`client-modules`](../packages/client/modules) | `client` | — |
|
||||
@@ -1403,7 +1404,6 @@ flowchart TD
|
||||
| [`storage`](../packages/storage/storage) | `storage` | — |
|
||||
| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — |
|
||||
| [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — |
|
||||
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — |
|
||||
| [`typert-generator`](../packages/typert/generator) | `typert` | — |
|
||||
| [`typert-protocol`](../packages/typert/protocol) | `typert` | — |
|
||||
| [`typert-registry`](../packages/typert/registry) | `typert` | — |
|
||||
@@ -1414,15 +1414,14 @@ flowchart TD
|
||||
| [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) |
|
||||
| [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) |
|
||||
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
|
||||
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) |
|
||||
| [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) |
|
||||
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) |
|
||||
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
|
||||
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
|
||||
| [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
@@ -1433,6 +1432,7 @@ flowchart TD
|
||||
| [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) |
|
||||
| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) |
|
||||
| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) |
|
||||
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
|
||||
@@ -1442,7 +1442,10 @@ flowchart TD
|
||||
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) |
|
||||
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
|
||||
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) |
|
||||
| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/typert.md
|
||||
typert.md: 954c3dfb7e66b100132c6d4604c28f06fba04916
|
||||
typert.zh.md: dd2becdc43871ddbecdfb45909e076435ab5a447
|
||||
typert.md: a8192b319215957b8d373ec01f6f29d73e165e45
|
||||
typert.zh.md: 08d1e8f466ef1e516c04426165b8180bd9881c83
|
||||
+122
-3
@@ -85,7 +85,7 @@ interface InvocationDescriptor {
|
||||
readonly method: string
|
||||
/** Service member invoked when the exported method name is an alias. */
|
||||
readonly implementation?: string
|
||||
/** Absent for unary calls; stream calls validate and deliver every yielded item. */
|
||||
/** Absent for unary calls; stream calls deliver every yielded item as the Host produced it. */
|
||||
readonly mode?: 'stream'
|
||||
/** Receiver selection mode. */
|
||||
readonly invocation:
|
||||
@@ -105,6 +105,16 @@ interface InvocationDescriptor {
|
||||
}
|
||||
/** Ordered business parameters. */
|
||||
readonly parameters: readonly InvocationParameterDescriptor[]
|
||||
/**
|
||||
* Client-to-Host items of the same logical stream, generated from the `In`
|
||||
* type argument of the method's `RemoteStream<Out, In>` return type; absent
|
||||
* when `In` is `never`. The method reads the items through
|
||||
* `RemoteInvocation.uplink()`, so nothing enters the parameter list.
|
||||
*/
|
||||
readonly uplink?: {
|
||||
/** Codec validating every uplink item before `uplink()` delivers it. */
|
||||
readonly codec: TypertCodec
|
||||
}
|
||||
/** Transport cancellation injected after business parameters instead of entering wire args. */
|
||||
readonly cancellation?: {
|
||||
/** Reserved final Host method parameter. */
|
||||
@@ -117,6 +127,106 @@ interface InvocationDescriptor {
|
||||
}
|
||||
```
|
||||
|
||||
The Host alias `RemoteStream<Out, In>` names both directions of one stream, and the receiving method reads its call context as `this.ctx.invocation`:
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One Remote stream as a Host method returns it: the items it yields to the
|
||||
* Client, iterated as a plain `AsyncIterable<Out>`. `In` is the type of the
|
||||
* items the Client may send back on the same logical stream, read through
|
||||
* `RemoteInvocation.uplink()`; it is carried only as a type-level marker. The
|
||||
* default `never` declares a method that reads none, and its descriptor
|
||||
* carries no uplink codec. A generated Client stream method returns the same
|
||||
* stream as a `RemoteStreamHandle<Out, In>`.
|
||||
* @template Out - item type the Host method yields.
|
||||
* @template In - item type the Client may send; `never` when the method reads none.
|
||||
*/
|
||||
type RemoteStream<Out, In = never> = AsyncIterable<Out> & { readonly [STREAM_UPLINK]?: In }
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One open Remote stream as the Client holds it: the downlink items as an
|
||||
* `AsyncIterable`, plus the uplink and cancellation of the same logical
|
||||
* stream. A generated Client stream method returns it, and calling that
|
||||
* method opens the stream: a holder that neither iterates nor disposes the
|
||||
* handle keeps the Host stream alive. A handle stands for one generation:
|
||||
* when the carrier is lost, iteration fails with the carrier error and the
|
||||
* handle is finished.
|
||||
* @template Out - item type the Host method yields.
|
||||
* @template In - item type the Client may send; `never` when the method reads none.
|
||||
*/
|
||||
interface RemoteStreamHandle<Out, In> extends AsyncIterable<Out> {
|
||||
/**
|
||||
* Send one uplink item. Items sent before the stream has opened are queued
|
||||
* and sent once the `open` frame is on the wire. A top-level `undefined`
|
||||
* travels as an `item` frame without `value`.
|
||||
* @param item - item the Host validates against the method's uplink codec.
|
||||
* @throws {Error} when the item is not a lossless JSON value, when `end()`
|
||||
* was called, or once the stream has terminated.
|
||||
*/
|
||||
send(item: In): void
|
||||
/** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */
|
||||
end(): void
|
||||
/**
|
||||
* Cancel the logical stream: send `cancel` unless a terminal frame has
|
||||
* arrived, and end the downlink iterator quietly. Breaking out of
|
||||
* `for await` early does the same.
|
||||
*/
|
||||
dispose(): void
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One Peer's session on this Host. Connection owns it: `ctx` is the Cordis
|
||||
* scope that owns connection-lifetime registrations and is disposed with the
|
||||
* Peer. Who the Peer is and what it may do are not recorded here.
|
||||
*/
|
||||
interface PeerScope {
|
||||
readonly id: PeerId
|
||||
readonly ctx: Context
|
||||
/**
|
||||
* Tear down every registration made through `ctx`.
|
||||
* @returns settles once the scope has quiesced; racing calls share one completion.
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* The context of one Remote call, reachable inside the receiving method as
|
||||
* `this.ctx.invocation`. The Gateway derives the receiver from a Context that
|
||||
* carries it, so no parameter is injected and nothing crosses the wire.
|
||||
*/
|
||||
interface RemoteInvocation {
|
||||
readonly request: {
|
||||
readonly namespace: string
|
||||
readonly method: string
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
}
|
||||
/** Cordis service key of the receiving Service. */
|
||||
readonly service: string
|
||||
/** Peer the call speaks for; an in-process carrier speaks for the operator. */
|
||||
readonly peer: PeerScope
|
||||
/** Carrier cancellation: Client cancel, socket close, or an uplink failure. */
|
||||
readonly signal: AbortSignal
|
||||
/**
|
||||
* The Client's uplink items for this call. Available once; a second call
|
||||
* throws. With an uplink codec on the descriptor every item is decoded to
|
||||
* `In`; without one items arrive as `unknown` after a JSON-safety check.
|
||||
* Iteration ends when the Client ends its uplink; when the method finishes
|
||||
* its downlink the Gateway calls the iterator's `return()` and unread items
|
||||
* are dropped. `In` is the caller's assertion: the runtime decodes by the
|
||||
* descriptor and does not cross-check it.
|
||||
* @template In - item type the caller reads; the descriptor codec decides what arrives.
|
||||
* @returns the single-consumer uplink iterable.
|
||||
*/
|
||||
uplink<In = unknown>(): AsyncIterable<In>
|
||||
}
|
||||
```
|
||||
|
||||
## Typert registry
|
||||
|
||||
`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, and scoped Context providers. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers.
|
||||
@@ -151,6 +261,13 @@ interface InvokeRemoteRequest {
|
||||
readonly method: string
|
||||
/** Named wire values; fields must exactly match the descriptor. */
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/**
|
||||
* Client uplink items of this logical stream, delivered to the method through
|
||||
* `invocation.uplink()`; absent means an immediately ended iterable.
|
||||
*/
|
||||
readonly uplink?: AsyncIterable<unknown>
|
||||
/** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */
|
||||
readonly peer?: PeerScope
|
||||
/** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
@@ -172,10 +289,12 @@ type TypertGatewayErrorCode =
|
||||
| 'gateway/lookup-not-found'
|
||||
| 'gateway/lookup-unavailable'
|
||||
| 'gateway/method-unavailable'
|
||||
| 'gateway/protocol'
|
||||
| 'gateway/provider-mismatch'
|
||||
| 'gateway/result-invalid'
|
||||
| 'gateway/service-unavailable'
|
||||
| 'gateway/signature-invalid'
|
||||
| 'gateway/uplink-overflow'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
@@ -202,7 +321,7 @@ interface TypertGateway {
|
||||
invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
/**
|
||||
* Open one live stream Remote method without assuming a physical carrier.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one.
|
||||
* @returns a cancellation-aware iterable over the business results.
|
||||
*/
|
||||
stream(request: InvokeRemoteRequest): Promise<AsyncIterable<unknown>>
|
||||
@@ -334,7 +453,7 @@ async invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
|
||||
/**
|
||||
* Open one live stream Remote method without assuming a physical carrier.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one.
|
||||
* @returns a cancellation-aware iterable over the business results.
|
||||
*/
|
||||
async stream(request: InvokeRemoteRequest): Promise<AsyncIterable<unknown>>
|
||||
|
||||
@@ -85,7 +85,7 @@ interface InvocationDescriptor {
|
||||
readonly method: string
|
||||
/** Service member invoked when the exported method name is an alias. */
|
||||
readonly implementation?: string
|
||||
/** Absent for unary calls; stream calls validate and deliver every yielded item. */
|
||||
/** Absent for unary calls; stream calls deliver every yielded item as the Host produced it. */
|
||||
readonly mode?: 'stream'
|
||||
/** Receiver selection mode. */
|
||||
readonly invocation:
|
||||
@@ -105,6 +105,16 @@ interface InvocationDescriptor {
|
||||
}
|
||||
/** Ordered business parameters. */
|
||||
readonly parameters: readonly InvocationParameterDescriptor[]
|
||||
/**
|
||||
* Client-to-Host items of the same logical stream, generated from the `In`
|
||||
* type argument of the method's `RemoteStream<Out, In>` return type; absent
|
||||
* when `In` is `never`. The method reads the items through
|
||||
* `RemoteInvocation.uplink()`, so nothing enters the parameter list.
|
||||
*/
|
||||
readonly uplink?: {
|
||||
/** Codec validating every uplink item before `uplink()` delivers it. */
|
||||
readonly codec: TypertCodec
|
||||
}
|
||||
/** Transport cancellation injected after business parameters instead of entering wire args. */
|
||||
readonly cancellation?: {
|
||||
/** Reserved final Host method parameter. */
|
||||
@@ -117,6 +127,106 @@ interface InvocationDescriptor {
|
||||
}
|
||||
```
|
||||
|
||||
Host 别名 `RemoteStream<Out, In>` 同时命名一条流的两个方向,接收方法通过 `this.ctx.invocation` 读取本次调用的上下文:
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One Remote stream as a Host method returns it: the items it yields to the
|
||||
* Client, iterated as a plain `AsyncIterable<Out>`. `In` is the type of the
|
||||
* items the Client may send back on the same logical stream, read through
|
||||
* `RemoteInvocation.uplink()`; it is carried only as a type-level marker. The
|
||||
* default `never` declares a method that reads none, and its descriptor
|
||||
* carries no uplink codec. A generated Client stream method returns the same
|
||||
* stream as a `RemoteStreamHandle<Out, In>`.
|
||||
* @template Out - item type the Host method yields.
|
||||
* @template In - item type the Client may send; `never` when the method reads none.
|
||||
*/
|
||||
type RemoteStream<Out, In = never> = AsyncIterable<Out> & { readonly [STREAM_UPLINK]?: In }
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One open Remote stream as the Client holds it: the downlink items as an
|
||||
* `AsyncIterable`, plus the uplink and cancellation of the same logical
|
||||
* stream. A generated Client stream method returns it, and calling that
|
||||
* method opens the stream: a holder that neither iterates nor disposes the
|
||||
* handle keeps the Host stream alive. A handle stands for one generation:
|
||||
* when the carrier is lost, iteration fails with the carrier error and the
|
||||
* handle is finished.
|
||||
* @template Out - item type the Host method yields.
|
||||
* @template In - item type the Client may send; `never` when the method reads none.
|
||||
*/
|
||||
interface RemoteStreamHandle<Out, In> extends AsyncIterable<Out> {
|
||||
/**
|
||||
* Send one uplink item. Items sent before the stream has opened are queued
|
||||
* and sent once the `open` frame is on the wire. A top-level `undefined`
|
||||
* travels as an `item` frame without `value`.
|
||||
* @param item - item the Host validates against the method's uplink codec.
|
||||
* @throws {Error} when the item is not a lossless JSON value, when `end()`
|
||||
* was called, or once the stream has terminated.
|
||||
*/
|
||||
send(item: In): void
|
||||
/** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */
|
||||
end(): void
|
||||
/**
|
||||
* Cancel the logical stream: send `cancel` unless a terminal frame has
|
||||
* arrived, and end the downlink iterator quietly. Breaking out of
|
||||
* `for await` early does the same.
|
||||
*/
|
||||
dispose(): void
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One Peer's session on this Host. Connection owns it: `ctx` is the Cordis
|
||||
* scope that owns connection-lifetime registrations and is disposed with the
|
||||
* Peer. Who the Peer is and what it may do are not recorded here.
|
||||
*/
|
||||
interface PeerScope {
|
||||
readonly id: PeerId
|
||||
readonly ctx: Context
|
||||
/**
|
||||
* Tear down every registration made through `ctx`.
|
||||
* @returns settles once the scope has quiesced; racing calls share one completion.
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* The context of one Remote call, reachable inside the receiving method as
|
||||
* `this.ctx.invocation`. The Gateway derives the receiver from a Context that
|
||||
* carries it, so no parameter is injected and nothing crosses the wire.
|
||||
*/
|
||||
interface RemoteInvocation {
|
||||
readonly request: {
|
||||
readonly namespace: string
|
||||
readonly method: string
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
}
|
||||
/** Cordis service key of the receiving Service. */
|
||||
readonly service: string
|
||||
/** Peer the call speaks for; an in-process carrier speaks for the operator. */
|
||||
readonly peer: PeerScope
|
||||
/** Carrier cancellation: Client cancel, socket close, or an uplink failure. */
|
||||
readonly signal: AbortSignal
|
||||
/**
|
||||
* The Client's uplink items for this call. Available once; a second call
|
||||
* throws. With an uplink codec on the descriptor every item is decoded to
|
||||
* `In`; without one items arrive as `unknown` after a JSON-safety check.
|
||||
* Iteration ends when the Client ends its uplink; when the method finishes
|
||||
* its downlink the Gateway calls the iterator's `return()` and unread items
|
||||
* are dropped. `In` is the caller's assertion: the runtime decodes by the
|
||||
* descriptor and does not cross-check it.
|
||||
* @template In - item type the caller reads; the descriptor codec decides what arrives.
|
||||
* @returns the single-consumer uplink iterable.
|
||||
*/
|
||||
uplink<In = unknown>(): AsyncIterable<In>
|
||||
}
|
||||
```
|
||||
|
||||
## Typert 注册表
|
||||
|
||||
`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方与作用域上下文提供方。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。
|
||||
@@ -151,6 +261,13 @@ interface InvokeRemoteRequest {
|
||||
readonly method: string
|
||||
/** Named wire values; fields must exactly match the descriptor. */
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/**
|
||||
* Client uplink items of this logical stream, delivered to the method through
|
||||
* `invocation.uplink()`; absent means an immediately ended iterable.
|
||||
*/
|
||||
readonly uplink?: AsyncIterable<unknown>
|
||||
/** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */
|
||||
readonly peer?: PeerScope
|
||||
/** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
@@ -172,10 +289,12 @@ type TypertGatewayErrorCode =
|
||||
| 'gateway/lookup-not-found'
|
||||
| 'gateway/lookup-unavailable'
|
||||
| 'gateway/method-unavailable'
|
||||
| 'gateway/protocol'
|
||||
| 'gateway/provider-mismatch'
|
||||
| 'gateway/result-invalid'
|
||||
| 'gateway/service-unavailable'
|
||||
| 'gateway/signature-invalid'
|
||||
| 'gateway/uplink-overflow'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
@@ -202,7 +321,7 @@ interface TypertGateway {
|
||||
invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
/**
|
||||
* Open one live stream Remote method without assuming a physical carrier.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one.
|
||||
* @returns a cancellation-aware iterable over the business results.
|
||||
*/
|
||||
stream(request: InvokeRemoteRequest): Promise<AsyncIterable<unknown>>
|
||||
@@ -334,7 +453,7 @@ async invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
|
||||
/**
|
||||
* Open one live stream Remote method without assuming a physical carrier.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one.
|
||||
* @returns a cancellation-aware iterable over the business results.
|
||||
*/
|
||||
async stream(request: InvokeRemoteRequest): Promise<AsyncIterable<unknown>>
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/web-server.md
|
||||
web-server.md: e74d51da21682c0271c42b0a4584558e41b52b26
|
||||
web-server.zh.md: da89262e7055dbf4d6ee044def4cb5a08ce4dfe4
|
||||
web-server.md: 7b4d3a17a66ce4c3736b6d5d3eadf887622057c0
|
||||
web-server.zh.md: 5a95bbaff4aae20c6df05529f4154e42ccefaa3d
|
||||
@@ -64,7 +64,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.connection` — `HostConnectionHandle`
|
||||
|
||||
Host `ctx.connection` shape consumed by transport-independent adapters.
|
||||
Host `ctx.connection` members consumed by transport-independent adapters.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -82,6 +82,14 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler
|
||||
*/
|
||||
requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection
|
||||
|
||||
/**
|
||||
* Admit one request: it passes {@link requestRejection} and speaks for the
|
||||
* operator, or it is refused with that status.
|
||||
* @param request - request headers from the HTTP or upgrade request.
|
||||
* @returns the operator Peer, or the rejection status.
|
||||
*/
|
||||
admit(request: ConnectionTrustRequest): PeerAdmission
|
||||
|
||||
/**
|
||||
* Authenticate one frontend index request, owning a token redirect or 401.
|
||||
* @param request - root or configured-index HTTP request.
|
||||
|
||||
@@ -64,7 +64,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.connection` — `HostConnectionHandle`
|
||||
|
||||
Host `ctx.connection` shape consumed by transport-independent adapters.
|
||||
Host `ctx.connection` members consumed by transport-independent adapters.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -82,6 +82,14 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler
|
||||
*/
|
||||
requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection
|
||||
|
||||
/**
|
||||
* Admit one request: it passes {@link requestRejection} and speaks for the
|
||||
* operator, or it is refused with that status.
|
||||
* @param request - request headers from the HTTP or upgrade request.
|
||||
* @returns the operator Peer, or the rejection status.
|
||||
*/
|
||||
admit(request: ConnectionTrustRequest): PeerAdmission
|
||||
|
||||
/**
|
||||
* Authenticate one frontend index request, owning a token redirect or 401.
|
||||
* @param request - root or configured-index HTTP request.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/README.md
|
||||
README.md: f1aefa7d4564539f94202e869bacb85857f939d5
|
||||
README.zh.md: 870bbd32552166fdbb06221baaf54b9a130c6069
|
||||
README.md: 79271f6f5f7a05ef4f6d5da63815dfe1ee5da3ef
|
||||
README.zh.md: d98a0ef73a1b14a5eea98084cfb4ef224a7747a9
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
The `api/` group provides the application's Remote layer: a Client environment can call the business capabilities running on the Host — manage goals, run commands, list the plugin inventory, discover file and session references — as typed method calls, and receive the results or forwarded Host events. `remotes` decides which capabilities are exposed and how each call reaches the right session's agent; `gateway` carries the calls and their results between Client and Host. The stack runs over the application's shared Connection; streaming session data is deliberately outside it.
|
||||
The `api/` group provides the application's Remote layer: a Client environment can call the business capabilities running on the Host — manage goals, run commands, list the plugin inventory, discover file and session references — as typed method calls, receive results or streams, send uplink items on an open stream, and observe forwarded Host events. `remotes` decides which capabilities are exposed and how each call reaches the right session's agent; `gateway` carries the calls and their results between Client and Host. The stack runs over the application's shared Connection; streaming session data is deliberately outside it.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -27,14 +27,14 @@ The packages below provide the Remote layer; the package READMEs own the exhaust
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — |
|
||||
| [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` |
|
||||
| [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams with their Client uplinks, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` |
|
||||
| [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` |
|
||||
| [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` |
|
||||
| [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` |
|
||||
| [`terminal-controller/`](terminal-controller/README.md) | Session-owned interactive shells, screen recovery and browser terminal control. | `ctx.terminalController` / `ctx.remote.terminal` |
|
||||
| [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the instrumented-operation `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
|
||||
|
||||
Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads.
|
||||
Remote calls run Client → Host over the application's shared Connection; stream items flow Host → Client on the Gateway mux, and a stream's uplink items flow Client → Host on the same logical stream. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-group"
|
||||
|
||||
## 概述
|
||||
|
||||
`api/` 组提供应用的 Remote 层:Client 环境可以调用运行在 Host 上的业务能力——管理目标、运行命令、查看插件清单、发现文件与会话引用——调用方式是类型化方法,并接收结果或转发的 Host 事件。`remotes` 决定暴露哪些能力、以及每次调用如何到达正确会话的 agent;`gateway` 在 Client 与 Host 之间承载调用及其结果。技术栈运行在应用共享的 Connection 之上;流式会话数据刻意不在其中。
|
||||
`api/` 组提供应用的 Remote 层:Client 环境可以调用运行在 Host 上的业务能力——管理目标、运行命令、查看插件清单、发现文件与会话引用——调用方式是类型化方法,可接收结果或流、在已打开的流上发送上行项,并观察转发的 Host 事件。`remotes` 决定暴露哪些能力、以及每次调用如何到达正确会话的 agent;`gateway` 在 Client 与 Host 之间承载调用及其结果。技术栈运行在应用共享的 Connection 之上;流式会话数据刻意不在其中。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -27,14 +27,14 @@ kind: "package-group"
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — |
|
||||
| [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、多路复用流与转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` |
|
||||
| [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、带 Client 上行的多路复用流,以及转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` |
|
||||
| [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` |
|
||||
| [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` |
|
||||
| [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` |
|
||||
| [`terminal-controller/`](terminal-controller/README.zh.md) | Session 拥有的交互式 shell、屏幕恢复和浏览器终端控制。 | `ctx.terminalController` / `ctx.remote.terminal` |
|
||||
| [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与已埋点操作的 `changes` 流——以及其上的 Client `file` 资源提供方。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
|
||||
|
||||
Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。
|
||||
Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上;流项沿 Host → Client 方向经 Gateway mux 传输,流的上行项则沿 Client → Host 方向走同一条逻辑流。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/gateway/README.md
|
||||
README.md: 1460f5a27062ffcac09c68358dc7b186241a1baa
|
||||
README.zh.md: 79ed379faad8498cfdf249a0accb437f6d5f98e3
|
||||
README.md: 996e68fd2b99989523b01272c693d4039fdc9f44
|
||||
README.zh.md: 23b83fbf2c7559434dfd47d6b06994a8d390d1ac
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams.
|
||||
Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams, each carrying a Client-to-Host uplink on the same logical stream.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -28,12 +28,14 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host
|
||||
|
||||
Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation.
|
||||
|
||||
The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged.
|
||||
The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, codecs, and the uplink: `gateway/uplink-overflow` for an uplink whose buffered frames exceed `streamInboxBytes`, `gateway/protocol` for an uplink item after `end`, and the codec code `gateway/input-invalid` for a rejected uplink item. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged.
|
||||
|
||||
A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type.
|
||||
|
||||
A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it.
|
||||
|
||||
Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream<Out, In>` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream<Out>` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). 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 unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly.
|
||||
|
||||
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 id="client-service-clientremote-ctx-key-remote"></a>
|
||||
@@ -43,7 +45,7 @@ The browser carrier accepts the shell-owned stream origin described by [Connecti
|
||||
|
||||
`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.
|
||||
|
||||
Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject.
|
||||
Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns a `RemoteStreamHandle<Out, In>` from `dsh-typert-protocol` and opens one logical stream when it is called, through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. The handle iterates the downlink once. `send(item)` queues an uplink item, sent after the `open` frame; `end()` half-closes the uplink; `dispose()` sends `cancel` unless a terminal frame has arrived and ends the iteration quietly. Breaking out of the iteration early disposes the handle; after the downlink terminates `send` throws and `end` is ignored. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject.
|
||||
|
||||
Every unary call resolves to `RemoteResult<T>` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. `carrierFailure(endpoint, error)` and `cancelledFailure(endpoint, cause)` build those two folded results, so a test stand-in for this face folds identically.
|
||||
|
||||
@@ -79,6 +81,7 @@ No direct effect; invoked business Services own any model-visible result.
|
||||
- Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key.
|
||||
- Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime.
|
||||
- `websocketHeartbeatIntervalMs` is both the Ping cadence and the Pong deadline. The Host terminates a peer that does not answer before the next interval, so a deployment whose event loop or network can stall longer than this interval must raise it.
|
||||
- Uplinks have no flow control beyond the bounded Host inbox: a Client that sends faster than the method reads, or that sends to a method never taking its uplink, fails its stream with `gateway/uplink-overflow`, and no uplink item is replayed across carrier generations; a domain that must resume an uplink carries its own acknowledgement cursor in the reopened request.
|
||||
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes。Connection 承载一元调用的请求关联、信任和响应 envelope,Gateway 则拥有多路复用的 Remote 流。
|
||||
为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes。Connection 承载一元调用的请求关联、信任和响应 envelope,Gateway 则拥有多路复用的 Remote 流,每条流都在同一条逻辑流上携带从 Client 到 Host 的上行(uplink)。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -28,12 +28,14 @@ kind: "package-reference"
|
||||
|
||||
严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的解析器:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 适配器解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。
|
||||
|
||||
Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。
|
||||
Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数、编解码器以及上行各自负责的故障:缓冲帧超过 `streamInboxBytes` 的上行报 `gateway/uplink-overflow`,`end` 之后的上行项报 `gateway/protocol`,被 codec 拒绝的上行项报编解码码 `gateway/input-invalid`。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。
|
||||
|
||||
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
|
||||
|
||||
流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。
|
||||
|
||||
每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream<Out, In>`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream<Out>` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。
|
||||
|
||||
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 读取。
|
||||
|
||||
<a id="client-service-clientremote-ctx-key-remote"></a>
|
||||
@@ -43,7 +45,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source
|
||||
|
||||
`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.<namespace>` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格 codec 的 Client 供值字段,都会在方法可调用前报错。
|
||||
|
||||
每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。
|
||||
每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `dsh-typert-protocol` 的 `RemoteStreamHandle<Out, In>`,并在被调用时就打开一条逻辑流,进程内 Connection 载体可用时通过它打开,否则通过共享的 Gateway WebSocket 打开。句柄只迭代下行一次。`send(item)` 把一个上行项入队,在 `open` 帧之后发出;`end()` 半关闭上行;`dispose()` 在未收到终止帧时发送 `cancel`,并让迭代安静结束。提前跳出迭代等价于 dispose 句柄;下行终止后 `send` 抛错,`end` 被忽略。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。
|
||||
|
||||
每次一元调用都解析为 `RemoteResult<T>`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。`carrierFailure(endpoint, error)` 与 `cancelledFailure(endpoint, cause)` 构造这两种折叠结果,测试里的替代实现据此采用相同的折叠方式。
|
||||
|
||||
@@ -79,6 +81,7 @@ Client waterfall 的 Context 解析保持同步。解析器可以返回借用的
|
||||
- lookup 解析器按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。
|
||||
- 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。
|
||||
- `websocketHeartbeatIntervalMs` 同时是 Ping 周期和 Pong 截止时间。对端未在下一周期前回复时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。
|
||||
- 上行除了有界的 Host inbox 之外没有流控:Client 发送快于方法读取,或发给从未取用 uplink 的方法时,其流以 `gateway/uplink-overflow` 失败;上行项不会跨载体代际重放,需要恢复上行的领域在重开的请求里自带确认游标。
|
||||
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -58,7 +58,8 @@
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { RemoteError, isRemoteUplinkItem, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
|
||||
export type { TypertGatewayFaultDetails } from '../remote-error-codes.ts'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
@@ -17,12 +17,14 @@ import type {
|
||||
TypertClientRemote,
|
||||
RemoteFailure,
|
||||
RemoteResult,
|
||||
RemoteStreamHandle,
|
||||
TypertCodec,
|
||||
TypertDisposer,
|
||||
TypertRemoteContribution,
|
||||
TypertRemoteEvent,
|
||||
} from '@deepseek-ai/dsh-typert-protocol'
|
||||
import {
|
||||
ClientUplinkQueue,
|
||||
RemoteStreamCarrierError,
|
||||
RemoteStreamMuxClient,
|
||||
} from './stream-client.ts'
|
||||
@@ -219,13 +221,14 @@ class ClientRemoteService extends Service implements ClientRemote {
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
uplink?: AsyncIterable<unknown>,
|
||||
noConnection = `client api: ${endpoint} has no active Connection`,
|
||||
): AsyncIterable<unknown> {
|
||||
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error(noConnection)
|
||||
const local = connection.rpc.open?.('/api', endpoint, payload, signal)
|
||||
const local = connection.rpc.open?.('/api', endpoint, payload, signal, uplink)
|
||||
return local === undefined
|
||||
? this.streams.open(endpoint, payload, signal)
|
||||
? this.streams.open(endpoint, payload, signal, uplink)
|
||||
: normalizeConnectionStream(local)
|
||||
}
|
||||
|
||||
@@ -387,7 +390,7 @@ class ClientRemoteService extends Service implements ClientRemote {
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
): Promise<RemoteResult<unknown>> | AsyncIterable<unknown> {
|
||||
): Promise<RemoteResult<unknown>> | RemoteStreamHandle<unknown, unknown> {
|
||||
if (scoped !== undefined) {
|
||||
const adapter = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
|
||||
const identity = adapter?.identity(callerCtx)
|
||||
@@ -418,8 +421,8 @@ class ClientRemoteService extends Service implements ClientRemote {
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
boundIdentity?: BoundContextIdentity,
|
||||
): Promise<RemoteResult<unknown>> | AsyncIterable<unknown> {
|
||||
if (descriptor.mode === 'stream') {
|
||||
): Promise<RemoteResult<unknown>> | RemoteStreamHandle<unknown, unknown> {
|
||||
if (descriptor.mode !== undefined) {
|
||||
return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity)
|
||||
}
|
||||
return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity)
|
||||
@@ -453,22 +456,27 @@ class ClientRemoteService extends Service implements ClientRemote {
|
||||
}
|
||||
}
|
||||
|
||||
private async *invokeStream(
|
||||
/** Open the logical stream now and hand back its handle; `send()` before the first read queues behind the `open` frame. */
|
||||
private invokeStream(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection | undefined,
|
||||
token: MountToken,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
boundIdentity?: BoundContextIdentity,
|
||||
): AsyncGenerator {
|
||||
): RemoteStreamHandle<unknown, unknown> {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
if (!token.active) throw new Error(withdrawn(endpoint).error.message)
|
||||
const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity)
|
||||
const stream = this.openRemoteStream(endpoint, { args: prepared.args }, prepared.signal)
|
||||
for await (const value of stream) {
|
||||
if (!mountActive(token)) throw new Error(withdrawn(endpoint).error.message)
|
||||
yield value
|
||||
}
|
||||
const generation = new AbortController()
|
||||
const uplink = new ClientUplinkQueue(endpoint)
|
||||
const downlink = this.openRemoteStream(
|
||||
endpoint,
|
||||
{ args: prepared.args },
|
||||
AbortSignal.any([prepared.signal, generation.signal]),
|
||||
uplink,
|
||||
)
|
||||
return new ClientStreamHandle(endpoint, downlink, uplink, generation, token)
|
||||
}
|
||||
|
||||
private prepareInvocation(
|
||||
@@ -521,12 +529,94 @@ class ClientRemoteService extends Service implements ClientRemote {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The handle a generated stream method returns: one generation of one logical
|
||||
* stream. The downlink is iterated once; `send`/`end` feed the uplink queue the
|
||||
* carrier pump drains; `dispose` aborts the generation and returns the carrier
|
||||
* iterator, which sends `cancel` unless a terminal frame arrived, drops what was
|
||||
* buffered, and ends the iteration quietly.
|
||||
*/
|
||||
class ClientStreamHandle implements RemoteStreamHandle<unknown, unknown> {
|
||||
private readonly downlink: AsyncIterator<unknown>
|
||||
private primed: Promise<IteratorResult<unknown>> | undefined
|
||||
private consumed = false
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly endpoint: string,
|
||||
downlink: AsyncIterable<unknown>,
|
||||
private readonly uplink: ClientUplinkQueue,
|
||||
private readonly generation: AbortController,
|
||||
private readonly token: MountToken,
|
||||
) {
|
||||
this.downlink = downlink[Symbol.asyncIterator]()
|
||||
// The carrier opens the logical stream on the first pull; pulling now puts
|
||||
// the `open` frame on the wire before any `send()`. The first read is kept
|
||||
// for the consumer, and a failure waits for it instead of surfacing here;
|
||||
// it also terminates the stream, so the queue closes and `send()` throws.
|
||||
this.primed = this.downlink.next()
|
||||
void this.primed.catch(() => { this.uplink.close() })
|
||||
}
|
||||
|
||||
send(item: unknown): void {
|
||||
if (!isRemoteUplinkItem(item)) throw new Error(`client api: ${this.endpoint} uplink item is not a lossless JSON value`)
|
||||
this.uplink.push(item)
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.uplink.end()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.uplink.close()
|
||||
this.generation.abort(new Error(`client api: ${this.endpoint} stream disposed`))
|
||||
// Returning the carrier iterator reaches its `finally` even while nobody
|
||||
// reads the downlink, so `cancel` goes out now and the pump stops.
|
||||
void Promise.resolve(this.downlink.return?.()).catch(() => undefined)
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<unknown> {
|
||||
if (this.consumed) throw new Error(`client api: ${this.endpoint} stream has one consumer`)
|
||||
this.consumed = true
|
||||
const iteration = this.iterate()
|
||||
return {
|
||||
next: () => iteration.next(),
|
||||
// A return before the first read ends a generator that never started, so its `finally` would not dispose.
|
||||
return: (value?: unknown) => {
|
||||
this.dispose()
|
||||
return iteration.return(value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private async *iterate(): AsyncGenerator {
|
||||
try {
|
||||
while (true) {
|
||||
const next = await (this.primed ?? this.downlink.next())
|
||||
this.primed = undefined
|
||||
if (this.disposed || next.done === true) return
|
||||
if (!mountActive(this.token)) throw new Error(withdrawn(this.endpoint).error.message)
|
||||
yield next.value
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.disposed) return
|
||||
throw error
|
||||
} finally {
|
||||
// Ending early is a dispose; a terminated stream accepts no more uplink.
|
||||
this.dispose()
|
||||
await this.downlink.return?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type InvokeRemote = (
|
||||
direct: DirectMethod | undefined,
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
args: readonly unknown[],
|
||||
) => Promise<RemoteResult<unknown>> | AsyncIterable<unknown>
|
||||
) => Promise<RemoteResult<unknown>> | RemoteStreamHandle<unknown, unknown>
|
||||
|
||||
class RemoteNamespaceService extends Service {
|
||||
private readonly methods = new Map<string, RemoteMethodRecord>()
|
||||
@@ -698,6 +788,7 @@ function requireStrictInputs(descriptor: InvocationDescriptor): void {
|
||||
for (const parameter of descriptor.parameters) {
|
||||
requireStrictCodec(parameter.codec, endpoint, parameter.wire)
|
||||
}
|
||||
if (descriptor.uplink !== undefined) requireStrictCodec(descriptor.uplink.codec, endpoint, 'uplink')
|
||||
if (descriptor.invocation.kind === 'context') {
|
||||
requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
TypertOwnedValue,
|
||||
TypertRemoteEvent,
|
||||
} from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { isTypertOwnedValue } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { isRemoteJsonValue, isTypertOwnedValue } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import {
|
||||
REMOTE_EVENT_RESULT_ENDPOINT,
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
isRemoteEventAgentId,
|
||||
isRemoteEventClientId,
|
||||
isRemoteEventId,
|
||||
isRemoteJsonValue,
|
||||
projectRemoteEventRejection,
|
||||
type RemoteEventClientId,
|
||||
type RemoteEventDownlinkFrame,
|
||||
|
||||
@@ -28,6 +28,21 @@ interface SocketWaiter {
|
||||
reject(error: unknown): void
|
||||
}
|
||||
|
||||
interface UplinkPump {
|
||||
/** Settles once the pump has stopped sending. */
|
||||
readonly done: Promise<void>
|
||||
/** Interrupt the pump, including a read blocked on the caller's iterator, and release the iterator. */
|
||||
stop(): void
|
||||
}
|
||||
|
||||
/** One open logical stream: its downlink frames and, once the `open` frame is out, its uplink pump. */
|
||||
interface LogicalStream {
|
||||
readonly inbox: StreamInbox
|
||||
pump: UplinkPump | undefined
|
||||
}
|
||||
|
||||
const UPLINK_DONE: IteratorReturnResult<undefined> = { value: undefined, done: true }
|
||||
|
||||
/**
|
||||
* Keep one physical WebSocket and share it among independently cancellable
|
||||
* Remote streams. A carrier that supplies an in-process stream opener never
|
||||
@@ -38,7 +53,7 @@ export class RemoteStreamMuxClient {
|
||||
private cancelCandidate: ((error: Error) => void) | undefined
|
||||
private keepAlive: Promise<void> | undefined
|
||||
private revision = 0
|
||||
private readonly streams = new Map<string, StreamInbox>()
|
||||
private readonly streams = new Map<string, LogicalStream>()
|
||||
private readonly waiters = new Set<SocketWaiter>()
|
||||
private running = false
|
||||
private disposed = false
|
||||
@@ -77,16 +92,20 @@ export class RemoteStreamMuxClient {
|
||||
* @param endpoint - Typert Remote stream endpoint.
|
||||
* @param payload - endpoint request encoded on the wire.
|
||||
* @param signal - cancellation for this logical stream.
|
||||
* @param uplink - the Client's items: each is sent as an `item` frame, its end as `end`; its `return()`
|
||||
* runs when the stream finishes, and its failure cancels the stream and fails the downlink.
|
||||
* @returns Host items until completion, cancellation, or failure.
|
||||
*/
|
||||
async *open(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
uplink?: AsyncIterable<unknown>,
|
||||
): AsyncGenerator {
|
||||
signal.throwIfAborted()
|
||||
const streamId = randomUUID()
|
||||
const inbox = new StreamInbox()
|
||||
const stream: LogicalStream = { inbox, pump: undefined }
|
||||
let carrier: WebSocket | undefined
|
||||
let opened = false
|
||||
let terminal = false
|
||||
@@ -96,9 +115,10 @@ export class RemoteStreamMuxClient {
|
||||
const socket = await this.waitForSocket(signal)
|
||||
signal.throwIfAborted()
|
||||
carrier = socket
|
||||
this.streams.set(streamId, inbox)
|
||||
this.streams.set(streamId, stream)
|
||||
this.send(socket, { type: 'open', streamId, endpoint, payload })
|
||||
opened = true
|
||||
if (uplink !== undefined) stream.pump = this.pumpUplink(socket, streamId, uplink, signal, inbox)
|
||||
while (true) {
|
||||
const frame = await inbox.next()
|
||||
signal.throwIfAborted()
|
||||
@@ -115,9 +135,65 @@ export class RemoteStreamMuxClient {
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort)
|
||||
this.streams.delete(streamId)
|
||||
stream.pump?.stop()
|
||||
if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) {
|
||||
this.send(carrier, { type: 'cancel', streamId })
|
||||
}
|
||||
// The old generation's pump has stopped before a supervisor reopens the next one.
|
||||
if (stream.pump !== undefined) await stream.pump.done
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the caller's uplink items on this generation's socket. `stop()`
|
||||
* interrupts a pump blocked on `uplink.next()` and releases the iterator: a
|
||||
* handle's queue closes at once, so `send()` throws from then on, and any
|
||||
* other iterator's `return()` is invoked without being awaited because a
|
||||
* generator blocked in `next()` only completes it once it yields.
|
||||
*/
|
||||
private pumpUplink(
|
||||
socket: WebSocket,
|
||||
streamId: string,
|
||||
uplink: AsyncIterable<unknown>,
|
||||
signal: AbortSignal,
|
||||
inbox: StreamInbox,
|
||||
): UplinkPump {
|
||||
const stopped = Promise.withResolvers<IteratorReturnResult<undefined>>()
|
||||
const interruption: IteratorReturnResult<undefined> = { value: undefined, done: true }
|
||||
const uplinkIterator = uplink[Symbol.asyncIterator]()
|
||||
const state = { stopping: false, exhausted: false, released: false }
|
||||
const release = (): void => {
|
||||
if (state.released || state.exhausted) return
|
||||
state.released = true
|
||||
if (uplink instanceof ClientUplinkQueue) uplink.close()
|
||||
void Promise.resolve().then(() => uplinkIterator.return?.()).catch(() => undefined)
|
||||
}
|
||||
const done = (async (): Promise<void> => {
|
||||
try {
|
||||
while (true) {
|
||||
const next = await Promise.race([uplinkIterator.next(), stopped.promise])
|
||||
if (state.stopping || next === interruption || signal.aborted || this.socket !== socket) return
|
||||
if (next.done === true) {
|
||||
state.exhausted = true
|
||||
break
|
||||
}
|
||||
this.send(socket, { type: 'item', streamId, value: next.value })
|
||||
}
|
||||
this.send(socket, { type: 'end', streamId })
|
||||
} catch (error) {
|
||||
inbox.fail(error)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
})()
|
||||
return {
|
||||
done,
|
||||
stop: () => {
|
||||
if (state.stopping) return
|
||||
state.stopping = true
|
||||
stopped.resolve(interruption)
|
||||
release()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +302,11 @@ export class RemoteStreamMuxClient {
|
||||
try {
|
||||
if (typeof data !== 'string') throw new Error('api gateway: Remote stream WebSocket requires text messages')
|
||||
const frame = parseRemoteStreamServerMessage(data)
|
||||
this.streams.get(frame.streamId)?.push(frame)
|
||||
const stream = this.streams.get(frame.streamId)
|
||||
if (stream === undefined) return
|
||||
stream.inbox.push(frame)
|
||||
// A terminal frame ends the uplink now, not on the consumer's next read.
|
||||
if (frame.type !== 'item') stream.pump?.stop()
|
||||
} catch (error) {
|
||||
const failure = new RemoteStreamCarrierError('api gateway: invalid Remote stream frame', { cause: error })
|
||||
this.failAll(failure)
|
||||
@@ -266,7 +346,10 @@ export class RemoteStreamMuxClient {
|
||||
}
|
||||
|
||||
private failAll(error: unknown): void {
|
||||
for (const stream of this.streams.values()) stream.fail(error)
|
||||
for (const stream of this.streams.values()) {
|
||||
stream.inbox.fail(error)
|
||||
stream.pump?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private send(socket: WebSocket, message: RemoteStreamClientMessage): void {
|
||||
@@ -303,6 +386,83 @@ class StreamInbox {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uplink items a stream handle queues for its carrier: the mux pump or the
|
||||
* in-process Host decoder iterates it as the stream's uplink. `end()` is the
|
||||
* Client half-close; `close()` marks the stream terminated, after which
|
||||
* `push()` throws. One consumer reads it, one read at a time.
|
||||
*/
|
||||
export class ClientUplinkQueue implements AsyncIterable<unknown>, AsyncIterator<unknown> {
|
||||
private readonly items = new Deque<unknown>()
|
||||
private ended = false
|
||||
private closed = false
|
||||
private wake: (() => void) | undefined
|
||||
|
||||
/** @param endpoint - canonical Remote endpoint named by failures. */
|
||||
constructor(private readonly endpoint: string) {}
|
||||
|
||||
/**
|
||||
* Queue one item for the carrier.
|
||||
* @param item - item the Host validates against the method's uplink codec.
|
||||
* @throws {Error} after `end()` or once the stream has terminated.
|
||||
*/
|
||||
push(item: unknown): void {
|
||||
if (this.closed) throw new Error(`client api: ${this.endpoint} stream has terminated`)
|
||||
if (this.ended) throw new Error(`client api: ${this.endpoint} uplink was ended`)
|
||||
this.items.pushBack(item)
|
||||
this.signal()
|
||||
}
|
||||
|
||||
/** Half-close: the carrier reads the queued items, then `end`. Idempotent; ignored after termination. */
|
||||
end(): void {
|
||||
if (this.ended || this.closed) return
|
||||
this.ended = true
|
||||
this.signal()
|
||||
}
|
||||
|
||||
/** The carrier stopped reading: the logical stream terminated or was disposed. Idempotent. */
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.items.clear()
|
||||
this.signal()
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<unknown> {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the next queued item, waiting for one; ends after `end()` or `close()`.
|
||||
* @returns the next item, or the end of the uplink.
|
||||
* @throws {Error} when a read is already pending.
|
||||
*/
|
||||
async next(): Promise<IteratorResult<unknown>> {
|
||||
while (true) {
|
||||
if (this.closed) return UPLINK_DONE
|
||||
if (this.items.size > 0) return { value: this.items.popFront(), done: false }
|
||||
if (this.ended) return UPLINK_DONE
|
||||
if (this.wake !== undefined) throw new Error(`client api: ${this.endpoint} uplink has one pending read`)
|
||||
await new Promise<void>((resolve) => { this.wake = resolve })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The carrier is done with the uplink: close it.
|
||||
* @returns the end of the uplink.
|
||||
*/
|
||||
return(): Promise<IteratorResult<unknown>> {
|
||||
this.close()
|
||||
return Promise.resolve(UPLINK_DONE)
|
||||
}
|
||||
|
||||
private signal(): void {
|
||||
const wake = this.wake
|
||||
this.wake = undefined
|
||||
wake?.()
|
||||
}
|
||||
}
|
||||
|
||||
function remoteStreamUrl(): string {
|
||||
// The mux route is registered absolute; a page resolves its document-relative
|
||||
// form against its own document base. A shell-owned Host on another origin
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service, symbols } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
|
||||
import { OperatorPeer, type ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
|
||||
import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
@@ -15,10 +15,13 @@ import z from '@deepseek-ai/schemastery'
|
||||
export type { TypertGatewayFaultDetails } from './remote-error-codes.ts'
|
||||
import {
|
||||
RemoteError,
|
||||
isRemoteJsonValue,
|
||||
remoteErrorOf,
|
||||
remoteMethods,
|
||||
type InvocationDescriptor,
|
||||
type InvocationParameterDescriptor,
|
||||
type PeerScope,
|
||||
type RemoteInvocation,
|
||||
type TypertCodec,
|
||||
type TypertGatewayBinding,
|
||||
} from '@deepseek-ai/dsh-typert-protocol'
|
||||
@@ -43,7 +46,6 @@ import {
|
||||
REMOTE_EVENT_RESULT_ENDPOINT,
|
||||
REMOTE_STREAM_MUX_PATH,
|
||||
isRemoteEventAgentId,
|
||||
isRemoteJsonValue,
|
||||
parseRemoteEventResult,
|
||||
projectRemoteEventRequest,
|
||||
restoreRemoteEventRejection,
|
||||
@@ -84,9 +86,22 @@ interface ResolvedBinding {
|
||||
interface PreparedInvocation {
|
||||
readonly endpoint: string
|
||||
readonly descriptor: InvocationDescriptor
|
||||
/** Service view bound to a Context carrying `invocation`, so the method reads it as `this.ctx.invocation`. */
|
||||
readonly receiver: object
|
||||
readonly args: readonly unknown[]
|
||||
readonly method: (...args: never[]) => unknown
|
||||
readonly invocation: GatewayInvocation
|
||||
}
|
||||
|
||||
/** Carrier inputs `GatewayInvocation.uplink()` decodes on first use. */
|
||||
interface UplinkSource {
|
||||
/** Carrier items; an immediately ended iterable when the carrier has none. */
|
||||
readonly source: AsyncIterable<unknown>
|
||||
/** Descriptor codec, or the JSON-safety codec when the descriptor declares no uplink. */
|
||||
readonly codec: TypertCodec
|
||||
readonly endpoint: string
|
||||
/** Fail the logical stream with a Remote failure as the reason. */
|
||||
readonly abort: (reason: unknown) => void
|
||||
}
|
||||
|
||||
interface RegisteredRemoteEventSource {
|
||||
@@ -114,15 +129,24 @@ type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
|
||||
type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
|
||||
const NEVER_ABORTED_SIGNAL = new AbortController().signal
|
||||
const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 2_000
|
||||
const DEFAULT_STREAM_INBOX_BYTES = 262_144
|
||||
const EMPTY_ASYNC_ITERABLE: AsyncIterable<never> = {
|
||||
[Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ value: undefined, done: true }) }),
|
||||
}
|
||||
const UPLINK_DONE: IteratorReturnResult<undefined> = { value: undefined, done: true }
|
||||
const SRC_JSON_CODEC: TypertCodec = { mode: 'src-json' }
|
||||
|
||||
/** Gateway transport configuration. */
|
||||
export interface Config {
|
||||
/** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */
|
||||
readonly websocketHeartbeatIntervalMs?: number
|
||||
/** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */
|
||||
readonly streamInboxBytes?: number
|
||||
}
|
||||
|
||||
interface ResolvedConfig extends Config {
|
||||
readonly websocketHeartbeatIntervalMs: number
|
||||
readonly streamInboxBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,15 +195,18 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
static Config: z<Config> = z.object({
|
||||
websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS)
|
||||
.default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS),
|
||||
streamInboxBytes: z.number().step(1).min(1).default(DEFAULT_STREAM_INBOX_BYTES),
|
||||
})
|
||||
|
||||
/** Carrier adapter shared by the WebSocket mux and local Host transports. */
|
||||
readonly wireStream: TypertGatewayWireStream = {
|
||||
open: (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal),
|
||||
open: (endpoint, payload, uplink, peer, signal) =>
|
||||
this.openWireStream(endpoint, payload, uplink, peer, signal, new AbortController()),
|
||||
failure: error => rpcError(error),
|
||||
}
|
||||
|
||||
private srcClaims: ReadonlySet<string> | undefined
|
||||
private inProcessOperator: PeerScope | undefined
|
||||
private remoteEvents: RegisteredRemoteEventSource | undefined
|
||||
private readonly remoteEventClients = new Map<RemoteEventClientId, RemoteEventClient>()
|
||||
private readonly pendingRemoteEvents = new Map<RemoteEventId, PendingRemoteEvent>()
|
||||
@@ -199,25 +226,27 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
connectionCtx.connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => this.claimsEndpoint(endpoint),
|
||||
(endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal),
|
||||
(endpoint, payload, signal, peer) => this.dispatchRpc(endpoint, payload, signal, peer),
|
||||
)
|
||||
})
|
||||
ctx.inject(['connection', 'webServer'], (webCtx) => {
|
||||
const mux = new RemoteStreamMuxServer(
|
||||
(endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal),
|
||||
(endpoint, payload, uplink, peer, control) =>
|
||||
this.openWireStream(endpoint, payload, uplink, peer, control.signal, control),
|
||||
this.wireStream.failure,
|
||||
resolved.websocketHeartbeatIntervalMs,
|
||||
resolved.streamInboxBytes,
|
||||
)
|
||||
webCtx.effect(() => {
|
||||
const route: WebUpgradeRoute = {
|
||||
path: REMOTE_STREAM_MUX_PATH,
|
||||
handler: (req, socket, head) => {
|
||||
const rejection = webCtx.connection.requestRejection(req)
|
||||
if (rejection !== undefined) {
|
||||
rejectRemoteStreamUpgrade(socket, rejection)
|
||||
const admission = webCtx.connection.admit(req)
|
||||
if ('rejection' in admission) {
|
||||
rejectRemoteStreamUpgrade(socket, admission.rejection)
|
||||
return
|
||||
}
|
||||
mux.handleUpgrade(req, socket, head)
|
||||
mux.handleUpgrade(req, socket, head, admission.peer)
|
||||
},
|
||||
}
|
||||
const unregister = webCtx.webServer.registerUpgrade(route)
|
||||
@@ -296,8 +325,8 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
async invoke(request: InvokeRemoteRequest): Promise<unknown> {
|
||||
const prepared = await this.prepareInvocation(request)
|
||||
if (prepared.descriptor.mode === 'stream') {
|
||||
const prepared = await this.prepareInvocation(request, new AbortController())
|
||||
if (prepared.descriptor.mode !== undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'gateway/signature-invalid',
|
||||
prepared.endpoint,
|
||||
@@ -310,17 +339,29 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
} catch (error) {
|
||||
if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error)
|
||||
throw error
|
||||
} finally {
|
||||
// A unary call's uplink is readable only while the method runs.
|
||||
await prepared.invocation.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one live stream Remote method without assuming a physical carrier.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one.
|
||||
* @returns a cancellation-aware iterable over the business results.
|
||||
*/
|
||||
async stream(request: InvokeRemoteRequest): Promise<AsyncIterable<unknown>> {
|
||||
const prepared = await this.prepareInvocation(request)
|
||||
if (prepared.descriptor.mode !== 'stream') {
|
||||
return this.openStream(request, new AbortController())
|
||||
}
|
||||
|
||||
/**
|
||||
* `control` belongs to the logical stream: a rejected uplink item aborts it
|
||||
* with the Remote failure as the reason so the carrier delivers that failure.
|
||||
*/
|
||||
private async openStream(request: InvokeRemoteRequest, control: AbortController): Promise<AsyncIterable<unknown>> {
|
||||
const prepared = await this.prepareInvocation(request, control)
|
||||
if (prepared.descriptor.mode === undefined) {
|
||||
await prepared.invocation.close()
|
||||
throw new TypertGatewayError(
|
||||
'gateway/signature-invalid',
|
||||
prepared.endpoint,
|
||||
@@ -331,10 +372,12 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
try {
|
||||
source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown
|
||||
} catch (error) {
|
||||
if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error)
|
||||
await prepared.invocation.close()
|
||||
if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error)
|
||||
throw error
|
||||
}
|
||||
if (!isIterable(source)) {
|
||||
await prepared.invocation.close()
|
||||
throw new TypertGatewayError(
|
||||
'gateway/result-invalid',
|
||||
prepared.endpoint,
|
||||
@@ -342,17 +385,14 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
{ field: 'result' },
|
||||
)
|
||||
}
|
||||
return cancellableStream(
|
||||
source,
|
||||
prepared.endpoint,
|
||||
request.signal ?? NEVER_ABORTED_SIGNAL,
|
||||
)
|
||||
return cancellableStream(source, prepared.endpoint, prepared.invocation)
|
||||
}
|
||||
|
||||
private async dispatchRpc(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
peer: PeerScope,
|
||||
): Promise<ConnectionRpcResult> {
|
||||
if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) {
|
||||
try {
|
||||
@@ -367,18 +407,36 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
return rpcFailure(error)
|
||||
}
|
||||
}
|
||||
return this.invokeRpc(endpoint, payload, signal)
|
||||
return this.invokeRpc(endpoint, payload, signal, peer)
|
||||
}
|
||||
|
||||
private async openWireStream(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
uplink: AsyncIterable<unknown>,
|
||||
peer: PeerScope | undefined,
|
||||
signal: AbortSignal,
|
||||
control: AbortController,
|
||||
): Promise<AsyncIterable<unknown>> {
|
||||
if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) {
|
||||
// A Gateway-owned stream reads no uplink: releasing it now keeps its items out of the bounded inbox.
|
||||
releaseUplink(uplink)
|
||||
return this.openRemoteEvents(payload, signal)
|
||||
}
|
||||
return this.stream(remoteRequest(endpoint, payload, signal))
|
||||
return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink }, control)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Peer an in-process carrier speaks for when it names none: the
|
||||
* operator's Peer when Connection is mounted, otherwise an operator scope the
|
||||
* Gateway owns for its own lifetime.
|
||||
* @returns the operator Peer.
|
||||
*/
|
||||
private operatorPeer(): PeerScope {
|
||||
const connection = this.ctx.get('connection')
|
||||
if (connection !== undefined) return connection.operator
|
||||
this.inProcessOperator ??= new OperatorPeer(this.ctx)
|
||||
return this.inProcessOperator
|
||||
}
|
||||
|
||||
private async *openRemoteEvents(
|
||||
@@ -582,9 +640,14 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
for (const client of [...this.remoteEventClients.values()]) client.queue.end()
|
||||
}
|
||||
|
||||
private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
|
||||
private async invokeRpc(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
peer: PeerScope,
|
||||
): Promise<ConnectionRpcResult> {
|
||||
try {
|
||||
const value = await this.invoke(remoteRequest(endpoint, payload, signal))
|
||||
const value = await this.invoke(remoteRequest(endpoint, payload, signal, peer))
|
||||
// A void or explicitly absent business result carries no `value` field;
|
||||
// JSON has no `undefined`, and the envelope's optional slot is the one
|
||||
// representation of absence that both args and results already use.
|
||||
@@ -594,7 +657,11 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareInvocation(request: InvokeRemoteRequest): Promise<PreparedInvocation> {
|
||||
/** `control` fails the logical stream when an uplink item is rejected; unary calls hand over an inert one. */
|
||||
private async prepareInvocation(
|
||||
request: InvokeRemoteRequest,
|
||||
control: AbortController,
|
||||
): Promise<PreparedInvocation> {
|
||||
const endpoint = endpointOf(request.namespace, request.method)
|
||||
const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
|
||||
assertExactArguments(request.args, descriptor, endpoint)
|
||||
@@ -610,9 +677,27 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
|
||||
const args = await Promise.all(descriptor.parameters.map(parameter =>
|
||||
this.resolveParameter(parameter, request.args, endpoint)))
|
||||
if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
|
||||
const signal = methodSignal(request, control)
|
||||
const invocation = new GatewayInvocation(
|
||||
{ namespace: request.namespace, method: request.method, args: request.args },
|
||||
descriptor.service,
|
||||
request.peer ?? this.operatorPeer(),
|
||||
signal,
|
||||
{
|
||||
source: request.uplink ?? EMPTY_ASYNC_ITERABLE,
|
||||
codec: descriptor.uplink?.codec ?? SRC_JSON_CODEC,
|
||||
endpoint,
|
||||
abort: (reason) => { control.abort(reason) },
|
||||
},
|
||||
)
|
||||
if (descriptor.cancellation !== undefined) args.push(signal)
|
||||
// The method runs on a Service view bound to a Context carrying this call:
|
||||
// Cordis rebinds `this.ctx` to the accessing Context, so `this.ctx.invocation`
|
||||
// is this call and nothing travels through the parameter list. The view
|
||||
// resolves the Service the plain read above already found.
|
||||
const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object
|
||||
const implementation = descriptor.implementation ?? descriptor.method
|
||||
const method: unknown = Reflect.get(receiver, implementation)
|
||||
const method: unknown = Reflect.get(callReceiver, implementation)
|
||||
if (typeof method !== 'function') {
|
||||
throw new TypertGatewayError(
|
||||
'gateway/method-unavailable',
|
||||
@@ -620,7 +705,14 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
`active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,
|
||||
)
|
||||
}
|
||||
return { endpoint, descriptor, receiver, args, method: method as (...args: never[]) => unknown }
|
||||
return {
|
||||
endpoint,
|
||||
descriptor,
|
||||
receiver: callReceiver,
|
||||
args,
|
||||
method: method as (...args: never[]) => unknown,
|
||||
invocation,
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
|
||||
@@ -933,7 +1025,12 @@ function parseRemoteEventResultPayload(payload: unknown): ReturnType<typeof pars
|
||||
return parseRemoteEventResult(payload.args)
|
||||
}
|
||||
|
||||
function remoteRequest(endpoint: string, payload: unknown, signal: AbortSignal): InvokeRemoteRequest {
|
||||
function remoteRequest(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
peer?: PeerScope,
|
||||
): InvokeRemoteRequest {
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
|
||||
throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
|
||||
@@ -947,7 +1044,20 @@ function remoteRequest(endpoint: string, payload: unknown, signal: AbortSignal):
|
||||
|| !isPlainObject(payload.args)) {
|
||||
throw new Error('Remote payload must contain exactly one plain-object args field')
|
||||
}
|
||||
return { namespace, method, args: payload.args, signal }
|
||||
return { namespace, method, args: payload.args, signal, ...(peer === undefined ? {} : { peer }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The signal a method observes. A carrier that supplies an uplink fails the
|
||||
* stream through `control` when an item is rejected, so that invocation joins
|
||||
* `control` with the carrier signal; every other invocation keeps the carrier
|
||||
* signal's identity.
|
||||
*/
|
||||
function methodSignal(request: InvokeRemoteRequest, control: AbortController): AbortSignal {
|
||||
const carrier = request.signal
|
||||
if (request.uplink === undefined) return carrier ?? NEVER_ABORTED_SIGNAL
|
||||
if (carrier === undefined || carrier === control.signal) return control.signal
|
||||
return AbortSignal.any([carrier, control.signal])
|
||||
}
|
||||
|
||||
function isIterable(value: unknown): value is Iterable<unknown> | AsyncIterable<unknown> {
|
||||
@@ -959,8 +1069,9 @@ function isIterable(value: unknown): value is Iterable<unknown> | AsyncIterable<
|
||||
async function *cancellableStream(
|
||||
source: Iterable<unknown> | AsyncIterable<unknown>,
|
||||
endpoint: string,
|
||||
signal: AbortSignal,
|
||||
invocation: GatewayInvocation,
|
||||
): AsyncGenerator {
|
||||
const { signal } = invocation
|
||||
const asyncFactory: unknown = Reflect.get(source, Symbol.asyncIterator)
|
||||
const syncFactory: unknown = Reflect.get(source, Symbol.iterator)
|
||||
const iterator = typeof asyncFactory === 'function'
|
||||
@@ -969,11 +1080,11 @@ async function *cancellableStream(
|
||||
let rejectAbort: ((error: unknown) => void) | undefined
|
||||
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
|
||||
const onAbort = (): void => {
|
||||
rejectAbort?.(remoteCancelled(endpoint, signal.reason))
|
||||
rejectAbort?.(streamAbortFailure(endpoint, signal.reason))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
if (signal.aborted) throw remoteCancelled(endpoint, signal.reason)
|
||||
if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason)
|
||||
while (true) {
|
||||
const next = await Promise.race([Promise.resolve(iterator.next()), aborted])
|
||||
if (next.done === true) return
|
||||
@@ -981,15 +1092,163 @@ async function *cancellableStream(
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
// The uplink closes first so a method blocked on `uplink.next()` unwinds
|
||||
// before its iterator is asked to return.
|
||||
await invocation.close()
|
||||
await iterator.return?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure a stream surfaces for its abort: a Remote failure used as the
|
||||
* reason is the Gateway or carrier failing the stream itself (a rejected,
|
||||
* overflowing, or misplaced uplink item); any other abort is a cancellation.
|
||||
*/
|
||||
function streamAbortFailure(endpoint: string, reason: unknown): unknown {
|
||||
return remoteErrorOf(reason) === undefined ? remoteCancelled(endpoint, reason) : reason
|
||||
}
|
||||
|
||||
/** Carrier-signal cancellation as the shared failure vocabulary expresses it. */
|
||||
function remoteCancelled(endpoint: string, cause: unknown): RemoteError<'gateway/cancelled'> {
|
||||
return new RemoteError('gateway/cancelled', `Remote invocation "${endpoint}" was aborted`, {}, { cause })
|
||||
}
|
||||
|
||||
/**
|
||||
* The iterable `invocation.uplink()` returns. Each uplink item passes the
|
||||
* descriptor codec, or the JSON-safety check when the descriptor declares no
|
||||
* uplink, before delivery; a rejected item fails the whole logical stream.
|
||||
* Iteration ends when the Client half-closes or the downlink finishes, and
|
||||
* fails when the stream is cancelled, so a method blocked on the uplink always
|
||||
* wakes.
|
||||
*/
|
||||
class UplinkDecoder implements AsyncIterable<unknown>, AsyncIterator<unknown> {
|
||||
private readonly source: AsyncIterator<unknown>
|
||||
private readonly interrupted = Promise.withResolvers<IteratorResult<unknown>>()
|
||||
private readonly onAbort = (): void => {
|
||||
this.interrupted.reject(streamAbortFailure(this.endpoint, this.signal.reason))
|
||||
}
|
||||
private closed = false
|
||||
|
||||
constructor(
|
||||
uplink: AsyncIterable<unknown>,
|
||||
private readonly codec: TypertCodec,
|
||||
private readonly endpoint: string,
|
||||
private readonly signal: AbortSignal,
|
||||
private readonly abort: (reason: unknown) => void,
|
||||
) {
|
||||
this.source = uplink[Symbol.asyncIterator]()
|
||||
// The rejection settles the race inside next(); an abort with no read in
|
||||
// flight must not surface as an unhandled rejection.
|
||||
void this.interrupted.promise.catch(() => undefined)
|
||||
signal.addEventListener('abort', this.onAbort, { once: true })
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<unknown> {
|
||||
return this
|
||||
}
|
||||
|
||||
async next(): Promise<IteratorResult<unknown>> {
|
||||
// A cancelled stream reports the cancellation on every read, closed or not.
|
||||
if (this.signal.aborted) throw streamAbortFailure(this.endpoint, this.signal.reason)
|
||||
if (this.closed) return UPLINK_DONE
|
||||
const next = await Promise.race([this.source.next(), this.interrupted.promise])
|
||||
if (next.done === true) {
|
||||
this.finish()
|
||||
return UPLINK_DONE
|
||||
}
|
||||
let value: unknown
|
||||
try {
|
||||
// A top-level `undefined` is the absent `value` of its frame: without a codec it is the item itself.
|
||||
value = next.value === undefined && this.codec.mode === 'src-json'
|
||||
? undefined
|
||||
: decode(this.codec, next.value, this.endpoint, 'uplink')
|
||||
} catch (failure) {
|
||||
// The codec failure (`gateway/input-invalid`, field `uplink`) fails the whole logical stream.
|
||||
this.abort(failure)
|
||||
throw failure
|
||||
}
|
||||
return { value, done: false }
|
||||
}
|
||||
|
||||
/** Downlink finished or the method stopped reading: unread uplink items are dropped. */
|
||||
return(): Promise<IteratorResult<unknown>> {
|
||||
if (!this.closed) {
|
||||
this.finish()
|
||||
// The carrier owns the source iterator; a generator blocked in next()
|
||||
// completes this return once it yields, so it is not awaited here.
|
||||
void Promise.resolve().then(() => this.source.return?.()).catch(() => undefined)
|
||||
}
|
||||
return Promise.resolve(UPLINK_DONE)
|
||||
}
|
||||
|
||||
private finish(): void {
|
||||
this.closed = true
|
||||
this.signal.removeEventListener('abort', this.onAbort)
|
||||
this.interrupted.resolve(UPLINK_DONE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The context of one Remote call as the receiving method reads it through
|
||||
* `this.ctx.invocation`. The uplink is decoded on first use and released when
|
||||
* the call's downlink finishes.
|
||||
*/
|
||||
class GatewayInvocation implements RemoteInvocation {
|
||||
private decoder: UplinkDecoder | undefined
|
||||
private taken = false
|
||||
|
||||
/**
|
||||
* @param request - decoded endpoint and wire arguments.
|
||||
* @param service - Cordis service key of the receiver.
|
||||
* @param peer - Peer the call speaks for.
|
||||
* @param signal - the signal the method observes.
|
||||
* @param uplink - carrier items and the codec that decodes them.
|
||||
*/
|
||||
constructor(
|
||||
readonly request: RemoteInvocation['request'],
|
||||
readonly service: string,
|
||||
readonly peer: PeerScope,
|
||||
readonly signal: AbortSignal,
|
||||
private readonly uplink_: UplinkSource,
|
||||
) {}
|
||||
|
||||
uplink<In = unknown>(): AsyncIterable<In> {
|
||||
if (this.taken) {
|
||||
throw new Error(`typert gateway: ${this.uplink_.endpoint}: invocation.uplink() is available once per call`)
|
||||
}
|
||||
this.taken = true
|
||||
const { source, codec, endpoint, abort } = this.uplink_
|
||||
this.decoder = new UplinkDecoder(source, codec, endpoint, this.signal, abort)
|
||||
// The descriptor codec decides what arrives; `In` is the caller's assertion.
|
||||
return this.decoder as AsyncIterable<In>
|
||||
}
|
||||
|
||||
/**
|
||||
* The downlink finished: release the uplink. Unread items are dropped, and a
|
||||
* carrier iterable the method never took is returned so it stops producing;
|
||||
* a later `uplink()` throws like a second one would.
|
||||
* @returns settles once a taken uplink has closed.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.decoder !== undefined) {
|
||||
await this.decoder.return()
|
||||
return
|
||||
}
|
||||
this.taken = true
|
||||
releaseUplink(this.uplink_.source)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a carrier uplink nobody will read, so it drops later items instead of
|
||||
* buffering them. The carrier owns the iterator and `return()` is not awaited:
|
||||
* a generator blocked in `next()` completes it only once it yields.
|
||||
* @param source - the carrier's uplink iterable.
|
||||
*/
|
||||
function releaseUplink(source: AsyncIterable<unknown>): void {
|
||||
void Promise.resolve().then(() => source[Symbol.asyncIterator]().return?.()).catch(() => undefined)
|
||||
}
|
||||
|
||||
function rpcFailure(error: unknown): ConnectionRpcResult {
|
||||
const remote = remoteErrorOf(error)
|
||||
if (remote !== undefined) {
|
||||
|
||||
@@ -27,9 +27,11 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
'gateway/lookup-not-found': TypertGatewayFaultDetails
|
||||
'gateway/lookup-unavailable': TypertGatewayFaultDetails
|
||||
'gateway/method-unavailable': TypertGatewayFaultDetails
|
||||
'gateway/protocol': TypertGatewayFaultDetails
|
||||
'gateway/provider-mismatch': TypertGatewayFaultDetails
|
||||
'gateway/result-invalid': TypertGatewayFaultDetails
|
||||
'gateway/service-unavailable': TypertGatewayFaultDetails
|
||||
'gateway/signature-invalid': TypertGatewayFaultDetails
|
||||
'gateway/uplink-overflow': TypertGatewayFaultDetails
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Wire messages for Gateway-owned Remote streams and event-result RPCs. */
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import { isRemoteJsonValue } from '@deepseek-ai/dsh-typert-protocol'
|
||||
|
||||
/** Exact WebSocket route carrying every Typert Remote stream. */
|
||||
export const REMOTE_STREAM_MUX_PATH = '/api/remote.mux'
|
||||
@@ -203,15 +204,6 @@ export function restoreRemoteEventRejection(rejection: RemoteEventRejection): Er
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a value crosses JSON transport without coercion or omission.
|
||||
* @param value - candidate boundary value.
|
||||
* @returns whether the value is losslessly JSON-compatible.
|
||||
*/
|
||||
export function isRemoteJsonValue(value: unknown): boolean {
|
||||
return visitJsonValue(value, new Set<object>())
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize a non-empty Remote Event correlation id at a wire boundary.
|
||||
* @param value - untrusted wire value.
|
||||
@@ -239,7 +231,7 @@ export function isRemoteEventAgentId(value: unknown): value is RemoteEventAgentI
|
||||
return typeof value === 'string' && value.length > 0
|
||||
}
|
||||
|
||||
/** One logical stream request sent from the browser. */
|
||||
/** One logical stream request sent from the browser: open, one uplink item, uplink half-close, or cancel. */
|
||||
export type RemoteStreamClientMessage =
|
||||
| {
|
||||
readonly type: 'open'
|
||||
@@ -247,6 +239,8 @@ export type RemoteStreamClientMessage =
|
||||
readonly endpoint: string
|
||||
readonly payload: unknown
|
||||
}
|
||||
| { readonly type: 'item'; readonly streamId: string; readonly value?: unknown }
|
||||
| { readonly type: 'end'; readonly streamId: string }
|
||||
| { readonly type: 'cancel'; readonly streamId: string }
|
||||
|
||||
/** Carrier-safe failure delivered by the Host. */
|
||||
@@ -269,7 +263,15 @@ export type RemoteStreamServerMessage =
|
||||
*/
|
||||
export function parseRemoteStreamClientMessage(text: string): RemoteStreamClientMessage {
|
||||
return parseMessage(text, (value) => {
|
||||
if (value.type === 'cancel' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) {
|
||||
if ((value.type === 'cancel' || value.type === 'end')
|
||||
&& exactKeys(value, ['type', 'streamId'])
|
||||
&& validId(value.streamId)) {
|
||||
return value as RemoteStreamClientMessage
|
||||
}
|
||||
if (value.type === 'item'
|
||||
&& (exactKeys(value, ['type', 'streamId']) || exactKeys(value, ['type', 'streamId', 'value']))
|
||||
&& validId(value.streamId)
|
||||
&& (!Object.hasOwn(value, 'value') || isRemoteJsonValue(value.value))) {
|
||||
return value as RemoteStreamClientMessage
|
||||
}
|
||||
if (value.type === 'open'
|
||||
@@ -377,31 +379,3 @@ function stringProperty(value: object | undefined, key: string): string | undefi
|
||||
const candidate: unknown = Reflect.get(value, key)
|
||||
return typeof candidate === 'string' ? candidate : undefined
|
||||
}
|
||||
|
||||
function visitJsonValue(value: unknown, ancestors: Set<object>): boolean {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
|
||||
if (typeof value === 'number') return Number.isFinite(value) && !Object.is(value, -0)
|
||||
if (typeof value !== 'object') return false
|
||||
if (ancestors.has(value)) return false
|
||||
ancestors.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getPrototypeOf(value) !== Array.prototype
|
||||
|| Reflect.ownKeys(value).length !== value.length + 1) return false
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (!Object.hasOwn(value, index) || !visitJsonValue(value[index], ancestors)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) return false
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (typeof key !== 'string') return false
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
||||
if (descriptor?.enumerable !== true || !visitJsonValue(Reflect.get(value, key), ancestors)) return false
|
||||
}
|
||||
return true
|
||||
} finally {
|
||||
ancestors.delete(value)
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,36 @@
|
||||
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import { RemoteError, remoteErrorOf, type PeerScope } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import WebSocket, { WebSocketServer, type RawData } from 'ws'
|
||||
import {
|
||||
parseRemoteStreamClientMessage,
|
||||
type RemoteStreamClientMessage,
|
||||
type RemoteStreamFailure,
|
||||
type RemoteStreamServerMessage,
|
||||
} from './stream-protocol.ts'
|
||||
|
||||
/** Open one validated Remote stream for a decoded wire request. */
|
||||
/**
|
||||
* Open one validated Remote stream for a decoded wire request on behalf of one
|
||||
* Peer. `uplink` carries the Client's items; `control` cancels the logical
|
||||
* stream, and aborting it with a Remote failure as the reason delivers that
|
||||
* failure to the Client.
|
||||
*/
|
||||
export type RemoteStreamOpener = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
uplink: AsyncIterable<unknown>,
|
||||
peer: PeerScope,
|
||||
control: AbortController,
|
||||
) => Promise<AsyncIterable<unknown>>
|
||||
|
||||
/** The opener one socket uses: its Peer is fixed at upgrade time. */
|
||||
type BoundStreamOpener = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
uplink: AsyncIterable<unknown>,
|
||||
control: AbortController,
|
||||
) => Promise<AsyncIterable<unknown>>
|
||||
|
||||
/** Convert an invocation or carrier failure to a stable wire value. */
|
||||
@@ -32,28 +50,40 @@ export class RemoteStreamMuxServer {
|
||||
* @param open - Gateway stream dispatcher.
|
||||
* @param failure - Gateway error-to-wire mapper.
|
||||
* @param heartbeatIntervalMs - interval between WebSocket Ping control frames.
|
||||
* @param streamInboxBytes - buffered uplink frame bytes one logical stream may hold before it fails.
|
||||
*/
|
||||
constructor(
|
||||
private readonly open: RemoteStreamOpener,
|
||||
private readonly failure: RemoteStreamFailureMapper,
|
||||
private readonly heartbeatIntervalMs: number,
|
||||
private readonly streamInboxBytes: number,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Upgrade one trusted request and begin serving its logical streams.
|
||||
* Upgrade one admitted request and begin serving its logical streams. Every
|
||||
* stream the socket opens speaks for the Peer admitted at upgrade, and the
|
||||
* socket closes when that Peer's scope is disposed.
|
||||
* @param req - authenticated HTTP upgrade request.
|
||||
* @param socket - carrier socket transferred to the WebSocket server.
|
||||
* @param head - bytes already read after the HTTP upgrade headers.
|
||||
* @param peer - Peer the upgrade was admitted as.
|
||||
*/
|
||||
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
|
||||
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer, peer: PeerScope): void {
|
||||
this.server.handleUpgrade(req, socket, head, (websocket) => {
|
||||
const release = bindPeer(websocket, peer)
|
||||
if (release === undefined) return
|
||||
this.missedHeartbeats.set(websocket, 0)
|
||||
websocket.on('pong', () => { this.missedHeartbeats.set(websocket, 0) })
|
||||
this.startHeartbeat()
|
||||
const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure)
|
||||
const bound: BoundStreamOpener = (endpoint, payload, uplink, control) =>
|
||||
this.open(endpoint, payload, uplink, peer, control)
|
||||
const connection = new RemoteStreamMuxConnection(websocket, bound, this.failure, this.streamInboxBytes)
|
||||
const done = connection.run()
|
||||
this.connections.add(done)
|
||||
void done.then(() => { this.connections.delete(done) })
|
||||
void done.then(() => {
|
||||
this.connections.delete(done)
|
||||
void release()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -96,6 +126,9 @@ export class RemoteStreamMuxServer {
|
||||
|
||||
interface ActiveStream {
|
||||
readonly abort: AbortController
|
||||
readonly inbox: UplinkInbox
|
||||
/** Cancel the logical stream and end any Host read still waiting on its uplink. */
|
||||
readonly stop: (reason: Error) => void
|
||||
done: Promise<void>
|
||||
}
|
||||
|
||||
@@ -105,8 +138,9 @@ class RemoteStreamMuxConnection {
|
||||
|
||||
constructor(
|
||||
private readonly socket: WebSocket,
|
||||
private readonly open: RemoteStreamOpener,
|
||||
private readonly open: BoundStreamOpener,
|
||||
private readonly failure: RemoteStreamFailureMapper,
|
||||
private readonly streamInboxBytes: number,
|
||||
) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
@@ -127,22 +161,58 @@ class RemoteStreamMuxConnection {
|
||||
})
|
||||
await closed
|
||||
const active = [...this.streams.values()]
|
||||
for (const stream of active) stream.abort.abort(new Error('Remote stream socket closed'))
|
||||
for (const stream of active) stream.stop(new Error('Remote stream socket closed'))
|
||||
await Promise.all(active.map(stream => stream.done))
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one frame. `item`, `end`, and `cancel` for a stream this connection
|
||||
* no longer owns are dropped: a finished stream leaves the table while the
|
||||
* Client's in-flight frames are still arriving. A duplicate `open` is the one
|
||||
* protocol violation that closes the socket.
|
||||
*/
|
||||
private receive(text: string): void {
|
||||
const message = parseRemoteStreamClientMessage(text)
|
||||
if (message.type === 'cancel') {
|
||||
this.streams.get(message.streamId)?.abort.abort(new Error('Remote stream cancelled'))
|
||||
switch (message.type) {
|
||||
case 'open': {
|
||||
this.openStream(message)
|
||||
return
|
||||
}
|
||||
case 'item': {
|
||||
this.streams.get(message.streamId)?.inbox.push(message.value, Buffer.byteLength(text, 'utf8'))
|
||||
return
|
||||
}
|
||||
case 'end': {
|
||||
this.streams.get(message.streamId)?.inbox.end()
|
||||
return
|
||||
}
|
||||
case 'cancel': {
|
||||
this.streams.get(message.streamId)?.stop(new Error('Remote stream cancelled'))
|
||||
return
|
||||
}
|
||||
/* v8 ignore next 4 -- parseRemoteStreamClientMessage admits only the four frame types above. */
|
||||
default: {
|
||||
const unknown: never = message
|
||||
throw new Error(`api gateway: unknown Remote stream client message ${JSON.stringify(unknown)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private openStream(message: Extract<RemoteStreamClientMessage, { readonly type: 'open' }>): void {
|
||||
if (this.streams.has(message.streamId)) {
|
||||
throw new Error(`api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`)
|
||||
}
|
||||
const abort = new AbortController()
|
||||
// Created before the opener resolves so items the Client sends right
|
||||
// after `open` wait in the inbox instead of being lost.
|
||||
const inbox = new UplinkInbox(this.streamInboxBytes, message.endpoint, (error) => { abort.abort(error) })
|
||||
const active: ActiveStream = {
|
||||
abort,
|
||||
inbox,
|
||||
stop: (reason) => {
|
||||
abort.abort(reason)
|
||||
inbox.fail(reason)
|
||||
},
|
||||
done: Promise.resolve(),
|
||||
}
|
||||
this.streams.set(message.streamId, active)
|
||||
@@ -158,14 +228,40 @@ class RemoteStreamMuxConnection {
|
||||
payload: unknown,
|
||||
active: ActiveStream,
|
||||
): Promise<void> {
|
||||
let outcome: { readonly failed: false } | { readonly failed: true; readonly error: unknown }
|
||||
try {
|
||||
const source = await this.open(endpoint, payload, active.abort.signal)
|
||||
const source = await this.open(endpoint, payload, active.inbox, active.abort)
|
||||
for await (const value of source) {
|
||||
await this.send({ type: 'item', streamId, value })
|
||||
}
|
||||
if (!active.abort.signal.aborted) await this.send({ type: 'end', streamId })
|
||||
outcome = { failed: false }
|
||||
} catch (error) {
|
||||
if (!active.abort.signal.aborted && this.socket.readyState === WebSocket.OPEN) {
|
||||
outcome = { failed: true, error }
|
||||
}
|
||||
// The downlink has settled; later uplink frames cannot change the outcome.
|
||||
active.inbox.fail(new Error('Remote stream ended'))
|
||||
if (active.abort.signal.aborted) {
|
||||
// A Remote failure as the abort reason is the Gateway or this mux failing
|
||||
// the stream (a rejected or overflowing uplink item, or an item after
|
||||
// end); the Client is still waiting for that terminal frame. Any other
|
||||
// abort is a cancellation.
|
||||
const reason: unknown = active.abort.signal.reason
|
||||
if (remoteErrorOf(reason) !== undefined) await this.sendFailure(streamId, reason)
|
||||
return
|
||||
}
|
||||
if (outcome.failed) {
|
||||
await this.sendFailure(streamId, outcome.error)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.send({ type: 'end', streamId })
|
||||
} catch (error) {
|
||||
await this.sendFailure(streamId, error)
|
||||
}
|
||||
}
|
||||
|
||||
private async sendFailure(streamId: string, error: unknown): Promise<void> {
|
||||
if (this.socket.readyState !== WebSocket.OPEN) return
|
||||
try {
|
||||
await this.send({ type: 'error', streamId, error: this.failure(error) })
|
||||
} catch {
|
||||
@@ -174,8 +270,6 @@ class RemoteStreamMuxConnection {
|
||||
this.socket.close(1011, 'Remote stream failure could not be delivered')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private send(message: RemoteStreamServerMessage): Promise<void> {
|
||||
let text: string
|
||||
@@ -199,6 +293,132 @@ class RemoteStreamMuxConnection {
|
||||
}
|
||||
}
|
||||
|
||||
interface UplinkEntry {
|
||||
readonly value: unknown
|
||||
readonly bytes: number
|
||||
}
|
||||
|
||||
const UPLINK_DONE: IteratorReturnResult<undefined> = { value: undefined, done: true }
|
||||
|
||||
/**
|
||||
* Bounded single-consumer uplink queue of one logical stream, the source the
|
||||
* Host method reads through `invocation.uplink()`. Buffered frame bytes are
|
||||
* capped: overflow, and an item after the Client's `end`, fail the queue and
|
||||
* report a Remote failure that the connection uses to fail the logical stream.
|
||||
*/
|
||||
class UplinkInbox implements AsyncIterable<unknown>, AsyncIterator<unknown> {
|
||||
private readonly queue = new Deque<UplinkEntry>()
|
||||
private bytes = 0
|
||||
private ended = false
|
||||
private closed = false
|
||||
private taken = false
|
||||
private failure: Error | undefined
|
||||
private wake: (() => void) | undefined
|
||||
|
||||
constructor(
|
||||
private readonly maxBytes: number,
|
||||
private readonly endpoint: string,
|
||||
private readonly onViolation: (error: RemoteError<'gateway/protocol' | 'gateway/uplink-overflow'>) => void,
|
||||
) {}
|
||||
|
||||
push(value: unknown, frameBytes: number): void {
|
||||
if (this.failure !== undefined || this.closed) return
|
||||
if (this.ended) {
|
||||
this.violate(new RemoteError(
|
||||
'gateway/protocol',
|
||||
'api gateway: Remote stream uplink item after end',
|
||||
{ endpoint: this.endpoint },
|
||||
))
|
||||
return
|
||||
}
|
||||
if (this.bytes + frameBytes > this.maxBytes) {
|
||||
this.violate(new RemoteError(
|
||||
'gateway/uplink-overflow',
|
||||
`api gateway: Remote stream uplink exceeded ${String(this.maxBytes)} buffered bytes`,
|
||||
{ endpoint: this.endpoint },
|
||||
))
|
||||
return
|
||||
}
|
||||
this.queue.pushBack({ value, bytes: frameBytes })
|
||||
this.bytes += frameBytes
|
||||
this.signal()
|
||||
}
|
||||
|
||||
/** Client half-close; idempotent. */
|
||||
end(): void {
|
||||
if (this.ended) return
|
||||
this.ended = true
|
||||
this.signal()
|
||||
}
|
||||
|
||||
/** End the consumer's next read with `error`; idempotent, drops buffered items. */
|
||||
fail(error: Error): void {
|
||||
if (this.failure !== undefined) return
|
||||
this.failure = error
|
||||
this.queue.clear()
|
||||
this.bytes = 0
|
||||
this.signal()
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<unknown> {
|
||||
if (this.taken) throw new Error('api gateway: Remote stream uplink inbox already has a consumer')
|
||||
this.taken = true
|
||||
return this
|
||||
}
|
||||
|
||||
async next(): Promise<IteratorResult<unknown>> {
|
||||
while (true) {
|
||||
if (this.closed) return UPLINK_DONE
|
||||
const entry = this.queue.popFront()
|
||||
if (entry !== undefined) {
|
||||
this.bytes -= entry.bytes
|
||||
return { value: entry.value, done: false }
|
||||
}
|
||||
if (this.failure !== undefined) throw this.failure
|
||||
if (this.ended) return UPLINK_DONE
|
||||
if (this.wake !== undefined) throw new Error('api gateway: Remote stream uplink inbox has one pending read')
|
||||
await new Promise<void>((resolve) => { this.wake = resolve })
|
||||
}
|
||||
}
|
||||
|
||||
/** Consumer stopped reading: later items are dropped, a pending read ends. */
|
||||
return(): Promise<IteratorResult<unknown>> {
|
||||
this.closed = true
|
||||
this.queue.clear()
|
||||
this.bytes = 0
|
||||
this.signal()
|
||||
return Promise.resolve(UPLINK_DONE)
|
||||
}
|
||||
|
||||
private violate(error: RemoteError<'gateway/protocol' | 'gateway/uplink-overflow'>): void {
|
||||
this.fail(error)
|
||||
this.onViolation(error)
|
||||
}
|
||||
|
||||
private signal(): void {
|
||||
const wake = this.wake
|
||||
this.wake = undefined
|
||||
wake?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the socket when the Peer's scope is disposed. A scope that is already
|
||||
* disposed leaves no Peer for the socket to speak for, so the socket closes now.
|
||||
* @returns the registration's disposer, or `undefined` when the socket was closed.
|
||||
*/
|
||||
function bindPeer(websocket: WebSocket, peer: PeerScope): (() => unknown) | undefined {
|
||||
try {
|
||||
return peer.ctx.effect(
|
||||
() => () => { websocket.close(1001, 'peer left') },
|
||||
'api-gateway: Remote stream socket bound to its Peer',
|
||||
)
|
||||
} catch {
|
||||
websocket.close(1001, 'peer left')
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function rawText(data: RawData): string {
|
||||
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
|
||||
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { RemoteEventHostInfo } from './stream-protocol.ts'
|
||||
|
||||
/** One Remote method request after a carrier has decoded its envelope. */
|
||||
@@ -14,6 +15,13 @@ export interface InvokeRemoteRequest {
|
||||
readonly method: string
|
||||
/** Named wire values; fields must exactly match the descriptor. */
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/**
|
||||
* Client uplink items of this logical stream, delivered to the method through
|
||||
* `invocation.uplink()`; absent means an immediately ended iterable.
|
||||
*/
|
||||
readonly uplink?: AsyncIterable<unknown>
|
||||
/** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */
|
||||
readonly peer?: PeerScope
|
||||
/** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
@@ -78,12 +86,17 @@ export interface TypertGatewayWireStream {
|
||||
* Open one logical stream from its wire endpoint and payload.
|
||||
* @param endpoint - canonical Remote endpoint or Gateway-owned stream name.
|
||||
* @param payload - decoded carrier payload.
|
||||
* @param uplink - Client-to-Host items of the logical stream; a Gateway-owned endpoint returns its iterator
|
||||
* as soon as it opens, so the carrier drops those items instead of buffering them.
|
||||
* @param peer - Peer the stream speaks for; `undefined` means the operator's in-process carrier.
|
||||
* @param signal - logical-stream cancellation.
|
||||
* @returns validated stream values.
|
||||
*/
|
||||
readonly open: (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
uplink: AsyncIterable<unknown>,
|
||||
peer: PeerScope | undefined,
|
||||
signal: AbortSignal,
|
||||
) => Promise<AsyncIterable<unknown>>
|
||||
|
||||
@@ -114,10 +127,12 @@ export type TypertGatewayErrorCode =
|
||||
| 'gateway/lookup-not-found'
|
||||
| 'gateway/lookup-unavailable'
|
||||
| 'gateway/method-unavailable'
|
||||
| 'gateway/protocol'
|
||||
| 'gateway/provider-mismatch'
|
||||
| 'gateway/result-invalid'
|
||||
| 'gateway/service-unavailable'
|
||||
| 'gateway/signature-invalid'
|
||||
| 'gateway/uplink-overflow'
|
||||
|
||||
/** Host dispatcher consumed by Connection adapters. */
|
||||
export interface TypertGateway {
|
||||
@@ -145,7 +160,7 @@ export interface TypertGateway {
|
||||
|
||||
/**
|
||||
* Open one live stream Remote method without assuming a physical carrier.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one.
|
||||
* @returns a cancellation-aware iterable over the business results.
|
||||
*/
|
||||
stream(request: InvokeRemoteRequest): Promise<AsyncIterable<unknown>>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
const browserCookies = new WeakMap<Context, string>()
|
||||
|
||||
/** Provide an in-memory credential-record owner for a mounted Connection plugin. */
|
||||
export function provideBrowserCredentials(ctx: Context): void {
|
||||
const records = new Map<unknown, unknown>()
|
||||
@@ -15,3 +17,28 @@ export function provideBrowserCredentials(ctx: Context): void {
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a test Host's process token for its WebSocket/HTTP Cookie header.
|
||||
* @param ctx - Host root with the Web server and Connection mounted.
|
||||
* @returns the `cookie` header value, cached per root.
|
||||
*/
|
||||
export function browserCookie(ctx: Context): string {
|
||||
const existing = browserCookies.get(ctx)
|
||||
if (existing !== undefined) return existing
|
||||
const origin = `http://127.0.0.1:${String(ctx.webServer.port)}`
|
||||
const target = new URL(ctx.connection.authenticatedUrl(origin))
|
||||
let setCookie: string | undefined
|
||||
ctx.connection.authorizeIndex({
|
||||
method: 'GET',
|
||||
url: `${target.pathname}${target.search}`,
|
||||
headers: { host: target.host },
|
||||
}, {
|
||||
writeHead(_status, headers) { setCookie = headers?.['set-cookie'] },
|
||||
end() {},
|
||||
})
|
||||
if (setCookie === undefined) throw new Error('gateway fixture did not receive a browser cookie')
|
||||
const cookie = setCookie.split(';', 1)[0]!
|
||||
browserCookies.set(ctx, cookie)
|
||||
return cookie
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { once } from 'node:events'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WebSocket, { type RawData } from 'ws'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection'
|
||||
import WebServer from '@deepseek-ai/dsh-host-webserver'
|
||||
import { Remote, TypertRemoteService, type RemoteStream } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import TypertGatewayService from '@deepseek-ai/dsh-api-gateway'
|
||||
import { browserCookie, provideBrowserCredentials } from './browser-credentials.ts'
|
||||
|
||||
/** The smallest Remote stream that reads its uplink: every carrier has to serve it alike. */
|
||||
class EchoService extends TypertRemoteService {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'echo', { namespace: 'echo' })
|
||||
}
|
||||
|
||||
@Remote({ mode: 'stream' })
|
||||
async *echo(prefix: string, signal: AbortSignal): RemoteStream<string, string> {
|
||||
const invocation = this.ctx.invocation
|
||||
if (invocation === undefined) throw new Error('echo ran outside a Remote call')
|
||||
for await (const item of invocation.uplink<string>()) {
|
||||
signal.throwIfAborted()
|
||||
yield `${prefix}${item}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const roots: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
})
|
||||
|
||||
describe('the echo stream on each carrier', () => {
|
||||
it('echoes uplink items through the in-process carrier', async () => {
|
||||
const ctx = await setup(false)
|
||||
const replies = await ctx.typertGateway.stream({
|
||||
namespace: 'echo', method: 'echo', args: { prefix: '> ' }, uplink: items(['a', 'b']),
|
||||
})
|
||||
await expect(collect(replies)).resolves.toEqual(['> a', '> b'])
|
||||
})
|
||||
|
||||
it('echoes uplink frames through the WebSocket carrier', async () => {
|
||||
const ctx = await setup(true)
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
||||
headers: { cookie: browserCookie(ctx) },
|
||||
})
|
||||
await once(socket, 'open')
|
||||
const frames: unknown[] = []
|
||||
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data))) })
|
||||
socket.send(JSON.stringify({ type: 'open', streamId: 'echo', endpoint: 'echo/echo', payload: { args: { prefix: '> ' } } }))
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'a' }))
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'b' }))
|
||||
socket.send(JSON.stringify({ type: 'end', streamId: 'echo' }))
|
||||
await vi.waitFor(() => {
|
||||
expect(frames).toEqual([
|
||||
{ type: 'item', streamId: 'echo', value: '> a' },
|
||||
{ type: 'item', streamId: 'echo', value: '> b' },
|
||||
{ type: 'end', streamId: 'echo' },
|
||||
])
|
||||
})
|
||||
socket.close()
|
||||
await once(socket, 'close')
|
||||
})
|
||||
})
|
||||
|
||||
async function setup(transport: boolean): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
roots.push(ctx)
|
||||
if (transport) {
|
||||
await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 })
|
||||
provideBrowserCredentials(ctx)
|
||||
}
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(TypertGatewayService, {})
|
||||
if (transport) await ctx.plugin({ inject: [...connectionInject], apply: applyConnection })
|
||||
await ctx.plugin(EchoService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function *items<T>(values: readonly T[]): AsyncIterable<T> {
|
||||
for (const value of values) yield value
|
||||
}
|
||||
|
||||
async function collect(source: AsyncIterable<unknown>): Promise<unknown[]> {
|
||||
const values: unknown[] = []
|
||||
for await (const value of source) values.push(value)
|
||||
return values
|
||||
}
|
||||
|
||||
function rawText(data: RawData): string {
|
||||
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
|
||||
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
|
||||
return Buffer.from(data).toString('utf8')
|
||||
}
|
||||
@@ -2,14 +2,18 @@ import { randomUUID } from 'node:crypto'
|
||||
import { once } from 'node:events'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WebSocket, { type RawData } from 'ws'
|
||||
import { Context, Service, symbols } from '@deepseek-ai/cordis'
|
||||
import { Context, symbols } from '@deepseek-ai/cordis'
|
||||
import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection'
|
||||
import WebServer from '@deepseek-ai/dsh-host-webserver'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import {
|
||||
bindTypertRemote,
|
||||
Remote,
|
||||
remoteErrorOf,
|
||||
TypertRemoteService,
|
||||
type InvocationDescriptor,
|
||||
type PeerScope,
|
||||
type RemoteInvocation,
|
||||
type RemoteStream,
|
||||
type TypertContextMap,
|
||||
type TypertContextWire,
|
||||
RemoteError,
|
||||
@@ -22,7 +26,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
'fixture/broken': { readonly count: bigint }
|
||||
}
|
||||
}
|
||||
import { provideBrowserCredentials } from './browser-credentials.ts'
|
||||
import { browserCookie, provideBrowserCredentials } from './browser-credentials.ts'
|
||||
import TypertGatewayService, {
|
||||
TypertGatewayError,
|
||||
type Config as GatewayConfig,
|
||||
@@ -42,35 +46,15 @@ vi.mock('node:crypto', async (importOriginal) => {
|
||||
})
|
||||
|
||||
const randomUuid = vi.mocked(randomUUID)
|
||||
const browserCookies = new WeakMap<Context, string>()
|
||||
const REMOTE_HOST = { home: '/home/fixture' } as const
|
||||
type AgentWireId = TypertContextWire<TypertContextMap['agent']>
|
||||
const agentId = (value: string): AgentWireId => value as AgentWireId
|
||||
|
||||
/** Exchange this test Host's process token for its WebSocket/HTTP Cookie header. */
|
||||
function browserCookie(ctx: Context): string {
|
||||
const existing = browserCookies.get(ctx)
|
||||
if (existing !== undefined) return existing
|
||||
const origin = `http://127.0.0.1:${String(ctx.webServer.port)}`
|
||||
const target = new URL(ctx.connection.authenticatedUrl(origin))
|
||||
let setCookie: string | undefined
|
||||
ctx.connection.authorizeIndex({
|
||||
method: 'GET',
|
||||
url: `${target.pathname}${target.search}`,
|
||||
headers: { host: target.host },
|
||||
}, {
|
||||
writeHead(_status, headers) { setCookie = headers?.['set-cookie'] },
|
||||
end() {},
|
||||
})
|
||||
if (setCookie === undefined) throw new Error('gateway stream fixture did not receive a browser cookie')
|
||||
const cookie = setCookie.split(';', 1)[0]!
|
||||
browserCookies.set(ctx, cookie)
|
||||
return cookie
|
||||
}
|
||||
|
||||
class FeedService extends Service {
|
||||
readonly typertRemote = bindTypertRemote(this, 'feed')
|
||||
class FeedService extends TypertRemoteService {
|
||||
readonly signals: AbortSignal[] = []
|
||||
readonly peeked: unknown[] = []
|
||||
readonly peers: PeerScope[] = []
|
||||
leftover: AsyncIterator<string> | undefined
|
||||
returns = 0
|
||||
|
||||
constructor(ctx: Context) {
|
||||
@@ -133,9 +117,114 @@ class FeedService extends Service {
|
||||
throw new RemoteError('fixture/broken', 'fixture emitted invalid details', { count: 1n })
|
||||
}
|
||||
|
||||
@Remote({ mode: 'stream' })
|
||||
async *echo(prefix: string, signal: AbortSignal): RemoteStream<string, string> {
|
||||
this.signals.push(signal)
|
||||
const invocation = this.invocation()
|
||||
this.peers.push(invocation.peer)
|
||||
try {
|
||||
for await (const item of invocation.uplink<string>()) yield `${prefix}${item}`
|
||||
} finally {
|
||||
this.returns += 1
|
||||
}
|
||||
}
|
||||
|
||||
/** Hides the uplink failure so the test proves the Gateway still fails the stream. */
|
||||
@Remote({ mode: 'stream' })
|
||||
async *swallow(signal: AbortSignal): RemoteStream<string, string> {
|
||||
try {
|
||||
for await (const item of this.invocation().uplink<string>()) yield item
|
||||
} catch {
|
||||
// The fixture keeps yielding after the rejected item on purpose.
|
||||
}
|
||||
yield 'swallowed'
|
||||
await abortOf(signal)
|
||||
}
|
||||
|
||||
/** Never takes its uplink. */
|
||||
@Remote({ mode: 'stream' })
|
||||
async *ignore(prefix: string): RemoteStream<string, string> {
|
||||
yield `${prefix}:one`
|
||||
yield `${prefix}:two`
|
||||
}
|
||||
|
||||
/** Keeps the downlink open without reading, and retains the uplink iterator for post-stream reads. */
|
||||
@Remote({ mode: 'stream' })
|
||||
async *hold(signal: AbortSignal): RemoteStream<string, string> {
|
||||
this.leftover = this.invocation().uplink<string>()[Symbol.asyncIterator]()
|
||||
yield 'held'
|
||||
await abortOf(signal)
|
||||
}
|
||||
|
||||
/** Reads before yielding so cancellation races the pending read, then reads once more after the abort. */
|
||||
@Remote({ mode: 'stream' })
|
||||
async *peek(signal: AbortSignal): RemoteStream<string, string> {
|
||||
const iterator = this.invocation().uplink<string>()[Symbol.asyncIterator]()
|
||||
const pending = iterator.next().then(result => result, (error: unknown) => error)
|
||||
yield 'ready'
|
||||
await abortOf(signal)
|
||||
this.peeked.push(await pending, await iterator.next().then(result => result, (error: unknown) => error))
|
||||
}
|
||||
|
||||
/** Yields once, then blocks on the uplink until the Gateway closes it. */
|
||||
@Remote({ mode: 'stream' })
|
||||
async *drain(): RemoteStream<string, string> {
|
||||
yield 'ready'
|
||||
for await (const item of this.invocation().uplink<string>()) yield item
|
||||
}
|
||||
|
||||
/** Reads the uplink to its end and then again: an ended uplink reports done on every later read. */
|
||||
@Remote({ mode: 'stream' })
|
||||
async *rereads(): RemoteStream<string, string> {
|
||||
const uplink = this.invocation().uplink<string>()
|
||||
for await (const item of uplink) yield item
|
||||
for await (const item of uplink) yield `again:${item}`
|
||||
yield 'done'
|
||||
}
|
||||
|
||||
/** Reports the call context; its descriptor declares no uplink codec, so items arrive as `unknown`. */
|
||||
@Remote({ mode: 'stream' })
|
||||
async *context(label: string): RemoteStream<string> {
|
||||
const invocation = this.invocation()
|
||||
this.peers.push(invocation.peer)
|
||||
const { namespace, method, args } = invocation.request
|
||||
yield `${namespace}/${method}:${invocation.service}:${JSON.stringify(args)}:${label}`
|
||||
for await (const item of invocation.uplink()) yield `raw:${JSON.stringify(item)}`
|
||||
try {
|
||||
invocation.uplink()
|
||||
} catch (error) {
|
||||
yield error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** SRC-discovered: no descriptor, so uplink items arrive as `unknown`. */
|
||||
@Remote({ mode: 'stream' })
|
||||
async *srcEcho(prefix: string, signal: AbortSignal): RemoteStream<string, string> {
|
||||
void signal
|
||||
for await (const item of this.invocation().uplink()) yield `${prefix}${String(item)}`
|
||||
}
|
||||
|
||||
unary(label: string): string {
|
||||
return label
|
||||
}
|
||||
|
||||
/** Reads the uplink inside a unary call: items arrive only while the method runs. */
|
||||
async unaryUplink(): Promise<string[]> {
|
||||
const items: string[] = []
|
||||
for await (const item of this.invocation().uplink<string>()) items.push(item)
|
||||
return items
|
||||
}
|
||||
|
||||
/** What `ctx.invocation` reads on the Service's own Context, outside any Remote call. */
|
||||
invocationOutsideCall(): RemoteInvocation | undefined {
|
||||
return this.ctx.invocation
|
||||
}
|
||||
|
||||
private invocation(): RemoteInvocation {
|
||||
const invocation = this.ctx.invocation
|
||||
if (invocation === undefined) throw new Error('fixture method ran outside a Remote call')
|
||||
return invocation
|
||||
}
|
||||
}
|
||||
|
||||
const roots: Context[] = []
|
||||
@@ -218,13 +307,16 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('Typert Remote streams', () => {
|
||||
it('validates the WebSocket heartbeat timer range', () => {
|
||||
expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000 })
|
||||
expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS }))
|
||||
.toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS })
|
||||
it('validates the WebSocket heartbeat timer range and the stream inbox bound', () => {
|
||||
expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000, streamInboxBytes: 262_144 })
|
||||
expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 }))
|
||||
.toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 })
|
||||
for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) {
|
||||
expect(() => TypertGatewayService.Config({ websocketHeartbeatIntervalMs })).toThrow()
|
||||
}
|
||||
for (const streamInboxBytes of [0, 1.5]) {
|
||||
expect(() => TypertGatewayService.Config({ streamInboxBytes })).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('opens decoded carrier payloads through the in-process wire adapter', async () => {
|
||||
@@ -232,10 +324,294 @@ describe('Typert Remote streams', () => {
|
||||
const source = await ctx.typertGateway.wireStream.open(
|
||||
'feed/sync',
|
||||
{ args: { label: 'wire' } },
|
||||
toAsync([]),
|
||||
undefined,
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two'])
|
||||
|
||||
const echoed = await ctx.typertGateway.wireStream.open(
|
||||
'feed/echo',
|
||||
{ args: { prefix: 'wire:' } },
|
||||
toAsync(['a', 'b']),
|
||||
undefined,
|
||||
new AbortController().signal,
|
||||
)
|
||||
await expect(collect(echoed)).resolves.toEqual(['wire:a', 'wire:b'])
|
||||
})
|
||||
|
||||
it('delivers uplink items through the in-process carrier and ends on half-close', async () => {
|
||||
const { ctx, service } = await setup(false)
|
||||
await expect(collect(await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'echo', args: { prefix: '> ' }, uplink: toAsync(['a', 'b']),
|
||||
}))).resolves.toEqual(['> a', '> b'])
|
||||
expect(service.returns).toBe(1)
|
||||
await expect(collect(await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'echo', args: { prefix: 'x' },
|
||||
}))).resolves.toEqual([])
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'feed', method: 'echo', args: { prefix: 'x' },
|
||||
})).rejects.toMatchObject({
|
||||
code: 'gateway/signature-invalid',
|
||||
message: 'typert gateway: feed/echo: stream Remote methods must be opened through the stream carrier',
|
||||
})
|
||||
|
||||
// The caller's iterator is released even when its own return() rejects.
|
||||
const returned = vi.fn(async (): Promise<IteratorResult<string>> => {
|
||||
throw new Error('fixture release failure')
|
||||
})
|
||||
const unread: AsyncIterable<string> = {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => new Promise<IteratorResult<string>>(() => {}),
|
||||
return: returned,
|
||||
}),
|
||||
}
|
||||
await expect(collect(await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'ignore', args: { prefix: 'i' }, uplink: unread,
|
||||
}))).resolves.toEqual(['i:one', 'i:two'])
|
||||
await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() })
|
||||
})
|
||||
|
||||
it('fails a stream whose uplink item fails its codec, even when the method swallows the failure', async () => {
|
||||
const { ctx, service } = await setup(false)
|
||||
const rejected = await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync<unknown>(['ok', 1]),
|
||||
})
|
||||
const iterator = rejected[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ok' })
|
||||
await expect(iterator.next()).rejects.toMatchObject({
|
||||
code: 'gateway/input-invalid',
|
||||
details: { endpoint: 'feed/echo', field: 'uplink' },
|
||||
})
|
||||
const signal = service.signals.at(-1)
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(remoteErrorOf(signal?.reason)?.code).toBe('gateway/input-invalid')
|
||||
expect(service.returns).toBe(1)
|
||||
|
||||
await expect(collect(await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'swallow', args: {}, uplink: toAsync<unknown>([1]),
|
||||
}))).rejects.toMatchObject({ code: 'gateway/input-invalid' })
|
||||
})
|
||||
|
||||
it('ends a pending uplink read on cancellation and reports later reads as cancelled', async () => {
|
||||
const { ctx, service } = await setup(false)
|
||||
const abort = new AbortController()
|
||||
const source = await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'peek', args: {}, uplink: neverYielding(), signal: abort.signal,
|
||||
})
|
||||
const iterator = source[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ready' })
|
||||
const pending = iterator.next()
|
||||
abort.abort(new Error('fixture cancellation'))
|
||||
await expect(pending).rejects.toThrow('Remote invocation "feed/peek" was aborted')
|
||||
await vi.waitFor(() => { expect(service.peeked).toHaveLength(2) })
|
||||
expect(service.peeked.map(outcome => remoteErrorOf(outcome)?.code))
|
||||
.toEqual(['gateway/cancelled', 'gateway/cancelled'])
|
||||
})
|
||||
|
||||
it('drops unread uplink items once the method finishes', async () => {
|
||||
const { ctx, service } = await setup(false)
|
||||
const abort = new AbortController()
|
||||
const source = await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'hold', args: {}, uplink: toAsync(['unread']), signal: abort.signal,
|
||||
})
|
||||
const iterator = source[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' })
|
||||
const pending = iterator.next()
|
||||
abort.abort(new Error('fixture cancellation'))
|
||||
await expect(pending).rejects.toThrow('Remote invocation "feed/hold" was aborted')
|
||||
const leftover = service.leftover
|
||||
if (leftover === undefined) throw new Error('fixture did not retain its uplink iterator')
|
||||
// The unread item is gone; a cancelled stream reports the cancellation on every later read.
|
||||
await expect(leftover.next()).rejects.toMatchObject({ code: 'gateway/cancelled' })
|
||||
await expect(leftover.return?.()).resolves.toEqual({ done: true, value: undefined })
|
||||
})
|
||||
|
||||
it('exposes the call as ctx.invocation, delivers codec-less uplink items as unknown, and hands the uplink out once', async () => {
|
||||
const { ctx, service } = await setup(false)
|
||||
await expect(collect(await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'context', args: { label: 'ctx' }, uplink: toAsync<unknown>([1, { nested: true }]),
|
||||
}))).resolves.toEqual([
|
||||
'feed/context:feed:{"label":"ctx"}:ctx',
|
||||
'raw:1',
|
||||
'raw:{"nested":true}',
|
||||
'typert gateway: feed/context: invocation.uplink() is available once per call',
|
||||
])
|
||||
expect(service.peers.at(-1)?.id).toMatch(/^[0-9a-f-]{36}$/)
|
||||
expect(service.invocationOutsideCall()).toBeUndefined()
|
||||
await expect(collect(await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'context', args: { label: 'again' },
|
||||
}))).resolves.toHaveLength(2)
|
||||
expect(service.peers.at(-1)).toBe(service.peers.at(-2))
|
||||
await expect(service.peers.at(-1)?.dispose()).resolves.toBeUndefined()
|
||||
|
||||
// Items sent to a method that never takes its uplink wait in the carrier and are dropped when the downlink ends.
|
||||
await expect(collect(await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'sync', args: { label: 'closed' }, uplink: toAsync(['unread']),
|
||||
}))).resolves.toEqual(['closed:one', 'closed:two'])
|
||||
|
||||
// Without a codec an item still has to be a JSON-safe value.
|
||||
await expect(collect(await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'context', args: { label: 'bad' }, uplink: toAsync<unknown>([1n]),
|
||||
}))).rejects.toMatchObject({
|
||||
code: 'gateway/input-invalid',
|
||||
details: { endpoint: 'feed/context', field: 'uplink' },
|
||||
})
|
||||
})
|
||||
|
||||
it('releases a taken uplink source even when its return() rejects', async () => {
|
||||
const { ctx } = await setup(false)
|
||||
const abort = new AbortController()
|
||||
const returned = vi.fn(async (): Promise<IteratorResult<string>> => {
|
||||
throw new Error('fixture release failure')
|
||||
})
|
||||
const stuck: AsyncIterable<string> = {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => new Promise<IteratorResult<string>>(() => {}),
|
||||
return: returned,
|
||||
}),
|
||||
}
|
||||
const source = await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'hold', args: {}, uplink: stuck, signal: abort.signal,
|
||||
})
|
||||
const iterator = source[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' })
|
||||
const pending = iterator.next()
|
||||
abort.abort(new Error('fixture cancellation'))
|
||||
await expect(pending).rejects.toThrow('Remote invocation "feed/hold" was aborted')
|
||||
await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() })
|
||||
})
|
||||
|
||||
it('closes the uplink before returning a method blocked on it', async () => {
|
||||
const { ctx } = await setup(false)
|
||||
const source = await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'drain', args: {}, uplink: neverYielding(),
|
||||
})
|
||||
const iterator = source[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ready' })
|
||||
await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined })
|
||||
})
|
||||
|
||||
it('reports done on every read after the uplink ended', async () => {
|
||||
const { ctx } = await setup(false)
|
||||
await expect(collect(await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'rereads', args: {}, uplink: toAsync(['a', 'b']),
|
||||
}))).resolves.toEqual(['a', 'b', 'done'])
|
||||
})
|
||||
|
||||
it('accepts a top-level undefined uplink item where no codec applies and rejects it where one does', async () => {
|
||||
const { ctx } = await setup(false)
|
||||
await expect(collect(await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'context', args: { label: 'absent' }, uplink: toAsync<unknown>([undefined, 'x']),
|
||||
}))).resolves.toEqual([
|
||||
'feed/context:feed:{"label":"absent"}:absent',
|
||||
'raw:undefined',
|
||||
'raw:"x"',
|
||||
'typert gateway: feed/context: invocation.uplink() is available once per call',
|
||||
])
|
||||
await expect(collect(await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync<unknown>([undefined]),
|
||||
}))).rejects.toMatchObject({ code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } })
|
||||
})
|
||||
|
||||
it('releases the uplink when a unary method is opened through the stream carrier', async () => {
|
||||
const { ctx } = await setup(false)
|
||||
const returned = vi.fn(async (): Promise<IteratorResult<string>> => ({ value: undefined, done: true }))
|
||||
const unread: AsyncIterable<string> = {
|
||||
[Symbol.asyncIterator]: () => ({ next: () => new Promise<IteratorResult<string>>(() => {}), return: returned }),
|
||||
}
|
||||
await expect(ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'unary', args: { label: 'a' }, uplink: unread,
|
||||
})).rejects.toMatchObject({ code: 'gateway/signature-invalid' })
|
||||
await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() })
|
||||
})
|
||||
|
||||
it('lets a unary method read uplink items while it runs', async () => {
|
||||
const { ctx } = await setup(false)
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'feed', method: 'unaryUplink', args: {}, uplink: toAsync(['a', 'b']),
|
||||
})).resolves.toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('derives SRC descriptors whose uplink arrives without a codec', async () => {
|
||||
const { ctx } = await setup(false)
|
||||
await expect(collect(await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'srcEcho', args: { prefix: 's:' }, uplink: toAsync<unknown>(['a', 2]),
|
||||
}))).resolves.toEqual(['s:a', 's:2'])
|
||||
})
|
||||
|
||||
it('echoes uplink frames over the WebSocket carrier and fails misused uplinks per stream', async () => {
|
||||
const { ctx, service } = await setup(true)
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
||||
headers: { cookie: browserCookie(ctx) },
|
||||
})
|
||||
await once(socket, 'open')
|
||||
const frames: Record<string, unknown>[] = []
|
||||
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
|
||||
|
||||
sendOpen(socket, 'echo', 'feed/echo', { prefix: '> ' })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'a' }))
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'b' }))
|
||||
socket.send(JSON.stringify({ type: 'end', streamId: 'echo' }))
|
||||
await vi.waitFor(() => {
|
||||
expect(frames.filter(frame => frame.streamId === 'echo')).toEqual([
|
||||
{ type: 'item', streamId: 'echo', value: '> a' },
|
||||
{ type: 'item', streamId: 'echo', value: '> b' },
|
||||
{ type: 'end', streamId: 'echo' },
|
||||
])
|
||||
})
|
||||
expect(service.returns).toBe(1)
|
||||
expect(service.peers.at(-1)).toBe(ctx.connection.operator)
|
||||
|
||||
sendOpen(socket, 'rejected', 'feed/echo', { prefix: '' })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: 'rejected', value: 1 }))
|
||||
await vi.waitFor(() => {
|
||||
expect(frames.find(frame => frame.streamId === 'rejected')).toMatchObject({
|
||||
type: 'error',
|
||||
error: { code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } },
|
||||
})
|
||||
})
|
||||
|
||||
// An item frame without value is a top-level undefined item.
|
||||
sendOpen(socket, 'absent', 'feed/context', { label: 'w' })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: 'absent' }))
|
||||
await vi.waitFor(() => {
|
||||
expect(frames).toContainEqual({ type: 'item', streamId: 'absent', value: 'raw:undefined' })
|
||||
})
|
||||
|
||||
sendOpen(socket, 'late', 'feed/hold', {})
|
||||
await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'item', streamId: 'late', value: 'held' }) })
|
||||
socket.send(JSON.stringify({ type: 'end', streamId: 'late' }))
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: 'late', value: 'after end' }))
|
||||
await vi.waitFor(() => {
|
||||
expect(frames.find(frame => frame.streamId === 'late' && frame.type === 'error')).toMatchObject({
|
||||
error: { code: 'gateway/protocol', details: { endpoint: 'feed/hold' } },
|
||||
})
|
||||
})
|
||||
expect(socket.readyState).toBe(WebSocket.OPEN)
|
||||
socket.close()
|
||||
await once(socket, 'close')
|
||||
})
|
||||
|
||||
it('fails a stream whose buffered uplink exceeds the configured inbox bytes', async () => {
|
||||
const { ctx } = await setup(true, { streamInboxBytes: 64 })
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
||||
headers: { cookie: browserCookie(ctx) },
|
||||
})
|
||||
await once(socket, 'open')
|
||||
const frames: Record<string, unknown>[] = []
|
||||
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
|
||||
|
||||
sendOpen(socket, 'big', 'feed/hold', {})
|
||||
await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'item', streamId: 'big', value: 'held' }) })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: 'big', value: 'x'.repeat(128) }))
|
||||
await vi.waitFor(() => {
|
||||
expect(frames.find(frame => frame.streamId === 'big' && frame.type === 'error')).toMatchObject({
|
||||
error: { code: 'gateway/uplink-overflow', details: { endpoint: 'feed/hold' } },
|
||||
})
|
||||
})
|
||||
socket.close()
|
||||
await once(socket, 'close')
|
||||
})
|
||||
|
||||
it('passes Iterable and AsyncIterable items through and returns the iterator on cancellation', async () => {
|
||||
@@ -371,6 +747,44 @@ describe('Typert Remote streams', () => {
|
||||
expect(service.signals[1]?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps uplink frames to a Gateway-owned stream out of the bounded inbox', async () => {
|
||||
const { ctx } = await setup(true, { streamInboxBytes: 64 })
|
||||
const source = (signal: AbortSignal): AsyncIterable<{ event: string; args: readonly unknown[] }> => (async function *() {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) resolve()
|
||||
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
})()
|
||||
ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST)
|
||||
|
||||
// An in-process carrier's uplink is returned as soon as the Gateway-owned stream opens.
|
||||
const returned = vi.fn(async (): Promise<IteratorResult<unknown>> => ({ value: undefined, done: true }))
|
||||
const idle: AsyncIterable<unknown> = {
|
||||
[Symbol.asyncIterator]: () => ({ next: () => new Promise<IteratorResult<unknown>>(() => {}), return: returned }),
|
||||
}
|
||||
const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, idle, undefined, new AbortController().signal)
|
||||
await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() })
|
||||
await events[Symbol.asyncIterator]().return?.()
|
||||
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
||||
headers: { cookie: browserCookie(ctx) },
|
||||
})
|
||||
await once(socket, 'open')
|
||||
const frames: Record<string, unknown>[] = []
|
||||
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
|
||||
sendOpen(socket, 'events', '$events', {})
|
||||
await vi.waitFor(() => { expect(frames.filter(frame => frame.streamId === 'events')).toHaveLength(1) })
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: 'events', value: 'x'.repeat(40) }))
|
||||
}
|
||||
// A Remote stream opened afterwards on the same socket shows the flood was processed and harmed nothing.
|
||||
sendOpen(socket, 'after', 'feed/sync', { label: 'after' })
|
||||
await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'end', streamId: 'after' }) })
|
||||
expect(frames.filter(frame => frame.streamId === 'events').map(frame => frame.type)).toEqual(['item'])
|
||||
socket.close()
|
||||
await once(socket, 'close')
|
||||
})
|
||||
|
||||
it('carries the registered Remote event source and withdraws its active stream', async () => {
|
||||
const { ctx } = await setup(true)
|
||||
let sourceSignal: AbortSignal | undefined
|
||||
@@ -603,6 +1017,8 @@ describe('Typert Remote streams', () => {
|
||||
|
||||
it('retries a colliding Remote event Client id before opening the second generation', async () => {
|
||||
const { ctx } = await setup(true)
|
||||
// Connection minted the operator Peer id above; only the event Client ids are counted below.
|
||||
randomUuid.mockClear()
|
||||
const source = new RemoteEventSourceProbe()
|
||||
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
||||
const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType<typeof randomUUID>
|
||||
@@ -956,6 +1372,7 @@ function descriptors(): InvocationDescriptor[] {
|
||||
source: 'json' as const,
|
||||
codec: { mode: 'strict' as const, typeSymbol: '@fixture/feed#Label', create: () => z.string() },
|
||||
}
|
||||
const prefix = { ...label, name: 'prefix', wire: 'prefix' }
|
||||
const stream = (method: string, parameters: InvocationDescriptor['parameters'], schema: z.ZodType): InvocationDescriptor => ({
|
||||
id: `@fixture/feed#feed/${method}`,
|
||||
service: 'feed',
|
||||
@@ -966,7 +1383,20 @@ function descriptors(): InvocationDescriptor[] {
|
||||
parameters,
|
||||
result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => schema },
|
||||
})
|
||||
const withUplink = (method: string, parameters: InvocationDescriptor['parameters'], cancellable = true): InvocationDescriptor => ({
|
||||
...stream(method, parameters, z.string()),
|
||||
uplink: { codec: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => z.string() } },
|
||||
...(cancellable ? { cancellation: { parameter: 'signal' } } : {}),
|
||||
})
|
||||
return [
|
||||
withUplink('echo', [prefix]),
|
||||
withUplink('swallow', []),
|
||||
withUplink('ignore', [prefix], false),
|
||||
withUplink('hold', []),
|
||||
withUplink('peek', []),
|
||||
withUplink('drain', [], false),
|
||||
withUplink('rereads', [], false),
|
||||
stream('context', [label], z.string()),
|
||||
{ ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } },
|
||||
stream('sync', [label], z.string()),
|
||||
stream('invalid', [], z.string()),
|
||||
@@ -984,6 +1414,15 @@ function descriptors(): InvocationDescriptor[] {
|
||||
parameters: [label],
|
||||
result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => z.string() },
|
||||
},
|
||||
{
|
||||
id: '@fixture/feed#feed/unaryUplink',
|
||||
service: 'feed',
|
||||
namespace: 'feed',
|
||||
method: 'unaryUplink',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [],
|
||||
result: { mode: 'strict', typeSymbol: '@fixture/feed#Items', create: () => z.array(z.string()) },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1089,3 +1528,18 @@ async function collect(source: AsyncIterable<unknown>): Promise<unknown[]> {
|
||||
for await (const value of source) values.push(value)
|
||||
return values
|
||||
}
|
||||
|
||||
async function *toAsync<T>(values: readonly T[]): AsyncIterable<T> {
|
||||
for (const value of values) yield value
|
||||
}
|
||||
|
||||
function neverYielding(): AsyncIterable<string> {
|
||||
return { [Symbol.asyncIterator]: () => ({ next: () => new Promise<IteratorResult<string>>(() => {}) }) }
|
||||
}
|
||||
|
||||
function abortOf(signal: AbortSignal): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (signal.aborted) resolve()
|
||||
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
RemoteResult,
|
||||
RemoteStreamHandle,
|
||||
TypertContextMap,
|
||||
TypertContextWire,
|
||||
TypertContext,
|
||||
@@ -25,9 +26,11 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import type { ClientRemote } from '../src/client/index.ts'
|
||||
import { apply, inject, RemoteStream } from '../src/client/index.ts'
|
||||
import {
|
||||
ClientUplinkQueue,
|
||||
RemoteStreamCarrierError,
|
||||
RemoteStreamMuxClient,
|
||||
} from '../src/client/stream-client.ts'
|
||||
import { parseRemoteStreamClientMessage } from '../src/stream-protocol.ts'
|
||||
|
||||
type FixtureApprovalOutcome = 'allowed' | 'unavailable'
|
||||
const fixtureContextTag = Symbol('fixture-context-tag')
|
||||
@@ -93,6 +96,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
) => Promise<RemoteResult<{ readonly ref: string }>>
|
||||
'probe/maybe': (value: string | null | undefined) => Promise<RemoteResult<string | null | undefined>>
|
||||
'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable<string>
|
||||
'probe/attach': (topic: string, signal?: AbortSignal) => RemoteStreamHandle<string, string>
|
||||
}
|
||||
|
||||
interface TypertRemoteScopeMap {
|
||||
@@ -224,6 +228,28 @@ function streamDescriptor(): InvocationDescriptor {
|
||||
}
|
||||
}
|
||||
|
||||
function attachDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/probe#probe/attach',
|
||||
service: 'probe',
|
||||
namespace: 'probe',
|
||||
method: 'attach',
|
||||
mode: 'stream',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{
|
||||
name: 'topic',
|
||||
wire: 'topic',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#Topic', create: () => z.string().min(1) },
|
||||
}],
|
||||
uplink: {
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AttachInput', create: () => z.string() },
|
||||
},
|
||||
cancellation: { parameter: 'signal' },
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#AttachItem', create: () => z.string().min(1) },
|
||||
}
|
||||
}
|
||||
|
||||
type WebSocketGlobal = { WebSocket?: typeof WebSocket }
|
||||
|
||||
class FakeWebSocket extends EventTarget {
|
||||
@@ -2226,20 +2252,6 @@ describe('Client Typert API', () => {
|
||||
it('guards stream iteration across mount and Connection withdrawal', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
const ctx = await bench(call)
|
||||
const firstDispose = await ctx.remote.$mount({
|
||||
package: '@fixture/stream-first', descriptors: [streamDescriptor()],
|
||||
})
|
||||
const withdrawn = ctx.remote.probe.watch('withdrawn')[Symbol.asyncIterator]()
|
||||
await firstDispose()
|
||||
await expect(withdrawn.next()).rejects.toThrow('Remote method probe/watch is no longer mounted')
|
||||
|
||||
const secondDispose = await ctx.remote.$mount({
|
||||
package: '@fixture/stream-second', descriptors: [streamDescriptor()],
|
||||
})
|
||||
ctx.set('connection', undefined)
|
||||
await expect(ctx.remote.probe.watch('offline')[Symbol.asyncIterator]().next())
|
||||
.rejects.toThrow('probe/watch has no active Connection')
|
||||
|
||||
let release!: () => void
|
||||
const released = new Promise<void>((resolve) => { release = resolve })
|
||||
let markStarted!: () => void
|
||||
@@ -2252,12 +2264,26 @@ describe('Client Typert API', () => {
|
||||
ctx.set('connection', {
|
||||
rpc: { call, open: () => source() },
|
||||
} as unknown as ConnectionHandle)
|
||||
const firstDispose = await ctx.remote.$mount({
|
||||
package: '@fixture/stream-first', descriptors: [streamDescriptor()],
|
||||
})
|
||||
// The handle opens its stream when the method is called; the mount is withdrawn before the first item arrives.
|
||||
const active = ctx.remote.probe.watch('active')[Symbol.asyncIterator]()
|
||||
const pending = active.next()
|
||||
await started
|
||||
await secondDispose()
|
||||
await firstDispose()
|
||||
release()
|
||||
await expect(pending).rejects.toThrow('Remote method probe/watch is no longer mounted')
|
||||
|
||||
const secondDispose = await ctx.remote.$mount({
|
||||
package: '@fixture/stream-second', descriptors: [streamDescriptor()],
|
||||
})
|
||||
ctx.set('connection', undefined)
|
||||
expect(() => ctx.remote.probe.watch('offline')).toThrow('probe/watch has no active Connection')
|
||||
// A method function read before the withdrawal keeps its mount token and refuses afterwards.
|
||||
const retained = ctx.remote.probe.watch
|
||||
await secondDispose()
|
||||
expect(() => retained('withdrawn')).toThrow('Remote method probe/watch is no longer mounted')
|
||||
})
|
||||
|
||||
it('publishes a namespace only after every contributed method is installed', async () => {
|
||||
@@ -2416,6 +2442,364 @@ describe('Client Typert API', () => {
|
||||
else Object.defineProperty(globalThis, 'document', documentDescriptor)
|
||||
}
|
||||
})
|
||||
|
||||
it('hands the handle uplink to the in-process carrier and echoes it through remote-mock', async () => {
|
||||
const mock = RemoteMock.create().stream('probe/attach', async (args, stream) => {
|
||||
const [{ topic }] = args as [{ readonly topic: string }]
|
||||
for await (const item of stream.uplink) stream.push(`${topic}:${String(item)}`)
|
||||
stream.end()
|
||||
})
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
const { ctx, client } = await benchFiber(call, 'in-process', mock.rpc.open)
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/weak-uplink',
|
||||
descriptors: [{ ...attachDescriptor(), uplink: { codec: { mode: 'src-json' } } }],
|
||||
})).rejects.toThrow('client api: generated Remote probe/attach field "uplink" has no strict codec')
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] })
|
||||
try {
|
||||
const alpha = ctx.remote.probe.attach('alpha')
|
||||
alpha.send('a')
|
||||
alpha.send('b')
|
||||
alpha.end()
|
||||
alpha.end()
|
||||
await expect(drainStream(alpha)).resolves.toEqual(['alpha:a', 'alpha:b'])
|
||||
expect(() => { alpha.send('late') }).toThrow('client api: probe/attach stream has terminated')
|
||||
const beta = ctx.remote.probe.attach('beta', new AbortController().signal)
|
||||
beta.send('c')
|
||||
expect(() => { beta.end(); beta.send('d') }).toThrow('client api: probe/attach uplink was ended')
|
||||
await expect(drainStream(beta)).resolves.toEqual(['beta:c'])
|
||||
expect(mock.log.streams('probe/attach').map(open => open.args)).toEqual([[{ topic: 'alpha' }], [{ topic: 'beta' }]])
|
||||
|
||||
// Arity is a runtime contract, so the topic is deliberately omitted through Reflect.
|
||||
expect(() => { Reflect.apply(ctx.remote.probe.attach, ctx.remote.probe, []) }).toThrow(
|
||||
'client api: probe/attach expected 1 business argument(s) plus an optional AbortSignal, got 0',
|
||||
)
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await dispose()
|
||||
await client.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('sends handle uplink items as wire frames, queued until the open frame, and half-closes on end', async () => {
|
||||
await withFakeWebSocket('https://harness.example/', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>(), 'web')
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] })
|
||||
try {
|
||||
const handle = ctx.remote.probe.attach('alpha')
|
||||
handle.send('a')
|
||||
handle.send('b')
|
||||
handle.end()
|
||||
const stream = handle[Symbol.asyncIterator]()
|
||||
const first = stream.next()
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(4) })
|
||||
const frames = socket.sent.map(text => parseRemoteStreamClientMessage(text))
|
||||
const opened = frames[0]
|
||||
if (opened?.type !== 'open') throw new Error('fixture expected the open frame first')
|
||||
expect(opened).toMatchObject({ endpoint: 'probe/attach', payload: { args: { topic: 'alpha' } } })
|
||||
expect(frames.slice(1)).toEqual([
|
||||
{ type: 'item', streamId: opened.streamId, value: 'a' },
|
||||
{ type: 'item', streamId: opened.streamId, value: 'b' },
|
||||
{ type: 'end', streamId: opened.streamId },
|
||||
])
|
||||
|
||||
socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:a' })
|
||||
await expect(first).resolves.toEqual({ done: false, value: 'alpha:a' })
|
||||
const ended = stream.next()
|
||||
socket.receive({ type: 'end', streamId: opened.streamId })
|
||||
await expect(ended).resolves.toEqual({ done: true, value: undefined })
|
||||
expect(socket.sent).toHaveLength(4)
|
||||
expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated')
|
||||
handle.end()
|
||||
expect(socket.sent).toHaveLength(4)
|
||||
} finally {
|
||||
await dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('terminates the handle uplink when the downlink ends first', async () => {
|
||||
await withFakeWebSocket('https://harness.example/', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>(), 'web')
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] })
|
||||
try {
|
||||
const handle = ctx.remote.probe.attach('alpha')
|
||||
const stream = handle[Symbol.asyncIterator]()
|
||||
const pending = stream.next()
|
||||
handle.send('first')
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) })
|
||||
const opened = JSON.parse(socket.sent[0]!) as { streamId: string }
|
||||
socket.receive({ type: 'end', streamId: opened.streamId })
|
||||
await expect(pending).resolves.toEqual({ done: true, value: undefined })
|
||||
expect(() => { handle.send('second') }).toThrow('client api: probe/attach stream has terminated')
|
||||
expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item'])
|
||||
} finally {
|
||||
await dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('sends cancel and ends the downlink quietly when the handle is disposed', async () => {
|
||||
await withFakeWebSocket('https://harness.example/', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>(), 'web')
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] })
|
||||
try {
|
||||
const handle = ctx.remote.probe.attach('alpha')
|
||||
const pending = handle[Symbol.asyncIterator]().next()
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) })
|
||||
handle.dispose()
|
||||
handle.dispose()
|
||||
await expect(pending).resolves.toEqual({ done: true, value: undefined })
|
||||
expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel'])
|
||||
expect(() => { handle.send('after dispose') }).toThrow('client api: probe/attach stream has terminated')
|
||||
|
||||
// Breaking out of the iteration early is the same as dispose().
|
||||
const second = ctx.remote.probe.attach('beta')
|
||||
const iterator = second[Symbol.asyncIterator]()
|
||||
const opened = iterator.next()
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(3) })
|
||||
const betaId = (JSON.parse(socket.sent[2]!) as { streamId: string }).streamId
|
||||
socket.receive({ type: 'item', streamId: betaId, value: 'beta:1' })
|
||||
await expect(opened).resolves.toEqual({ done: false, value: 'beta:1' })
|
||||
await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined })
|
||||
expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type))
|
||||
.toEqual(['open', 'cancel', 'open', 'cancel'])
|
||||
expect(() => { second.send('late') }).toThrow('client api: probe/attach stream has terminated')
|
||||
} finally {
|
||||
await dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('sends cancel on dispose while nobody reads, and drops what was buffered', async () => {
|
||||
await withFakeWebSocket('https://harness.example/', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>(), 'web')
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] })
|
||||
try {
|
||||
const handle = ctx.remote.probe.attach('alpha')
|
||||
const iterator = handle[Symbol.asyncIterator]()
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) })
|
||||
const opened = JSON.parse(socket.sent[0]!) as { streamId: string }
|
||||
socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:1' })
|
||||
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'alpha:1' })
|
||||
// No read is pending: the cancel still goes out on dispose, and the buffered item is dropped.
|
||||
socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:2' })
|
||||
handle.dispose()
|
||||
await vi.waitFor(() => {
|
||||
expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel'])
|
||||
})
|
||||
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
|
||||
expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated')
|
||||
} finally {
|
||||
await dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('ends the uplink the moment a terminal frame arrives, before the consumer reads it', async () => {
|
||||
await withFakeWebSocket('https://harness.example/', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>(), 'web')
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] })
|
||||
try {
|
||||
const handle = ctx.remote.probe.attach('alpha')
|
||||
handle.send('a')
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) })
|
||||
const opened = JSON.parse(socket.sent[0]!) as { streamId: string }
|
||||
socket.receive({ type: 'end', streamId: opened.streamId })
|
||||
expect(() => { handle.send('after end') }).toThrow('client api: probe/attach stream has terminated')
|
||||
handle.end()
|
||||
await expect(drainStream(handle)).resolves.toEqual([])
|
||||
expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item'])
|
||||
} finally {
|
||||
await dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores server frames for a stream it no longer tracks', async () => {
|
||||
await withFakeWebSocket('https://harness.example/', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>(), 'web')
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] })
|
||||
try {
|
||||
const alpha = ctx.remote.probe.attach('alpha')
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) })
|
||||
const alphaId = (JSON.parse(socket.sent[0]!) as { streamId: string }).streamId
|
||||
const beta = ctx.remote.probe.attach('beta')
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) })
|
||||
const betaId = (JSON.parse(socket.sent[1]!) as { streamId: string }).streamId
|
||||
alpha.dispose()
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(3) })
|
||||
// The Host's end for alpha crosses the cancel on the wire, so no stream owns that id any more; a
|
||||
// never-opened id is just as unknown. Neither frame disturbs beta or the socket.
|
||||
socket.receive({ type: 'end', streamId: alphaId })
|
||||
socket.receive({ type: 'item', streamId: 'never-opened', value: 'stray' })
|
||||
socket.receive({ type: 'item', streamId: betaId, value: 'beta:1' })
|
||||
socket.receive({ type: 'end', streamId: betaId })
|
||||
await expect(drainStream(beta)).resolves.toEqual(['beta:1'])
|
||||
expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'open', 'cancel'])
|
||||
expect(socket.closedWith).toEqual([])
|
||||
} finally {
|
||||
await dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('swallows a carrier iterator whose return() rejects on dispose', async () => {
|
||||
// One item, then silence: dispose reaches the carrier's return() while the stream is suspended at that item.
|
||||
const next = vi.fn<() => Promise<IteratorResult<unknown>>>()
|
||||
.mockResolvedValueOnce({ value: 'alpha:1', done: false })
|
||||
.mockReturnValue(new Promise<IteratorResult<unknown>>(() => {}))
|
||||
const returned = vi.fn<() => Promise<IteratorResult<unknown>>>()
|
||||
.mockRejectedValueOnce(new Error('carrier return failed'))
|
||||
.mockResolvedValue({ value: undefined, done: true })
|
||||
const open: NonNullable<ConnectionHandle['rpc']['open']> = () => ({
|
||||
[Symbol.asyncIterator]: () => ({ next, return: returned }),
|
||||
})
|
||||
const { ctx, client } = await benchFiber(vi.fn<ConnectionHandle['rpc']['call']>(), 'in-process', open)
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] })
|
||||
try {
|
||||
const handle = ctx.remote.probe.attach('alpha')
|
||||
const iterator = handle[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'alpha:1' })
|
||||
handle.dispose()
|
||||
await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() })
|
||||
// The rejection is swallowed (an unhandled rejection would fail this test) and the iteration ends quietly.
|
||||
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
|
||||
} finally {
|
||||
await dispose()
|
||||
await client.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('sends a top-level undefined item as an item frame without value', async () => {
|
||||
await withFakeWebSocket('https://harness.example/', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>(), 'web')
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] })
|
||||
try {
|
||||
const handle: RemoteStreamHandle<string, unknown> = ctx.remote.probe.attach('alpha')
|
||||
handle.send(undefined)
|
||||
expect(() => { handle.send({ nested: undefined }) }).toThrow('is not a lossless JSON value')
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) })
|
||||
const opened = JSON.parse(socket.sent[0]!) as { streamId: string }
|
||||
const item = JSON.parse(socket.sent[1]!) as Record<string, unknown>
|
||||
expect(item).toEqual({ type: 'item', streamId: opened.streamId })
|
||||
expect(Object.keys(item)).toEqual(['type', 'streamId'])
|
||||
handle.dispose()
|
||||
} finally {
|
||||
await dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('disposes on an iterator return that precedes the first read', async () => {
|
||||
await withFakeWebSocket('https://harness.example/', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>(), 'web')
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] })
|
||||
try {
|
||||
const handle = ctx.remote.probe.attach('alpha')
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) })
|
||||
await expect(handle[Symbol.asyncIterator]().return?.()).resolves.toEqual({ done: true, value: undefined })
|
||||
await vi.waitFor(() => {
|
||||
expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel'])
|
||||
})
|
||||
expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated')
|
||||
} finally {
|
||||
await dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the uplink when the stream fails to open and keeps the failure for the reader', async () => {
|
||||
const open: NonNullable<ConnectionHandle['rpc']['open']> = () => ({
|
||||
[Symbol.asyncIterator]: () => ({ next: () => Promise.reject(new Error('handshake failed')) }),
|
||||
})
|
||||
const { ctx, client } = await benchFiber(vi.fn<ConnectionHandle['rpc']['call']>(), 'in-process', open)
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] })
|
||||
try {
|
||||
const handle = ctx.remote.probe.attach('alpha')
|
||||
await vi.waitFor(() => {
|
||||
expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated')
|
||||
})
|
||||
await expect(drainStream(handle)).rejects.toThrow('handshake failed')
|
||||
} finally {
|
||||
await dispose()
|
||||
await client.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses an uplink item that is not a lossless JSON value', async () => {
|
||||
const mock = RemoteMock.create().stream('probe/attach', async (args, stream) => {
|
||||
const [{ topic }] = args as [{ readonly topic: string }]
|
||||
for await (const item of stream.uplink) stream.push(`${topic}:${String(item)}`)
|
||||
stream.end()
|
||||
})
|
||||
const { ctx, client } = await benchFiber(vi.fn<ConnectionHandle['rpc']['call']>(), 'in-process', mock.rpc.open)
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] })
|
||||
try {
|
||||
// Widened to the loosest caller `send` accepts: the check is on the value, not on the generated type.
|
||||
const handle: RemoteStreamHandle<string, unknown> = ctx.remote.probe.attach('alpha')
|
||||
expect(() => { handle.send(1n) }).toThrow('client api: probe/attach uplink item is not a lossless JSON value')
|
||||
expect(() => { handle.send(Number.NaN) }).toThrow('is not a lossless JSON value')
|
||||
handle.send('ok')
|
||||
handle.end()
|
||||
await expect(drainStream(handle)).resolves.toEqual(['alpha:ok'])
|
||||
} finally {
|
||||
await dispose()
|
||||
await client.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('lets one uplink read wait at a time', async () => {
|
||||
const queue = new ClientUplinkQueue('probe/attach')
|
||||
const first = queue.next()
|
||||
await expect(queue.next()).rejects.toThrow('client api: probe/attach uplink has one pending read')
|
||||
queue.push('x')
|
||||
await expect(first).resolves.toEqual({ value: 'x', done: false })
|
||||
queue.end()
|
||||
await expect(queue.next()).resolves.toEqual({ value: undefined, done: true })
|
||||
expect(() => { queue.push('late') }).toThrow('client api: probe/attach uplink was ended')
|
||||
await expect(queue.return()).resolves.toEqual({ value: undefined, done: true })
|
||||
expect(() => { queue.push('closed') }).toThrow('client api: probe/attach stream has terminated')
|
||||
})
|
||||
|
||||
it('sends cancel and fails the downlink when the caller signal aborts', async () => {
|
||||
await withFakeWebSocket('https://harness.example/', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>(), 'web')
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] })
|
||||
try {
|
||||
const abort = new AbortController()
|
||||
const handle = ctx.remote.probe.attach('alpha', abort.signal)
|
||||
const pending = handle[Symbol.asyncIterator]().next()
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) })
|
||||
const reason = new Error('caller left')
|
||||
abort.abort(reason)
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel'])
|
||||
expect(() => { handle.send('after abort') }).toThrow('client api: probe/attach stream has terminated')
|
||||
expect(() => handle[Symbol.asyncIterator]().next()).toThrow('client api: probe/attach stream has one consumer')
|
||||
} finally {
|
||||
await dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remote stream client carrier lifecycle', () => {
|
||||
@@ -2433,6 +2817,40 @@ describe('Remote stream client carrier lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('fails the downlink and cancels when an uplink iterable throws, and swallows a rejecting uplink return()', async () => {
|
||||
await withFakeWebSocket('https://harness.example', async () => {
|
||||
const client = new RemoteStreamMuxClient()
|
||||
client.start()
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
const exploding = (async function* (): AsyncGenerator<string> {
|
||||
yield 'a'
|
||||
throw new Error('uplink exploded')
|
||||
})()
|
||||
const pending = client.open('probe/attach', {}, new AbortController().signal, exploding)[Symbol.asyncIterator]().next()
|
||||
await expect(pending).rejects.toThrow('uplink exploded')
|
||||
expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item', 'cancel'])
|
||||
|
||||
const returned = vi.fn(async (): Promise<IteratorResult<string>> => {
|
||||
throw new Error('fixture release failure')
|
||||
})
|
||||
const stuck: AsyncIterable<string> = {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => new Promise<IteratorResult<string>>(() => {}),
|
||||
return: returned,
|
||||
}),
|
||||
}
|
||||
const abort = new AbortController()
|
||||
const second = client.open('probe/attach', {}, abort.signal, stuck)[Symbol.asyncIterator]().next()
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(4) })
|
||||
const reason = new Error('caller left')
|
||||
abort.abort(reason)
|
||||
await expect(second).rejects.toBe(reason)
|
||||
await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() })
|
||||
expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item', 'cancel', 'open', 'cancel'])
|
||||
await client.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('requires the transport owner to start the physical carrier', async () => {
|
||||
const client = new RemoteStreamMuxClient()
|
||||
await expect(client.open('feed/follow', {}, new AbortController().signal)
|
||||
@@ -2737,3 +3155,9 @@ async function withFakeWebSocket(
|
||||
else Object.defineProperty(globalThis, 'location', locationDescriptor)
|
||||
}
|
||||
}
|
||||
|
||||
async function drainStream(source: AsyncIterable<unknown>): Promise<unknown[]> {
|
||||
const values: unknown[] = []
|
||||
for await (const value of source) values.push(value)
|
||||
return values
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context, Service, symbols } from '@deepseek-ai/cordis'
|
||||
import { z } from 'zod'
|
||||
import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection'
|
||||
import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
|
||||
import type { HostConnectionHandle, PeerId, PeerScope } from '@deepseek-ai/dsh-client-connection'
|
||||
import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import {
|
||||
bindTypertRemote,
|
||||
@@ -112,9 +112,12 @@ class FakeConnectionService extends Service {
|
||||
channel: string | undefined
|
||||
matches: ((endpoint: string) => boolean) | undefined
|
||||
handler: FakeRpcHandler | undefined
|
||||
/** The operator Peer every call this fake dispatches speaks for. */
|
||||
readonly operator: PeerScope
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'connection')
|
||||
this.operator = { id: 'fake-operator' as PeerId, ctx, dispose: () => Promise.resolve() }
|
||||
}
|
||||
|
||||
get rpc() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isRemoteJsonValue } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import {
|
||||
isRemoteJsonValue,
|
||||
parseRemoteEventResult,
|
||||
parseRemoteStreamClientMessage,
|
||||
projectRemoteEventRequest,
|
||||
|
||||
@@ -14,6 +14,15 @@ describe('Remote stream wire protocol', () => {
|
||||
expect(parseRemoteStreamClientMessage(JSON.stringify({
|
||||
type: 'cancel', streamId: 'stream-1',
|
||||
}))).toEqual({ type: 'cancel', streamId: 'stream-1' })
|
||||
expect(parseRemoteStreamClientMessage(JSON.stringify({
|
||||
type: 'item', streamId: 'stream-1', value: { line: 'ls\n' },
|
||||
}))).toEqual({ type: 'item', streamId: 'stream-1', value: { line: 'ls\n' } })
|
||||
expect(parseRemoteStreamClientMessage(JSON.stringify({
|
||||
type: 'item', streamId: 'stream-1',
|
||||
}))).toEqual({ type: 'item', streamId: 'stream-1' })
|
||||
expect(parseRemoteStreamClientMessage(JSON.stringify({
|
||||
type: 'end', streamId: 'stream-1',
|
||||
}))).toEqual({ type: 'end', streamId: 'stream-1' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -21,12 +30,22 @@ describe('Remote stream wire protocol', () => {
|
||||
{ type: 'open', streamId: 'stream-1', endpoint: '', payload: {} },
|
||||
{ type: 'open', streamId: 'stream-1', endpoint: 'feed/follow' },
|
||||
{ type: 'cancel', streamId: 'stream-1', extra: true },
|
||||
{ type: 'item', streamId: '', value: 'line' },
|
||||
{ type: 'item', streamId: 'stream-1', value: 'line', extra: true },
|
||||
{ type: 'item', streamId: 'stream-1', endpoint: 'feed/follow' },
|
||||
{ type: 'end', streamId: '' },
|
||||
{ type: 'end', streamId: 'stream-1', value: 'line' },
|
||||
{ type: 'unknown', streamId: 'stream-1' },
|
||||
])('rejects an invalid client message: %j', (message) => {
|
||||
expect(() => parseRemoteStreamClientMessage(JSON.stringify(message)))
|
||||
.toThrow('api gateway: invalid Remote stream client message')
|
||||
})
|
||||
|
||||
it('rejects an uplink item whose value is not lossless JSON', () => {
|
||||
expect(() => parseRemoteStreamClientMessage('{"type":"item","streamId":"stream-1","value":-0}'))
|
||||
.toThrow('api gateway: invalid Remote stream client message')
|
||||
})
|
||||
|
||||
it('accepts every server message variant', () => {
|
||||
expect(parseRemoteStreamServerMessage(JSON.stringify({
|
||||
type: 'item', streamId: 'stream-1', value: null,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { once } from 'node:events'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { remoteErrorOf, type PeerId, type PeerScope } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
import {
|
||||
@@ -26,7 +28,7 @@ afterEach(async () => {
|
||||
|
||||
describe('Remote stream mux server carrier lifecycle', () => {
|
||||
it('sends WebSocket Ping control frames without application messages', async () => {
|
||||
const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20)
|
||||
const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20)
|
||||
const client = await connect(entry.url)
|
||||
const serverSocket = acceptedSocket(entry.mux)
|
||||
const messages = vi.fn()
|
||||
@@ -51,7 +53,7 @@ describe('Remote stream mux server carrier lifecycle', () => {
|
||||
})
|
||||
|
||||
it('requires two missed heartbeats before terminating an unresponsive socket', async () => {
|
||||
const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20)
|
||||
const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20)
|
||||
const client = await connect(entry.url)
|
||||
const serverSocket = acceptedSocket(entry.mux)
|
||||
serverSocket.removeAllListeners('pong')
|
||||
@@ -66,7 +68,7 @@ describe('Remote stream mux server carrier lifecycle', () => {
|
||||
})
|
||||
|
||||
it('keeps the socket when a delayed Pong arrives before the final check', async () => {
|
||||
const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20)
|
||||
const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20)
|
||||
const client = await connect(entry.url, false)
|
||||
const serverSocket = acceptedSocket(entry.mux)
|
||||
const terminated = vi.spyOn(serverSocket, 'terminate')
|
||||
@@ -92,7 +94,7 @@ describe('Remote stream mux server carrier lifecycle', () => {
|
||||
})
|
||||
|
||||
it('rejects binary, malformed, and duplicate logical-stream messages', async () => {
|
||||
const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal))
|
||||
const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal))
|
||||
|
||||
const binary = await connect(entry.url)
|
||||
const binaryClosed = once(binary, 'close')
|
||||
@@ -115,16 +117,16 @@ describe('Remote stream mux server carrier lifecycle', () => {
|
||||
expect(duplicateEvent[0]).toBe(1008)
|
||||
expect(String(duplicateEvent[1])).toBe('invalid Remote stream request')
|
||||
|
||||
const noInput = await connect(entry.url)
|
||||
noInput.send(openFrame('no-input'))
|
||||
noInput.send(JSON.stringify({ type: 'input', streamId: 'no-input', value: 'unexpected' }))
|
||||
const noInputEvent = await once(noInput, 'close')
|
||||
expect(noInputEvent[0]).toBe(1008)
|
||||
expect(String(noInputEvent[1])).toBe('invalid Remote stream request')
|
||||
const unknownType = await connect(entry.url)
|
||||
unknownType.send(openFrame('unknown-type'))
|
||||
unknownType.send(JSON.stringify({ type: 'input', streamId: 'unknown-type', value: 'unexpected' }))
|
||||
const unknownTypeEvent = await once(unknownType, 'close')
|
||||
expect(unknownTypeEvent[0]).toBe(1008)
|
||||
expect(String(unknownTypeEvent[1])).toBe('invalid Remote stream request')
|
||||
})
|
||||
|
||||
it('accepts all ws text representations and terminates a carrier error', async () => {
|
||||
const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal))
|
||||
const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal))
|
||||
const client = await connect(entry.url)
|
||||
const serverSocket = acceptedSocket(entry.mux)
|
||||
const cancel = JSON.stringify({ type: 'cancel', streamId: 'absent' })
|
||||
@@ -142,16 +144,12 @@ describe('Remote stream mux server carrier lifecycle', () => {
|
||||
const didOpen = new Promise<void>((resolve) => { opened = resolve })
|
||||
let returned!: () => void
|
||||
const didReturn = new Promise<void>((resolve) => { returned = resolve })
|
||||
const entry = await startMux(async (_endpoint, _payload, signal) => {
|
||||
const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => {
|
||||
opened()
|
||||
return cleanlyCancelled(signal, returned)
|
||||
return cleanlyCancelled(control.signal, returned)
|
||||
})
|
||||
const client = await connect(entry.url)
|
||||
const frames: unknown[] = []
|
||||
client.on('message', (data) => {
|
||||
if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame')
|
||||
frames.push(JSON.parse(data.toString('utf8')) as unknown)
|
||||
})
|
||||
const frames = collectFrames(client)
|
||||
client.send(openFrame('cancelled'))
|
||||
await didOpen
|
||||
client.send(JSON.stringify({ type: 'cancel', streamId: 'cancelled' }))
|
||||
@@ -171,13 +169,7 @@ describe('Remote stream mux server carrier lifecycle', () => {
|
||||
const client = await connect(entry.url)
|
||||
client.send(openFrame('write-failure'))
|
||||
await didOpen
|
||||
const serverSocket = acceptedSocket(entry.mux)
|
||||
const mutable = serverSocket as {
|
||||
send(data: unknown, callback: (error?: Error) => void): void
|
||||
}
|
||||
mutable.send = (_data, callback): void => {
|
||||
callback(new Error('fixture ws write failure'))
|
||||
}
|
||||
failWrites(acceptedSocket(entry.mux))
|
||||
|
||||
const closed = once(client, 'close')
|
||||
release()
|
||||
@@ -186,6 +178,43 @@ describe('Remote stream mux server carrier lifecycle', () => {
|
||||
expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered')
|
||||
})
|
||||
|
||||
it('closes the carrier when the end frame cannot be written', async () => {
|
||||
const entry = await startMux(async () => (async function *(): AsyncIterable<never> {})())
|
||||
const client = await connect(entry.url)
|
||||
failWrites(acceptedSocket(entry.mux))
|
||||
|
||||
const closed = once(client, 'close')
|
||||
client.send(openFrame('empty'))
|
||||
const closeEvent = await closed
|
||||
expect(closeEvent[0]).toBe(1011)
|
||||
expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered')
|
||||
})
|
||||
|
||||
it('leaves a failed stream silent once its socket is no longer open', async () => {
|
||||
const opened = Promise.withResolvers<undefined>()
|
||||
const entry = await startMux(async () => {
|
||||
opened.resolve(undefined)
|
||||
throw new Error('fixture opener failure')
|
||||
})
|
||||
const client = await connect(entry.url)
|
||||
const frames = collectFrames(client)
|
||||
const serverSocket = acceptedSocket(entry.mux)
|
||||
Object.defineProperty(serverSocket, 'readyState', { configurable: true, value: WebSocket.CLOSING })
|
||||
try {
|
||||
client.send(openFrame('silent'))
|
||||
await opened.promise
|
||||
// The failure path awaits nothing before it inspects the socket, so one
|
||||
// macrotask hop drains every microtask it can schedule.
|
||||
await new Promise<void>((resolve) => { setImmediate(resolve) })
|
||||
expect(frames).toEqual([])
|
||||
} finally {
|
||||
Reflect.deleteProperty(serverSocket, 'readyState')
|
||||
}
|
||||
expect(serverSocket.readyState).toBe(WebSocket.OPEN)
|
||||
client.close()
|
||||
await once(client, 'close')
|
||||
})
|
||||
|
||||
it('contains an item produced after its socket closes', async () => {
|
||||
let release!: () => void
|
||||
const released = new Promise<void>((resolve) => { release = resolve })
|
||||
@@ -210,9 +239,9 @@ describe('Remote stream mux server carrier lifecycle', () => {
|
||||
const didOpen = new Promise<void>((resolve) => { opened = resolve })
|
||||
let returned!: () => void
|
||||
const didReturn = new Promise<void>((resolve) => { returned = resolve })
|
||||
const entry = await startMux(async (_endpoint, _payload, signal) => {
|
||||
const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => {
|
||||
opened()
|
||||
return cleanlyCancelled(signal, returned)
|
||||
return cleanlyCancelled(control.signal, returned)
|
||||
})
|
||||
const client = await connect(entry.url)
|
||||
client.send(openFrame('active'))
|
||||
@@ -228,16 +257,254 @@ describe('Remote stream mux server carrier lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const mapFailure: RemoteStreamFailureMapper = error => ({
|
||||
describe('Remote stream mux server Peer binding', () => {
|
||||
it('closes an upgrade at once when the admitted Peer scope is already disposed', async () => {
|
||||
const peer = await fixturePeer()
|
||||
await peer.dispose()
|
||||
const opened = vi.fn()
|
||||
const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => {
|
||||
opened()
|
||||
return waitForAbort(control.signal)
|
||||
}, 2_000, 262_144, peer)
|
||||
const client = new WebSocket(entry.url)
|
||||
const closeEvent = await once(client, 'close')
|
||||
expect(closeEvent[0]).toBe(1001)
|
||||
expect(String(closeEvent[1])).toBe('peer left')
|
||||
expect(opened).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hands the admitted Peer to every opener and closes the socket when its scope is disposed', async () => {
|
||||
const peer = await fixturePeer()
|
||||
const seen: PeerScope[] = []
|
||||
const entry = await startMux(async (_endpoint, _payload, _uplink, opened, control) => {
|
||||
seen.push(opened)
|
||||
return waitForAbort(control.signal)
|
||||
}, 2_000, 262_144, peer)
|
||||
const client = await connect(entry.url)
|
||||
client.send(openFrame('bound'))
|
||||
await vi.waitFor(() => { expect(seen).toHaveLength(1) })
|
||||
expect(seen[0]).toBe(peer)
|
||||
|
||||
const closed = once(client, 'close')
|
||||
await peer.dispose()
|
||||
const closeEvent = await closed
|
||||
expect(closeEvent[0]).toBe(1001)
|
||||
expect(String(closeEvent[1])).toBe('peer left')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remote stream mux server uplink', () => {
|
||||
it('buffers uplink items sent before the opener resolves and half-closes on end', async () => {
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const entry = await startMux(async (_endpoint, _payload, uplink) => {
|
||||
await release.promise
|
||||
return echoUplink(uplink)
|
||||
})
|
||||
const client = await connect(entry.url)
|
||||
const frames = collectFrames(client)
|
||||
const received = countMessages(acceptedSocket(entry.mux))
|
||||
client.send(openFrame('echo'))
|
||||
client.send(itemFrame('echo', 'one'))
|
||||
client.send(itemFrame('echo'))
|
||||
await vi.waitFor(() => { expect(received.count).toBe(3) })
|
||||
expect(frames).toEqual([])
|
||||
|
||||
release.resolve(undefined)
|
||||
await vi.waitFor(() => {
|
||||
expect(frames).toEqual([
|
||||
{ type: 'item', streamId: 'echo', value: 'one' },
|
||||
{ type: 'item', streamId: 'echo' },
|
||||
])
|
||||
})
|
||||
client.send(endFrame('echo'))
|
||||
await vi.waitFor(() => { expect(frames.at(-1)).toEqual({ type: 'end', streamId: 'echo' }) })
|
||||
expect(frames).toHaveLength(3)
|
||||
client.close()
|
||||
await once(client, 'close')
|
||||
})
|
||||
|
||||
it('fails a stream on an item after end and keeps the socket open', async () => {
|
||||
const entry = await startMux(async (endpoint, _payload, uplink, _peer, control) =>
|
||||
endpoint === 'fixture/echo' ? echoUplink(uplink) : waitForAbort(control.signal))
|
||||
const client = await connect(entry.url)
|
||||
const frames = collectFrames(client)
|
||||
client.send(openFrame('held'))
|
||||
client.send(endFrame('held'))
|
||||
client.send(endFrame('held'))
|
||||
client.send(itemFrame('held', 'late'))
|
||||
await vi.waitFor(() => {
|
||||
expect(frames).toEqual([{
|
||||
type: 'error',
|
||||
streamId: 'held',
|
||||
error: {
|
||||
code: 'gateway/protocol',
|
||||
message: 'api gateway: Remote stream uplink item after end',
|
||||
details: { endpoint: 'fixture/follow' },
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
expect(client.readyState).toBe(WebSocket.OPEN)
|
||||
client.send(openFrame('again', 'fixture/echo'))
|
||||
client.send(itemFrame('again', 'still served'))
|
||||
await vi.waitFor(() => {
|
||||
expect(frames.at(-1)).toEqual({ type: 'item', streamId: 'again', value: 'still served' })
|
||||
})
|
||||
client.close()
|
||||
await once(client, 'close')
|
||||
})
|
||||
|
||||
it('fails a stream whose buffered uplink exceeds the configured bytes and drops later items', async () => {
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const entry = await startMux(async () => (async function *(): AsyncIterable<never> {
|
||||
await release.promise
|
||||
})(), 2_000, 48)
|
||||
const client = await connect(entry.url)
|
||||
const frames = collectFrames(client)
|
||||
const received = countMessages(acceptedSocket(entry.mux))
|
||||
client.send(openFrame('big'))
|
||||
client.send(itemFrame('big', 'x'.repeat(64)))
|
||||
client.send(itemFrame('big', 'dropped'))
|
||||
client.send(endFrame('big'))
|
||||
await vi.waitFor(() => { expect(received.count).toBe(4) })
|
||||
expect(frames).toEqual([])
|
||||
|
||||
release.resolve(undefined)
|
||||
await vi.waitFor(() => {
|
||||
expect(frames).toEqual([{
|
||||
type: 'error',
|
||||
streamId: 'big',
|
||||
error: {
|
||||
code: 'gateway/uplink-overflow',
|
||||
message: 'api gateway: Remote stream uplink exceeded 48 buffered bytes',
|
||||
details: { endpoint: 'fixture/follow' },
|
||||
},
|
||||
}])
|
||||
})
|
||||
client.close()
|
||||
await once(client, 'close')
|
||||
})
|
||||
|
||||
it('drops uplink frames for a stream it no longer owns and keeps the socket open', async () => {
|
||||
const entry = await startMux(async (endpoint, _payload, uplink, _peer, control) =>
|
||||
endpoint === 'fixture/echo' ? echoUplink(uplink) : waitForAbort(control.signal))
|
||||
const client = await connect(entry.url)
|
||||
const frames = collectFrames(client)
|
||||
const received = countMessages(acceptedSocket(entry.mux))
|
||||
client.send(itemFrame('absent', 1))
|
||||
client.send(endFrame('absent'))
|
||||
client.send(openFrame('live', 'fixture/echo'))
|
||||
client.send(itemFrame('live', 'served'))
|
||||
await vi.waitFor(() => { expect(received.count).toBe(4) })
|
||||
await vi.waitFor(() => { expect(frames).toEqual([{ type: 'item', streamId: 'live', value: 'served' }]) })
|
||||
expect(client.readyState).toBe(WebSocket.OPEN)
|
||||
client.close()
|
||||
await once(client, 'close')
|
||||
})
|
||||
|
||||
it('ends a pending uplink read when the socket closes', async () => {
|
||||
const read = Promise.withResolvers<unknown>()
|
||||
const opened = Promise.withResolvers<undefined>()
|
||||
const entry = await startMux(async (_endpoint, _payload, uplink, _peer, control) => {
|
||||
uplink[Symbol.asyncIterator]().next().then(read.resolve, read.resolve)
|
||||
opened.resolve(undefined)
|
||||
return waitForAbort(control.signal)
|
||||
})
|
||||
const client = await connect(entry.url)
|
||||
client.send(openFrame('pending'))
|
||||
await opened.promise
|
||||
client.close()
|
||||
await once(client, 'close')
|
||||
await expect(read.promise).resolves.toMatchObject({ message: 'Remote stream socket closed' })
|
||||
})
|
||||
|
||||
it('drops uplink items after the Host stops reading', async () => {
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const returned = Promise.withResolvers<undefined>()
|
||||
const entry = await startMux(async (_endpoint, _payload, uplink) => {
|
||||
const iterator = uplink[Symbol.asyncIterator]()
|
||||
const first: IteratorResult<unknown, undefined> = await iterator.next()
|
||||
await iterator.return?.()
|
||||
returned.resolve(undefined)
|
||||
return (async function *(): AsyncIterable<unknown> {
|
||||
await release.promise
|
||||
yield { first: first.value, afterReturn: (await iterator.next()).done }
|
||||
})()
|
||||
})
|
||||
const client = await connect(entry.url)
|
||||
const frames = collectFrames(client)
|
||||
const received = countMessages(acceptedSocket(entry.mux))
|
||||
client.send(openFrame('closed'))
|
||||
client.send(itemFrame('closed', 'one'))
|
||||
await returned.promise
|
||||
client.send(itemFrame('closed', 'two'))
|
||||
client.send(endFrame('closed'))
|
||||
client.send(itemFrame('closed', 'three'))
|
||||
await vi.waitFor(() => { expect(received.count).toBe(5) })
|
||||
|
||||
release.resolve(undefined)
|
||||
await vi.waitFor(() => {
|
||||
expect(frames).toEqual([
|
||||
{ type: 'item', streamId: 'closed', value: { first: 'one', afterReturn: true } },
|
||||
{ type: 'end', streamId: 'closed' },
|
||||
])
|
||||
})
|
||||
client.close()
|
||||
await once(client, 'close')
|
||||
})
|
||||
|
||||
it('reports a second uplink consumer and a second pending read as stream failures', async () => {
|
||||
const entry = await startMux(async (endpoint, _payload, uplink) => {
|
||||
const iterator = uplink[Symbol.asyncIterator]()
|
||||
if (endpoint === 'fixture/twice') uplink[Symbol.asyncIterator]()
|
||||
void iterator.next().catch(() => undefined)
|
||||
await iterator.next()
|
||||
return echoUplink(uplink)
|
||||
})
|
||||
const client = await connect(entry.url)
|
||||
const frames = collectFrames(client)
|
||||
client.send(openFrame('twice', 'fixture/twice'))
|
||||
client.send(openFrame('pending', 'fixture/pending'))
|
||||
await vi.waitFor(() => {
|
||||
expect(frames).toEqual(expect.arrayContaining([
|
||||
{
|
||||
type: 'error',
|
||||
streamId: 'twice',
|
||||
error: { code: 'internal', message: 'api gateway: Remote stream uplink inbox already has a consumer', details: {} },
|
||||
},
|
||||
{
|
||||
type: 'error',
|
||||
streamId: 'pending',
|
||||
error: { code: 'internal', message: 'api gateway: Remote stream uplink inbox has one pending read', details: {} },
|
||||
},
|
||||
]))
|
||||
})
|
||||
expect(frames).toHaveLength(2)
|
||||
client.close()
|
||||
await once(client, 'close')
|
||||
})
|
||||
})
|
||||
|
||||
const mapFailure: RemoteStreamFailureMapper = (error) => {
|
||||
const remote = remoteErrorOf(error)
|
||||
if (remote !== undefined) return { code: remote.code, message: remote.message, details: remote.details }
|
||||
return {
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 2_000): Promise<RunningMux> {
|
||||
const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs)
|
||||
async function startMux(
|
||||
open: RemoteStreamOpener,
|
||||
heartbeatIntervalMs = 2_000,
|
||||
streamInboxBytes = 262_144,
|
||||
peer?: PeerScope,
|
||||
): Promise<RunningMux> {
|
||||
const admitted = peer ?? await fixturePeer()
|
||||
const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs, streamInboxBytes)
|
||||
const http = createServer()
|
||||
http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) })
|
||||
http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head, admitted) })
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
http.once('error', reject)
|
||||
http.listen(0, '127.0.0.1', () => {
|
||||
@@ -252,6 +519,18 @@ async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 2_000):
|
||||
return entry
|
||||
}
|
||||
|
||||
/** A Peer whose scope is a plain Cordis fiber, so `peer.ctx.effect` and `dispose()` behave as the registry's do. */
|
||||
async function fixturePeer(): Promise<PeerScope> {
|
||||
const root = new Context()
|
||||
const fiber = root.plugin(() => {})
|
||||
await fiber
|
||||
return {
|
||||
id: 'fixture-peer' as PeerId,
|
||||
ctx: fiber.ctx,
|
||||
dispose: async () => { await fiber.dispose() },
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(url: string, autoPong = true): Promise<WebSocket> {
|
||||
const socket = new WebSocket(url, { autoPong })
|
||||
await once(socket, 'open')
|
||||
@@ -265,8 +544,45 @@ function acceptedSocket(mux: RemoteStreamMuxServer): WebSocket {
|
||||
return socket
|
||||
}
|
||||
|
||||
function openFrame(streamId: string): string {
|
||||
return JSON.stringify({ type: 'open', streamId, endpoint: 'fixture/follow', payload: {} })
|
||||
function collectFrames(client: WebSocket): Record<string, unknown>[] {
|
||||
const frames: Record<string, unknown>[] = []
|
||||
client.on('message', (data) => {
|
||||
if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame')
|
||||
frames.push(JSON.parse(data.toString('utf8')) as Record<string, unknown>)
|
||||
})
|
||||
return frames
|
||||
}
|
||||
|
||||
/** Count the text frames the mux has already dispatched, so a test can wait for the Host to hold them. */
|
||||
function countMessages(serverSocket: WebSocket): { count: number } {
|
||||
const received = { count: 0 }
|
||||
serverSocket.on('message', () => { received.count += 1 })
|
||||
return received
|
||||
}
|
||||
|
||||
function failWrites(serverSocket: WebSocket): void {
|
||||
const mutable = serverSocket as {
|
||||
send(data: unknown, callback: (error?: Error) => void): void
|
||||
}
|
||||
mutable.send = (_data, callback): void => {
|
||||
callback(new Error('fixture ws write failure'))
|
||||
}
|
||||
}
|
||||
|
||||
function openFrame(streamId: string, endpoint = 'fixture/follow'): string {
|
||||
return JSON.stringify({ type: 'open', streamId, endpoint, payload: {} })
|
||||
}
|
||||
|
||||
function itemFrame(streamId: string, value?: unknown): string {
|
||||
return JSON.stringify(value === undefined ? { type: 'item', streamId } : { type: 'item', streamId, value })
|
||||
}
|
||||
|
||||
function endFrame(streamId: string): string {
|
||||
return JSON.stringify({ type: 'end', streamId })
|
||||
}
|
||||
|
||||
async function *echoUplink(uplink: AsyncIterable<unknown>): AsyncIterable<unknown> {
|
||||
for await (const value of uplink) yield value
|
||||
}
|
||||
|
||||
async function *waitForAbort(signal: AbortSignal): AsyncIterable<never> {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
|
||||
import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
|
||||
import { ok, streamHandle, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
|
||||
import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
|
||||
import { ClientSessions, SessionCreateError, SessionForkError } from '../src/client/sessions/service.ts'
|
||||
import { scopeOf } from '../src/client/scope.ts'
|
||||
@@ -551,7 +551,7 @@ describe('Agent scope disposal lifecycle', () => {
|
||||
if (signal === undefined) throw new Error('fixture requires a signal')
|
||||
followSignal = signal
|
||||
let opened = false
|
||||
return {
|
||||
return streamHandle<SessionFollowFrame>({
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => {
|
||||
if (!opened) {
|
||||
@@ -588,7 +588,7 @@ describe('Agent scope disposal lifecycle', () => {
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
})
|
||||
const readiness = b.ctx.plugin(() => undefined)
|
||||
await readiness
|
||||
@@ -625,7 +625,7 @@ describe('Agent scope disposal lifecycle', () => {
|
||||
const closeGate = Promise.withResolvers<undefined>()
|
||||
closeGates.set(sessionId, closeGate)
|
||||
let opened = false
|
||||
return {
|
||||
return streamHandle<SessionFollowFrame>({
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => {
|
||||
if (!opened) {
|
||||
@@ -655,7 +655,7 @@ describe('Agent scope disposal lifecycle', () => {
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
})
|
||||
const readiness = b.ctx.plugin(() => undefined)
|
||||
await readiness
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
type RemoteStreamOptions,
|
||||
} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { streamHandle } from '@deepseek-ai/dsh-remote-mock'
|
||||
import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { RemoteResult, RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import {
|
||||
createSessionControlStream,
|
||||
SessionEventStream,
|
||||
@@ -106,7 +107,11 @@ class ScriptedSessionRemote implements SessionTransportRemote {
|
||||
private readonly holdControl = true,
|
||||
) {}
|
||||
|
||||
async *follow(request: SessionFollowRequest, signal = new AbortController().signal): AsyncIterable<SessionFollowFrame> {
|
||||
follow(request: SessionFollowRequest, signal = new AbortController().signal): RemoteStreamHandle<SessionFollowFrame, never> {
|
||||
return streamHandle(this.followFrames(request, signal))
|
||||
}
|
||||
|
||||
private async *followFrames(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
|
||||
const generation = this.generations.shift()
|
||||
if (generation === undefined) throw new Error('no scripted Session generation')
|
||||
this.followRequests.push(request)
|
||||
@@ -128,7 +133,11 @@ class ScriptedSessionRemote implements SessionTransportRemote {
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
|
||||
async *control(signal = new AbortController().signal): AsyncIterable<SessionControlFrame> {
|
||||
control(signal = new AbortController().signal): RemoteStreamHandle<SessionControlFrame, never> {
|
||||
return streamHandle(this.controlSequence(signal))
|
||||
}
|
||||
|
||||
private async *controlSequence(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
|
||||
for (const frame of this.controlFrames) yield frame
|
||||
if (this.holdControl && !signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-remote-mock": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { streamMethod } from '@deepseek-ai/dsh-remote-mock'
|
||||
import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { TerminalView, type TerminalRemote } from '../src/client/model.ts'
|
||||
@@ -31,22 +32,22 @@ function failure(message: string): RemoteResult<never> {
|
||||
|
||||
function fixture(prepareStream?: <Item>(stream: RemoteStream<Item>) => void) {
|
||||
const remote: TerminalRemote = {
|
||||
retain: vi.fn<TerminalRemote['retain']>(async function* (_session, _id, signal) {
|
||||
retain: vi.fn<TerminalRemote['retain']>(streamMethod<TerminalRemote['retain']>(async function* (_session, _id, signal) {
|
||||
yield { type: 'retained' }
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal?.aborted) resolve()
|
||||
else signal?.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}),
|
||||
})),
|
||||
shells: vi.fn<TerminalRemote['shells']>(async () => success([info.shell])),
|
||||
environment: vi.fn<TerminalRemote['environment']>(async () => success(environment)), list: vi.fn<TerminalRemote['list']>(async () => success([])),
|
||||
create: vi.fn<TerminalRemote['create']>(async (_sessionId, request) => success({ ...info, id: request.id })),
|
||||
close: vi.fn<TerminalRemote['close']>(async () => success(undefined)), rename: vi.fn<TerminalRemote['rename']>(async () => success(undefined)),
|
||||
write: vi.fn<TerminalRemote['write']>(async () => success(undefined)), resize: vi.fn<TerminalRemote['resize']>(async () => success(undefined)),
|
||||
follow: vi.fn<TerminalRemote['follow']>(async function* (_sessionId, id, attachmentId, signal) {
|
||||
follow: vi.fn<TerminalRemote['follow']>(streamMethod<TerminalRemote['follow']>(async function* (_sessionId, id, attachmentId, signal) {
|
||||
yield { type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id, controllerId: attachmentId } }
|
||||
await untilAborted(signal)
|
||||
}),
|
||||
})),
|
||||
}
|
||||
const options: RemoteStreamOptions<unknown>[] = []
|
||||
const streams: { dispose(): Promise<void>; restart(): void }[] = []
|
||||
@@ -103,7 +104,7 @@ it('does not let a late input failure downgrade a newer connection', async () =>
|
||||
|
||||
it('waits for render acknowledgement before publishing subsequent output', async () => {
|
||||
const { model, remote } = fixture()
|
||||
vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) {
|
||||
vi.mocked(remote.follow).mockImplementation(streamMethod<TerminalRemote['follow']>(async function* (_sessionId, id, controllerId, signal) {
|
||||
const frames: TerminalFrame[] = [
|
||||
{ type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } },
|
||||
{ type: 'output', sequence: 1, data: 'first' },
|
||||
@@ -111,7 +112,7 @@ it('waits for render acknowledgement before publishing subsequent output', async
|
||||
]
|
||||
for (const frame of frames) yield frame
|
||||
await untilAborted(signal)
|
||||
})
|
||||
}))
|
||||
await mount(model)
|
||||
await expect.poll(() => model.state.getSnapshot().render?.frame.type).toBe('snapshot')
|
||||
acknowledge(model)
|
||||
@@ -209,7 +210,7 @@ it.each([
|
||||
['repeated snapshot', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'snapshot', sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'],
|
||||
] satisfies readonly (readonly [string, readonly TerminalFrame[], string])[])('refuses a malformed output stream: %s', async (_name, frames, message) => {
|
||||
const { model, remote } = fixture()
|
||||
vi.mocked(remote.follow).mockImplementation(async function* () { yield* frames })
|
||||
vi.mocked(remote.follow).mockImplementation(streamMethod<TerminalRemote['follow']>(async function* () { yield* frames }))
|
||||
await mount(model)
|
||||
if (frames[0]?.type === 'snapshot') {
|
||||
await expect.poll(() => model.state.getSnapshot().render).toBeDefined()
|
||||
@@ -221,10 +222,10 @@ it.each([
|
||||
|
||||
it('retains an exited screen and closes controls when its stream ends', async () => {
|
||||
const { model, remote } = fixture()
|
||||
vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId) {
|
||||
vi.mocked(remote.follow).mockImplementation(streamMethod<TerminalRemote['follow']>(async function* (_sessionId, id, controllerId) {
|
||||
yield { type: 'snapshot', sequence: 0, screen: 'last screen', info: { ...info, id, controllerId } }
|
||||
yield { type: 'state', info: { ...info, id, controllerId, state: 'exited', exitCode: 3 } }
|
||||
})
|
||||
}))
|
||||
await connected(model)
|
||||
await expect.poll(() => model.state.getSnapshot().phase).toBe('closed')
|
||||
expect(model.state.getSnapshot()).toMatchObject({ writable: false, info: { state: 'exited', exitCode: 3 }, render: { frame: { screen: 'last screen' } } })
|
||||
@@ -232,9 +233,9 @@ it('retains an exited screen and closes controls when its stream ends', async ()
|
||||
|
||||
it('reports an attachment ending while the shell is still running and permits manual reconnect', async () => {
|
||||
const { model, remote } = fixture()
|
||||
vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId) {
|
||||
vi.mocked(remote.follow).mockImplementationOnce(streamMethod<TerminalRemote['follow']>(async function* (_sessionId, id, controllerId) {
|
||||
yield { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } }
|
||||
})
|
||||
}))
|
||||
await connected(model)
|
||||
await expect.poll(() => model.state.getSnapshot().issue).toBe('attachmentEnded')
|
||||
model.connect()
|
||||
@@ -243,11 +244,11 @@ it('reports an attachment ending while the shell is still running and permits ma
|
||||
|
||||
it('publishes remote control transfer without replacing the retained screen', async () => {
|
||||
const { model, remote } = fixture()
|
||||
vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) {
|
||||
vi.mocked(remote.follow).mockImplementation(streamMethod<TerminalRemote['follow']>(async function* (_sessionId, id, controllerId, signal) {
|
||||
yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } }
|
||||
yield { type: 'state', info: { ...info, id } }
|
||||
await untilAborted(signal)
|
||||
})
|
||||
}))
|
||||
await connected(model)
|
||||
await expect.poll(() => model.state.getSnapshot().writable).toBe(false)
|
||||
expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' })
|
||||
@@ -298,10 +299,10 @@ it('ignores an already delivered screen when its attachment is replaced before t
|
||||
it('ignores an already settled stream error after a newer attachment has become writable', async () => {
|
||||
const barrier = deliveryBarrier()
|
||||
const { model, remote } = fixture(barrier.prepare)
|
||||
vi.mocked(remote.follow).mockImplementationOnce(async function* () {
|
||||
vi.mocked(remote.follow).mockImplementationOnce(streamMethod<TerminalRemote['follow']>(async function* () {
|
||||
yield* []
|
||||
throw new Error('retired attachment failed')
|
||||
})
|
||||
}))
|
||||
await mount(model)
|
||||
await barrier.ready
|
||||
model.connect()
|
||||
@@ -338,12 +339,12 @@ it.each(['write', 'resize'] as const)('keeps a fresh attachment writable when a
|
||||
const { model, remote, generation } = fixture()
|
||||
generation.set({ id: 1, host: { home: '/home/fixture' } })
|
||||
const disconnected = Promise.withResolvers<undefined>()
|
||||
vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId, signal) {
|
||||
vi.mocked(remote.follow).mockImplementationOnce(streamMethod<TerminalRemote['follow']>(async function* (_sessionId, id, controllerId, signal) {
|
||||
yield { type: 'snapshot', sequence: 0, screen: 'before disconnect', info: { ...info, id, controllerId } }
|
||||
await Promise.race([disconnected.promise, untilAborted(signal)])
|
||||
if (signal?.aborted) return
|
||||
throw new RemoteStreamCarrierError('connection lost')
|
||||
})
|
||||
}))
|
||||
await connected(model)
|
||||
const pending = Promise.withResolvers<RemoteResult<void>>()
|
||||
if (operation === 'write') {
|
||||
@@ -516,12 +517,12 @@ it.each(['write', 'resize'] as const)('keeps the output connection when %s loses
|
||||
const transfer = Promise.withResolvers<undefined>()
|
||||
const response = Promise.withResolvers<RemoteResult<void>>()
|
||||
cleanups.push(() => { transfer.resolve(undefined); response.resolve(success(undefined)) })
|
||||
vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) {
|
||||
vi.mocked(remote.follow).mockImplementation(streamMethod<TerminalRemote['follow']>(async function* (_session, id, controllerId, signal) {
|
||||
yield { type: 'snapshot', sequence: 0, screen: 'retained screen', info: { ...info, id, controllerId } }
|
||||
await transfer.promise
|
||||
yield { type: 'state', info: { ...info, id } }
|
||||
await untilAborted(signal)
|
||||
})
|
||||
}))
|
||||
await connected(model)
|
||||
vi.mocked(remote[operation]).mockReturnValueOnce(response.promise)
|
||||
if (operation === 'write') model.write('before transfer')
|
||||
@@ -542,12 +543,12 @@ it('keeps an exited screen when a pending input is refused after the exit state
|
||||
const exit = Promise.withResolvers<undefined>()
|
||||
const response = Promise.withResolvers<RemoteResult<void>>()
|
||||
cleanups.push(() => { exit.resolve(undefined); response.resolve(success(undefined)) })
|
||||
vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) {
|
||||
vi.mocked(remote.follow).mockImplementation(streamMethod<TerminalRemote['follow']>(async function* (_session, id, controllerId, signal) {
|
||||
yield { type: 'snapshot', sequence: 0, screen: 'final screen', info: { ...info, id, controllerId } }
|
||||
await exit.promise
|
||||
yield { type: 'state', info: { ...info, id, state: 'exited', exitCode: 0 } }
|
||||
await untilAborted(signal)
|
||||
})
|
||||
}))
|
||||
await connected(model)
|
||||
vi.mocked(remote.write).mockReturnValueOnce(response.promise)
|
||||
model.write('exit race')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { streamMethod } from '@deepseek-ai/dsh-remote-mock'
|
||||
import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -29,25 +30,25 @@ function storage() {
|
||||
|
||||
function fixture() {
|
||||
const remote: TerminalRemote = {
|
||||
retain: vi.fn<TerminalRemote['retain']>(async function* (_session, _id, signal) {
|
||||
retain: vi.fn<TerminalRemote['retain']>(streamMethod<TerminalRemote['retain']>(async function* (_session, _id, signal) {
|
||||
yield { type: 'retained' }
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal?.aborted) resolve()
|
||||
else signal?.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}),
|
||||
})),
|
||||
shells: vi.fn<TerminalRemote['shells']>(async () => success([info.shell])),
|
||||
environment: vi.fn<TerminalRemote['environment']>(async () => success(environment)), list: vi.fn<TerminalRemote['list']>(async () => success([])),
|
||||
create: vi.fn<TerminalRemote['create']>(async (_session, request) => success({ ...info, id: request.id })),
|
||||
close: vi.fn<TerminalRemote['close']>(async () => success(undefined)), rename: vi.fn<TerminalRemote['rename']>(async () => success(undefined)),
|
||||
write: vi.fn<TerminalRemote['write']>(async () => success(undefined)), resize: vi.fn<TerminalRemote['resize']>(async () => success(undefined)),
|
||||
follow: vi.fn<TerminalRemote['follow']>(async function* (_session, id, controllerId, signal) {
|
||||
follow: vi.fn<TerminalRemote['follow']>(streamMethod<TerminalRemote['follow']>(async function* (_session, id, controllerId, signal) {
|
||||
yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } }
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal?.aborted) resolve()
|
||||
else signal?.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}),
|
||||
})),
|
||||
}
|
||||
const gateway: Pick<ClientRemote, '$stream'> = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) }
|
||||
function view() {
|
||||
@@ -489,7 +490,7 @@ it('waits for both active and detached stream finalizers during plugin disposal
|
||||
const finished = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
|
||||
cleanups.push(() => { for (const barrier of release) barrier.resolve(undefined) })
|
||||
let index = 0
|
||||
vi.mocked(h.remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) {
|
||||
vi.mocked(h.remote.follow).mockImplementation(streamMethod<TerminalRemote['follow']>(async function* (_session, id, controllerId, signal) {
|
||||
const current = index++
|
||||
try {
|
||||
yield { type: 'snapshot', sequence: 0, screen: 'screen', info: { ...info, id, controllerId } }
|
||||
@@ -502,7 +503,7 @@ it('waits for both active and detached stream finalizers during plugin disposal
|
||||
await release[current]!.promise
|
||||
finished[current]!.resolve(undefined)
|
||||
}
|
||||
})
|
||||
}))
|
||||
const model = service.view(sessionId, 'tab', 'tab')
|
||||
model.mount()
|
||||
await model.refresh()
|
||||
@@ -625,14 +626,14 @@ it('waits for the window hold acknowledgement before restoring an output attachm
|
||||
const h = fixture()
|
||||
vi.mocked(h.remote.list).mockResolvedValue(success([info]))
|
||||
const acknowledge = Promise.withResolvers<undefined>()
|
||||
vi.mocked(h.remote.retain).mockImplementation(async function* (_session, _id, signal) {
|
||||
vi.mocked(h.remote.retain).mockImplementation(streamMethod<TerminalRemote['retain']>(async function* (_session, _id, signal) {
|
||||
await acknowledge.promise
|
||||
yield { type: 'retained' }
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal?.aborted) resolve()
|
||||
else signal?.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
})
|
||||
}))
|
||||
const { service } = await h.service()
|
||||
service.retainTabs([{ sessionId, tabId: 'restored', contentId: 'restored' }])
|
||||
const model = service.view(sessionId, 'restored', 'restored')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import { streamMethod } from '@deepseek-ai/dsh-remote-mock'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { TerminalRemote } from '../src/client/model.ts'
|
||||
import { TerminalWindowHold } from '../src/client/retention.ts'
|
||||
@@ -9,9 +10,14 @@ import type { TerminalRetentionFrame, WebTerminalId } from '../src/types.ts'
|
||||
|
||||
const holds: TerminalWindowHold[] = []
|
||||
afterEach(async () => { await Promise.all(holds.splice(0).map(hold => hold.dispose())) })
|
||||
function hold(retain: TerminalRemote['retain']) {
|
||||
function hold(retain: Parameters<typeof streamMethod<TerminalRemote['retain']>>[0]) {
|
||||
const gateway: Pick<ClientRemote, '$stream'> = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) }
|
||||
const held = new TerminalWindowHold(gateway, { retain } as TerminalRemote, 'session' as SessionId, 'terminal' as WebTerminalId)
|
||||
const held = new TerminalWindowHold(
|
||||
gateway,
|
||||
{ retain: streamMethod<TerminalRemote['retain']>(retain) } as TerminalRemote,
|
||||
'session' as SessionId,
|
||||
'terminal' as WebTerminalId,
|
||||
)
|
||||
holds.push(held)
|
||||
return held
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ import type {
|
||||
WorkspaceId,
|
||||
WorkspaceView,
|
||||
} from '../src/types.ts'
|
||||
import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { RemoteError, type RemoteFailure, type RemoteResult, type RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { streamHandle } from '@deepseek-ai/dsh-remote-mock'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
@@ -146,7 +147,9 @@ class FakeWorkspaceRemote implements WorkspaceRemote {
|
||||
return this.onUnpinSession(request)
|
||||
}
|
||||
|
||||
async *follow(_signal?: AbortSignal): AsyncGenerator<WorkspaceFollowFrame> {}
|
||||
follow(_signal?: AbortSignal): RemoteStreamHandle<WorkspaceFollowFrame, never> {
|
||||
return streamHandle<WorkspaceFollowFrame>((async function* () {})())
|
||||
}
|
||||
|
||||
private record(method: string, request: unknown): void {
|
||||
this.calls.push({ method, request })
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"@deepseek-ai/dsh-client-resources": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-remote-mock": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* opened `changes` generation, and a supervisor that runs one generation and
|
||||
* classifies its end the way the real one does.
|
||||
*/
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { RemoteResult, RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { streamHandle } from '@deepseek-ai/dsh-remote-mock'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { WorkspaceFileWatchFrame, WorkspaceFileStat } from '../src/types.ts'
|
||||
import type { SupervisedStream, SupervisedStreamOptions, WorkspaceFilesRemote } from '../src/client/remote.ts'
|
||||
@@ -225,7 +226,7 @@ export class FakeRemote implements WorkspaceFilesRemote {
|
||||
this.statWaiters.delete(index)
|
||||
if (this.closed) resolve(this.closedStat())
|
||||
}),
|
||||
changes: (sessionId: SessionId, path: string, signal?: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> => {
|
||||
changes: (sessionId: SessionId, path: string, signal?: AbortSignal): RemoteStreamHandle<WorkspaceFileWatchFrame, never> => {
|
||||
this.calls.push('changes')
|
||||
if (signal === undefined) throw new Error('the feed must hand its signal to the Host stream')
|
||||
const source = new Source<WorkspaceFileWatchFrame>(signal)
|
||||
@@ -235,7 +236,7 @@ export class FakeRemote implements WorkspaceFilesRemote {
|
||||
for (const waiter of this.watchWaiters.get(index) ?? []) waiter(watch)
|
||||
this.watchWaiters.delete(index)
|
||||
if (this.autoReady) source.push({ kind: 'ready' })
|
||||
return source
|
||||
return streamHandle(source)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: 17bcb9940286b91433273cef2fcda3147249133b
|
||||
README.zh.md: 6a090709414007ddb7fe7ca3fdd8c29b737a39dc
|
||||
README.md: 6c49836b0e581666db0df503b175132ed1e6295b
|
||||
README.zh.md: 5843efd2299526cf00b0c6331668c304d6042f56
|
||||
@@ -27,7 +27,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con
|
||||
|
||||
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.
|
||||
|
||||
The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md).
|
||||
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).
|
||||
|
||||
-----
|
||||
|
||||
@@ -40,6 +40,8 @@ The cookie signing secret is the owner-scoped `client-connection/browser-session
|
||||
|
||||
Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md).
|
||||
|
||||
Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Cordis scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `OperatorPeer` is exported so a composition without Connection, such as the Gateway's in-process carrier, owns an operator scope with the same contract.
|
||||
|
||||
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. WebSocket stream ownership remains with API Gateway.
|
||||
|
||||
<a id="connection-generation"></a>
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-reference"
|
||||
|
||||
静态桌面页面可以通过 `__DSH_TRANSPORT__.streamBaseUrl` 提供其所拥有 Host 的 HTTP origin。Gateway 将该 origin 用于 WebSocket,HTTP 传输仍独立选择。桌面载体负责认证;仅设置 origin 不会授予访问权限。
|
||||
|
||||
浏览器通过 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)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 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) 提供。
|
||||
|
||||
-----
|
||||
|
||||
@@ -40,6 +40,8 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi
|
||||
|
||||
认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。
|
||||
|
||||
每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`OperatorPeer` 对外导出,供没有 Connection 的组合(例如 Gateway 的进程内载体)以同一约定拥有一个操作者 scope。
|
||||
|
||||
通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。WebSocket 流仍由 API Gateway 负责。
|
||||
|
||||
<a id="connection-generation"></a>
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
@@ -52,6 +53,8 @@
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^"
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,12 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
*/
|
||||
export type RpcFetch = (input: string | URL, init: RequestInit) => Promise<Response>
|
||||
|
||||
/** Worker-local opener for decoded Gateway Remote streams. */
|
||||
/** Worker-local opener for decoded Gateway Remote streams; `uplink` carries the Client's items for the stream. */
|
||||
export type RpcStreamOpen = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
uplink?: AsyncIterable<unknown>,
|
||||
) => AsyncIterable<unknown>
|
||||
|
||||
/**
|
||||
@@ -64,12 +65,12 @@ export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStrea
|
||||
return full.result
|
||||
},
|
||||
...openStream === undefined ? {} : {
|
||||
open(channel, endpoint, payload, signal) {
|
||||
open(channel, endpoint, payload, signal, uplink) {
|
||||
assertTarget(channel, endpoint)
|
||||
if (channel !== '/api') {
|
||||
throw new Error(`connection: worker-local streams require the /api channel, got ${JSON.stringify(channel)}`)
|
||||
}
|
||||
return openStream(endpoint, payload, signal)
|
||||
return openStream(endpoint, payload, signal, uplink)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { HostConnectionService } from './rpc-host.ts'
|
||||
import { ConnectionRecoveryConfigSchema, resolveConnectionConfig, type ConnectionRecoveryConfig } from './recovery-config.ts'
|
||||
|
||||
export type {
|
||||
PeerAdmission,
|
||||
ConnectionFetchMethod,
|
||||
ConnectionFetchHandler,
|
||||
ConnectionFetchRoute,
|
||||
@@ -33,7 +34,9 @@ export type {
|
||||
RpcMessage,
|
||||
ServerResponse,
|
||||
} from './rpc.ts'
|
||||
export type { PeerId, PeerScope, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol'
|
||||
export { RpcId, transportError } from './rpc.ts'
|
||||
export { OperatorPeer } from './operator-peer.ts'
|
||||
export {
|
||||
clientRequestSchema,
|
||||
rpcErrorSchema,
|
||||
@@ -141,10 +144,10 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise<vo
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
handler: async (req, res) => {
|
||||
const rejection = connection.requestRejection(req)
|
||||
if (rejection !== undefined) {
|
||||
res.writeHead(rejection)
|
||||
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
|
||||
const admission = connection.admit(req)
|
||||
if ('rejection' in admission) {
|
||||
res.writeHead(admission.rejection)
|
||||
res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
|
||||
return
|
||||
}
|
||||
await webCtx.waterfall('connection/request', req, res, () => bridge(req, res, fetchHandler, maxRequestBodyBytes))
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The operator Peer: the one party this Host answers to. Connection owns it
|
||||
* for its own lifetime, admits every request as it, and hands it to each
|
||||
* Remote call as `invocation.peer`.
|
||||
* @module @deepseek-ai/dsh-client-connection/src/operator-peer
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
|
||||
import type { PeerId, PeerScope } from '@deepseek-ai/dsh-typert-protocol'
|
||||
|
||||
/**
|
||||
* The operator's scope. The instance is its own scope key, so `scopeOf(peer.ctx)`
|
||||
* returns it and events dispatched with `scopeTarget(subject, peer)` reach
|
||||
* listeners registered through `peer.ctx` and nobody else.
|
||||
*/
|
||||
export class OperatorPeer implements PeerScope {
|
||||
readonly id: PeerId = randomUUID() as PeerId
|
||||
readonly ctx: Context
|
||||
private readonly scope: Scope
|
||||
|
||||
/** @param owner - Connection plugin context the scope fiber hangs under. */
|
||||
constructor(owner: Context) {
|
||||
this.scope = createScope(owner, this)
|
||||
this.ctx = this.scope.ctx
|
||||
}
|
||||
|
||||
/** Tear down every connection-lifetime registration; racing calls share one completion. */
|
||||
dispose(): Promise<void> {
|
||||
return this.scope.dispose()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import {
|
||||
RpcId,
|
||||
type ClientRequest,
|
||||
@@ -12,7 +13,9 @@ import { bridge } from './http-bridge.ts'
|
||||
import { isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
import type { BrowserAuth } from './browser-auth.ts'
|
||||
import { OperatorPeer } from './operator-peer.ts'
|
||||
import type {
|
||||
PeerAdmission,
|
||||
ConnectionIndexRequest,
|
||||
ConnectionIndexResponse,
|
||||
ConnectionFetchRoute,
|
||||
@@ -58,6 +61,8 @@ declare module '@deepseek-ai/cordis' {
|
||||
|
||||
/** Host Connection service whose channel registrations belong to the caller fiber. */
|
||||
export class HostConnectionService extends Service implements HostConnectionHandle {
|
||||
/** The operator Peer every admitted request speaks for. */
|
||||
readonly operator: PeerScope
|
||||
private readonly interceptors = new Map<string, ConnectionRpcInterceptor>()
|
||||
private readonly fetchRoutes = new Map<string, RegisteredFetchRoute>()
|
||||
|
||||
@@ -73,6 +78,8 @@ export class HostConnectionService extends Service implements HostConnectionHand
|
||||
private readonly browserAuth: BrowserAuth,
|
||||
) {
|
||||
super(ctx, 'connection')
|
||||
this.operator = new OperatorPeer(ctx)
|
||||
ctx.effect(() => () => this.operator.dispose(), 'client-connection: operator Peer')
|
||||
}
|
||||
|
||||
/** Generic channel registry scoped to the Context reading this service. */
|
||||
@@ -99,6 +106,12 @@ export class HostConnectionService extends Service implements HostConnectionHand
|
||||
return this.browserAuth.isAuthenticated(request) ? undefined : 401
|
||||
}
|
||||
|
||||
/** A request that passes the fence and authentication speaks for the operator. */
|
||||
admit(request: ConnectionTrustRequest): PeerAdmission {
|
||||
const rejection = this.requestRejection(request)
|
||||
return rejection === undefined ? { peer: this.operator } : { rejection }
|
||||
}
|
||||
|
||||
/** Authenticate an index request through the process-token exchange or cookie. */
|
||||
authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean {
|
||||
return this.browserAuth.authorizeIndex(request, response)
|
||||
@@ -161,15 +174,15 @@ export class HostConnectionService extends Service implements HostConnectionHand
|
||||
handler: ConnectionRpcHandler,
|
||||
): () => Promise<void> {
|
||||
assertChannel(channel)
|
||||
const fetchHandler = rpcFetchHandler(channel, handler)
|
||||
const fetchHandler = rpcFetchHandler(channel, handler, this.operator)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: channel,
|
||||
handler: async (req, res) => {
|
||||
const rejection = this.requestRejection(req)
|
||||
if (rejection !== undefined) {
|
||||
res.writeHead(rejection)
|
||||
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
|
||||
const admission = this.admit(req)
|
||||
if ('rejection' in admission) {
|
||||
res.writeHead(admission.rejection)
|
||||
res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, fetchHandler)
|
||||
@@ -192,7 +205,7 @@ export class HostConnectionService extends Service implements HostConnectionHand
|
||||
}
|
||||
const interceptor: ConnectionRpcInterceptor = {
|
||||
matches,
|
||||
fetchHandler: rpcFetchHandler(channel, handler),
|
||||
fetchHandler: rpcFetchHandler(channel, handler, this.operator),
|
||||
}
|
||||
return owner.effect(() => {
|
||||
if (this.interceptors.has(channel)) {
|
||||
@@ -209,6 +222,7 @@ export class HostConnectionService extends Service implements HostConnectionHand
|
||||
function rpcFetchHandler(
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
peer: PeerScope,
|
||||
): ConnectionFetchHandler {
|
||||
return {
|
||||
requestBodyMode: () => 'buffered',
|
||||
@@ -244,7 +258,7 @@ function rpcFetchHandler(
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(endpoint, message.payload, request.signal)
|
||||
const result = await handler(endpoint, message.payload, request.signal, peer)
|
||||
return fullResponse(message.rpcId, result)
|
||||
} catch (error) {
|
||||
return new Response(`handler failure: ${String(error)}`, { status: 500 })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Generic unary RPC contracts shared by the Host and Client Connection halves. */
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol'
|
||||
|
||||
/** Correlation id minted by a caller and echoed by the Connection response. */
|
||||
export type RpcId = Branded<'rpc-id'>
|
||||
@@ -96,11 +97,20 @@ export interface ConnectionIndexResponse {
|
||||
end(body?: string): unknown
|
||||
}
|
||||
|
||||
/** Handler invoked after Connection has decoded the transport envelope. */
|
||||
/** Outcome of admitting one request: the operator Peer it speaks for, or the status refusing it. */
|
||||
export type PeerAdmission =
|
||||
| { readonly peer: PeerScope }
|
||||
| { readonly rejection: 401 | 403 }
|
||||
|
||||
/**
|
||||
* Handler invoked after Connection has decoded the transport envelope.
|
||||
* `peer` is the Peer the request was admitted as: the operator.
|
||||
*/
|
||||
export type ConnectionRpcHandler = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
peer: PeerScope,
|
||||
) => Promise<ConnectionRpcResult<unknown>>
|
||||
|
||||
/** Synchronous ownership test for one endpoint on a shared RPC channel. */
|
||||
@@ -161,12 +171,14 @@ export interface HostConnectionRpc {
|
||||
): () => Promise<void>
|
||||
}
|
||||
|
||||
/** Host `ctx.connection` shape consumed by transport-independent adapters. */
|
||||
/** Host `ctx.connection` members consumed by transport-independent adapters. */
|
||||
export interface HostConnectionHandle {
|
||||
/** Generic RPC channel registry. */
|
||||
readonly rpc: HostConnectionRpc
|
||||
/** Exact Fetch routes for streaming or browser-native responses. */
|
||||
readonly fetch: HostConnectionFetch
|
||||
/** The operator Peer every admitted request speaks for; its scope lives as long as Connection. */
|
||||
readonly operator: PeerScope
|
||||
|
||||
/**
|
||||
* Compose exact Fetch routes and the shared-channel RPC interceptor.
|
||||
@@ -183,6 +195,14 @@ export interface HostConnectionHandle {
|
||||
*/
|
||||
requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection
|
||||
|
||||
/**
|
||||
* Admit one request: it passes {@link requestRejection} and speaks for the
|
||||
* operator, or it is refused with that status.
|
||||
* @param request - request headers from the HTTP or upgrade request.
|
||||
* @returns the operator Peer, or the rejection status.
|
||||
*/
|
||||
admit(request: ConnectionTrustRequest): PeerAdmission
|
||||
|
||||
/**
|
||||
* Authenticate one frontend index request, owning a token redirect or 401.
|
||||
* @param request - root or configured-index HTTP request.
|
||||
@@ -240,6 +260,7 @@ export interface ClientConnectionRpc {
|
||||
* @param endpoint - channel-relative endpoint such as `session/follow`.
|
||||
* @param payload - channel-owned request payload.
|
||||
* @param signal - caller cancellation for this logical stream.
|
||||
* @param uplink - Client uplink items the Host method reads through `invocation.uplink()`.
|
||||
* @returns decoded stream values from the in-process carrier.
|
||||
*/
|
||||
readonly open?: (
|
||||
@@ -247,5 +268,6 @@ export interface ClientConnectionRpc {
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
uplink?: AsyncIterable<unknown>,
|
||||
) => AsyncIterable<unknown>
|
||||
}
|
||||
@@ -495,9 +495,10 @@ describe('connection client apply', () => {
|
||||
it('exposes a worker-local Gateway stream through connection.rpc.open', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'preview.example' }
|
||||
const openStream = vi.fn<NonNullable<ClientTransportHooks['openStream']>>(
|
||||
(endpoint, payload, signal) => (async function *(): AsyncGenerator {
|
||||
(endpoint, payload, signal, uplink) => (async function *(): AsyncGenerator {
|
||||
signal.throwIfAborted()
|
||||
yield { endpoint, payload }
|
||||
if (uplink !== undefined) yield* uplink
|
||||
})(),
|
||||
)
|
||||
;(globalThis as Win).__DSH_TRANSPORT__ = {
|
||||
@@ -521,7 +522,14 @@ describe('connection client apply', () => {
|
||||
'session/follow',
|
||||
{ args: { sessionId: 'session-1' } },
|
||||
abort.signal,
|
||||
undefined,
|
||||
)
|
||||
|
||||
const uplink = (async function *(): AsyncGenerator<string> { yield 'typed' })()
|
||||
const echoed = []
|
||||
for await (const value of open('/api', 'job/attach', { args: {} }, abort.signal, uplink)) echoed.push(value)
|
||||
expect(echoed).toEqual([{ endpoint: 'job/attach', payload: { args: {} } }, 'typed'])
|
||||
expect(openStream).toHaveBeenLastCalledWith('job/attach', { args: {} }, abort.signal, uplink)
|
||||
expect(handle.isLoopback).toBe(true)
|
||||
expect(() => open('/rpc', 'session/follow', {}, abort.signal))
|
||||
.toThrow('worker-local streams require the /api channel')
|
||||
|
||||
@@ -8,7 +8,16 @@ import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import type { IndexInjection, WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, RpcId, apply, inject, type ClientRequest, type ConnectionConfig, type HostConnectionHandle } from '../src/index.ts'
|
||||
import {
|
||||
API_PATH,
|
||||
RpcId,
|
||||
apply,
|
||||
inject,
|
||||
type ClientRequest,
|
||||
type ConnectionConfig,
|
||||
type HostConnectionHandle,
|
||||
type PeerScope,
|
||||
} from '../src/index.ts'
|
||||
import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts'
|
||||
import { provideBrowserCredentials } from './browser-credentials.ts'
|
||||
|
||||
@@ -469,6 +478,47 @@ describe('connection node half', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('admits every trusted, authenticated request as the operator Peer and hands each call that Peer', async () => {
|
||||
const { connection, routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
|
||||
const peers: PeerScope[] = []
|
||||
const remove = connection.rpc.intercept(
|
||||
'/api',
|
||||
() => true,
|
||||
async (_endpoint, _payload, _signal, peer) => {
|
||||
peers.push(peer)
|
||||
return { ok: true, value: null }
|
||||
},
|
||||
)
|
||||
expect(connection.admit(fakeRequest({ host: 'other.example' }))).toEqual({ rejection: 403 })
|
||||
expect(connection.admit(fakeRequest({ host: '127.0.0.1:3080' }))).toEqual({ rejection: 401 })
|
||||
const cookie = browserCookie(connection, '127.0.0.1:3080')
|
||||
expect(connection.admit(fakeRequest({ host: '127.0.0.1:3080', cookie }))).toEqual({ peer: connection.operator })
|
||||
|
||||
const route = routes.find(candidate => candidate.path === API_PATH)!
|
||||
const request: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId: RpcId('rpc-peer'),
|
||||
method: 'goals/create',
|
||||
payload: { args: {} },
|
||||
}
|
||||
const answered = fakeResponse()
|
||||
await route.handler(fakePost({ host: '127.0.0.1:3080', cookie }, '/api/goals/create', request), answered.response)
|
||||
expect(JSON.parse(String(answered.state.body))).toMatchObject({ result: { ok: true, value: null } })
|
||||
// A shell-owned carrier dispatches the shared handler without the bridge and speaks for the operator too.
|
||||
const direct = await connection.createSharedFetchHandler('/api').fetch(new Request('http://127.0.0.1:3080/api/goals/create', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
}))
|
||||
expect(direct.status).toBe(200)
|
||||
expect(peers).toEqual([connection.operator, connection.operator])
|
||||
|
||||
// Racing disposals share one completion, and the scope goes with the Connection.
|
||||
await Promise.all([connection.operator.dispose(), connection.operator.dispose()])
|
||||
await remove()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"src/http-bridge.ts",
|
||||
"src/index.ts",
|
||||
"src/loopback-hostname.ts",
|
||||
"src/operator-peer.ts",
|
||||
"src/recovery-config.ts",
|
||||
"src/rpc-host.ts",
|
||||
"src/rpc-schema.ts",
|
||||
@@ -28,14 +29,20 @@
|
||||
"path": "../../attachment/attachment"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-commands/README.md
|
||||
README.md: f8cba29179f9960042a4a9e67e86496591a76d08
|
||||
README.zh.md: 1cea3ae7aea857f2b2d219abf0a7192c2f222792
|
||||
README.md: 7fada747562ff15a96997d8bffe9aeb6f9a18fb5
|
||||
README.zh.md: 2f532cbd0f3d33e304074644cc6cb02b7151d3e1
|
||||
@@ -49,6 +49,8 @@ When the composer submits with images or generic files, only a host command decl
|
||||
|
||||
`src/client/contract.ts` defines contribution and decoration registration, plus `dismiss(name)`, which closes that command's open popups and confirmations: dismissal aborts pending option loads, prevents their late results from reopening the popup, and preserves composer drafts. `CommandDirectory` owns the per-session wire cache and resolves typed commands through `resolution.ts`; that module owns first-party identity matching and localized input spellings. `matchSpace` reads the ready cache synchronously, while `matchEnter` waits for readiness and rejects on warmup failure or cancellation. Forwarded catalog and connection events invalidate the cache. After a matched Host execution, this browser emits `command/executed`; other clients observe only the durable command events. `PopupSelectController` owns popup state, and `PopupSelectView` occupies the input overlay. `presentation.ts` owns row labels, icons, and sections; its helpers and the resolution helpers stay internal to the plugin.
|
||||
|
||||
Each command-directory fetch requires an existing retained Client Session and waits for its initial history open to succeed before sending `commands.list`. A temporary `commandCatalog` reference holds that Session until the fetch settles. An unretained Session or a failed open rejects without sending the RPC, so background catalog refreshes do not reopen closed Sessions.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
@@ -49,6 +49,8 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
|
||||
|
||||
`src/client/contract.ts` 定义贡献项和装饰的注册接口,以及 `dismiss(name)`:它关闭该命令已打开的弹窗与确认对话框,中止待完成的选项加载,阻止晚到结果重新打开弹窗,并保留 composer 草稿。`CommandDirectory` 负责会话级协议缓存,并通过 `resolution.ts` 解析输入命令;该模块负责内置命令标识匹配和本地化输入写法。`matchSpace` 同步读取就绪缓存,`matchEnter` 等待缓存就绪,预热失败或取消时拒绝。转发的目录和连接事件使缓存失效。宿主执行匹配的命令后,本浏览器发布 `command/executed`,其他客户端只观察持久命令事件。`PopupSelectController` 负责弹窗状态,`PopupSelectView` 占据输入浮层。`presentation.ts` 负责行标题、图标和分节,展示与解析辅助函数均留在插件内部。
|
||||
|
||||
每次命令目录拉取都要求客户端已持有该会话,并等待首次历史打开成功后才发送 `commands.list`。临时的 `commandCatalog` 引用持续持有会话,直到拉取结束。未被持有的会话或打开失败会直接拒绝,不发送 RPC,因此后台目录刷新不会重新打开已关闭的会话。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* `rankByName`). A host/contribution name collision fails loud. Every
|
||||
* execute addresses the session's agent by sessionId — sessions are always
|
||||
* agent-backed.
|
||||
* Catalog RPCs retain an existing Client Session through completion and
|
||||
* wait for its initial history open to succeed before contacting the Host.
|
||||
*/
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
@@ -34,6 +36,13 @@ import { builtinRowFace, sectionRows } from './presentation.ts'
|
||||
import { claimToken } from './resolution.ts'
|
||||
import type { TokenSegment } from './popup.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-api-session-controller/client' {
|
||||
interface SessionReferenceSourceMap {
|
||||
/** A command-catalog fetch waiting for initial history and its RPC result. */
|
||||
commandCatalog: unknown
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
@@ -86,11 +95,21 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
||||
if (locale === undefined) throw new Error('ui-commands: locale service unavailable')
|
||||
this.t = locale.bind('command')
|
||||
this.directory = new CommandDirectory(async (sessionId) => {
|
||||
if (this.sessions().subagentAddress(sessionId) !== undefined) return []
|
||||
const sessions = this.sessions()
|
||||
if (sessions.subagentAddress(sessionId) !== undefined) return []
|
||||
if (sessions.binding(sessionId) === undefined) {
|
||||
throw new Error(`command catalog requires a retained session "${sessionId}"`)
|
||||
}
|
||||
return sessions.using(sessionId, { source: 'commandCatalog' }, async (reference) => {
|
||||
const state = reference.binding.session.getSnapshot()
|
||||
if (state.openState !== 'open') {
|
||||
throw state.openError ?? new Error(`session "${sessionId}" is not open`)
|
||||
}
|
||||
const result = await ctx.remote.commands.list(sessionId)
|
||||
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
|
||||
return result.value
|
||||
})
|
||||
})
|
||||
const inputTriggers = ctx.get('inputTriggers')
|
||||
if (inputTriggers === undefined) throw new Error('ui-commands: slash service unavailable')
|
||||
ctx.effect(() => inputTriggers.registerSource({
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { TestSessions } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
@@ -28,21 +28,16 @@ async function bench() {
|
||||
return () => { sources.delete(`${src.trigger} ${src.name}`) }
|
||||
},
|
||||
})
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
const bindings = new Map<SessionId, {
|
||||
readonly sessionId: SessionId
|
||||
readonly session: { readonly sessionId: SessionId }
|
||||
readonly ctx: Context
|
||||
}>()
|
||||
ctx.provide('sessions', {
|
||||
scope: (id: SessionId) => scopes.get(id),
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
sessionOf: (c: Context) => bindings.get(scopeOf(c)!)?.session,
|
||||
binding: (id: SessionId) => bindings.get(id),
|
||||
subagentAddress: (id: SessionId) => id === sid('child')
|
||||
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
|
||||
: undefined,
|
||||
const sessions = new TestSessions(async (action) => { await action() }, ctx)
|
||||
onTestFinished(async () => {
|
||||
await sessions.disposeScopes()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
ctx.provide('sessions', sessions)
|
||||
await sessions.add({ id: 's1' })
|
||||
await sessions.add({ id: 'child' })
|
||||
sessions.retainFor(ctx, sid('s1'))
|
||||
sessions.retainFor(ctx, { parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable' })
|
||||
const commandsRemote = { list: () => Promise.resolve({ ok: true as const, value: [] }) }
|
||||
// The service subscribes its cache-invalidation events on construction, so
|
||||
// the Remote face needs `$on` even where this spec dispatches none.
|
||||
@@ -57,10 +52,8 @@ async function bench() {
|
||||
await fiber.await()
|
||||
const mint = (key: string) => {
|
||||
const id = sid(key)
|
||||
const handle = createScope(ctx, id)
|
||||
scopes.set(id, handle.ctx)
|
||||
bindings.set(id, { sessionId: id, session: { sessionId: id }, ctx: handle.ctx })
|
||||
return handle
|
||||
const binding = sessions.binding(id) ?? sessions.retainFor(ctx, id).binding
|
||||
return { ctx: binding.ctx, fiber: binding.ctx.fiber }
|
||||
}
|
||||
return { ctx, fiber, sources, slots: ctx.slots, mint }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* CommandUiRuntime tests on a real cordis Context with fake slash/connection
|
||||
* faces and real session scopes (createScope): session-keyed candidate
|
||||
* faces and retained TestSessions: session-keyed candidate
|
||||
* synthesis (host catalog by sessionId + contributions by availability,
|
||||
* collision fail-loud), the dispatch decision table cell by cell, matchSpace
|
||||
* hot-key policy, matchEnter strong-wait / reject, the sessionId execute
|
||||
@@ -11,9 +11,10 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
|
||||
import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { RemoteError, TestRemote, TestSessions } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionFixture } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { IconGoalOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts'
|
||||
@@ -44,6 +45,8 @@ interface BenchOptions {
|
||||
execute?: (payload: { sessionId: SessionId; line: string }) => Promise<ExecuteValue>
|
||||
translate?: (namespace: string, key: string, params?: Record<string, unknown>) => string
|
||||
addressed?: SessionId
|
||||
initialOpen?: SessionFixture['initialOpen']
|
||||
snapshot?: SessionFixture['snapshot']
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,18 +108,30 @@ async function bench(opts: BenchOptions = {}) {
|
||||
opts.translate?.(ns, key, params)
|
||||
?? `${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`,
|
||||
})
|
||||
// Real scope tags behind a fake sessions face.
|
||||
const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
|
||||
const bindings = new Map<SessionId, { sessionId: SessionId; session: { sessionId: SessionId }; ctx: Context }>()
|
||||
const removeSessions = ctx.provide('sessions', {
|
||||
scope: (id: SessionId) => scopes.get(id)?.ctx,
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
sessionOf: (scopeCtx: Context) => [...bindings.values()].find(binding => binding.ctx === scopeCtx)?.session,
|
||||
binding: (id: SessionId) => bindings.get(id),
|
||||
subagentAddress: (id: SessionId) => id === opts.addressed
|
||||
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
|
||||
: undefined,
|
||||
const sessions = new TestSessions(async (action) => { await action() }, ctx)
|
||||
onTestFinished(async () => {
|
||||
await sessions.disposeScopes()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
const removeSessions = ctx.provide('sessions', sessions)
|
||||
for (const id of new Set(['s1', 's2', 'other', 'ghost', ...opts.addressed === undefined ? [] : [opts.addressed]])) {
|
||||
await sessions.add({
|
||||
id,
|
||||
...opts.initialOpen === undefined ? {} : { initialOpen: opts.initialOpen },
|
||||
...opts.snapshot === undefined ? {} : { snapshot: opts.snapshot },
|
||||
})
|
||||
}
|
||||
const mint = (key: string) => {
|
||||
const id = sid(key)
|
||||
const target = id === opts.addressed
|
||||
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
|
||||
: id
|
||||
const binding = sessions.binding(id) ?? sessions.retainFor(ctx, target).binding
|
||||
return { ctx: binding.ctx, fiber: binding.ctx.fiber }
|
||||
}
|
||||
mint('s1')
|
||||
mint('s2')
|
||||
if (opts.addressed !== undefined) mint(opts.addressed)
|
||||
const remote = Object.assign(new TestRemote(ctx), { commands: commandsRemote })
|
||||
ctx.provide('remote.commands', commandsRemote)
|
||||
const executions: Array<{ sessionId: SessionId; name: string; result: CommandResult }> = []
|
||||
@@ -142,24 +157,13 @@ async function bench(opts: BenchOptions = {}) {
|
||||
const command = ctx.get('commandUi') as CommandUiRuntime
|
||||
const source = registered.get('/ command')
|
||||
if (source === undefined) throw new Error('command source not registered')
|
||||
const mint = (key: string) => {
|
||||
const id = sid(key)
|
||||
const handle = createScope(ctx, id)
|
||||
const binding = { sessionId: id, session: { sessionId: id }, ctx: handle.ctx }
|
||||
scopes.set(id, handle)
|
||||
bindings.set(id, binding)
|
||||
handle.ctx.effect(() => () => {
|
||||
if (bindings.get(id) === binding) bindings.delete(id)
|
||||
})
|
||||
return handle
|
||||
}
|
||||
/** Warm one session's catalog through the source's own candidate pull. */
|
||||
const warm = async (session: ClientSessionContext) => {
|
||||
await source.candidates(session, { query: '', position: 'leading', drilled: false, signal: new AbortController().signal })
|
||||
}
|
||||
return {
|
||||
ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, focuses, remote,
|
||||
removeSessions, removeConversation,
|
||||
removeSessions, removeConversation, sessions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,15 +219,51 @@ describe('registration', () => {
|
||||
it('the warm hook prewarms the session key: one pull per session, no duplicate over pending', async () => {
|
||||
const { source, listCalls } = await bench()
|
||||
source.warm!(proj('s1'))
|
||||
expect(listCalls).toEqual([{ sessionId: sid('s1') }])
|
||||
await vi.waitFor(() => { expect(listCalls).toEqual([{ sessionId: sid('s1') }]) })
|
||||
source.warm!(proj('s2'))
|
||||
expect(listCalls).toEqual([{ sessionId: sid('s1') }, { sessionId: sid('s2') }])
|
||||
await vi.waitFor(() => { expect(listCalls).toEqual([{ sessionId: sid('s1') }, { sessionId: sid('s2') }]) })
|
||||
source.warm!(proj('s1')) // s1 already pending → no duplicate pull
|
||||
expect(listCalls).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('candidates', () => {
|
||||
it('waits for initial history and retains the Session until the catalog RPC settles', async () => {
|
||||
const opened = Promise.withResolvers<undefined>()
|
||||
const response = Promise.withResolvers<{ commands: CommandDescriptor[] }>()
|
||||
const b = await bench({ initialOpen: () => opened.promise, commands: () => response.promise })
|
||||
try {
|
||||
const pending = b.source.candidates(proj('s1'), req(''))
|
||||
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().retainedBy.commandCatalog).toBe(1)
|
||||
expect(b.listCalls).toEqual([])
|
||||
opened.resolve(undefined)
|
||||
await vi.waitFor(() => { expect(b.listCalls).toHaveLength(1) })
|
||||
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(2)
|
||||
response.resolve({ commands: S1_CMDS })
|
||||
await pending
|
||||
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1)
|
||||
} finally {
|
||||
opened.resolve(undefined)
|
||||
response.resolve({ commands: S1_CMDS })
|
||||
}
|
||||
})
|
||||
|
||||
it.each([true, false])('refuses an unsuccessful history open (reported error: %s)', async (reported) => {
|
||||
const error = new RemoteError('gateway/internal', 'history unavailable', {})
|
||||
const b = await bench({ snapshot: { openState: 'error', openError: reported ? error : null } })
|
||||
await expect(b.source.candidates(proj('s1'), req(''))).rejects.toThrow(reported ? 'history unavailable' : 'is not open')
|
||||
expect(b.listCalls).toEqual([])
|
||||
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1)
|
||||
})
|
||||
|
||||
it('does not reopen a Session released before catalog lookup', async () => {
|
||||
const b = await bench()
|
||||
await b.mint('s1').fiber.dispose()
|
||||
await expect(b.source.candidates(proj('s1'), req(''))).rejects.toThrow('requires a retained session')
|
||||
expect(b.sessions.binding(sid('s1'))).toBeUndefined()
|
||||
expect(b.listCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('does not fetch Agent-bound commands for an addressed child', async () => {
|
||||
const b = await bench({ addressed: sid('child') })
|
||||
await expect(b.warm(proj('child'))).resolves.toBeUndefined()
|
||||
@@ -677,6 +717,7 @@ describe('matchEnter (enter column)', () => {
|
||||
commands: () => new Promise((resolve) => { release = resolve }),
|
||||
})
|
||||
const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { attachments: 0 })
|
||||
await vi.waitFor(() => { expect(release).toBeTypeOf('function') })
|
||||
release({ commands: S1_CMDS })
|
||||
const outcome = await wait
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
@@ -906,10 +947,12 @@ describe('detached admission notices', () => {
|
||||
})
|
||||
|
||||
it('a torn-down scope drops the failure notice', async () => {
|
||||
const { source, warm, notices } = await bench({
|
||||
const { source, warm, notices, mint } = await bench({
|
||||
execute: () => Promise.reject(new Error('orphan failure')),
|
||||
})
|
||||
await warm(proj('ghost')) // never minted: scopeFor misses
|
||||
const scope = mint('ghost')
|
||||
await warm(proj('ghost'))
|
||||
await scope.fiber.dispose()
|
||||
menuPick(source, 'plan', proj('ghost'))
|
||||
await flush()
|
||||
expect(notices).toEqual([])
|
||||
@@ -1122,6 +1165,7 @@ describe('directory invalidation events', () => {
|
||||
ctx.emit('connection/reset')
|
||||
// Hard reset: silent until the rewarm lands.
|
||||
expect(source.matchSpace!(proj('s2'), '/attach')).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(release).toBeTypeOf('function') })
|
||||
release({ commands: S2_CMDS })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md
|
||||
README.md: 7e6e0502db9720f788711fa3e98cab1581cc8738
|
||||
README.zh.md: 7e3fb604469ec73238f0843fdc95e6ea175c1d03
|
||||
README.md: ac23ac87c5b1bfe3eb849679c30a2ed283f33455
|
||||
README.zh.md: bc9971a5e8917bf636de34a742530b113e4e4453
|
||||
@@ -45,6 +45,8 @@ A rejected mutation surfaces the Remote error inline on the strip; loading, abse
|
||||
|
||||
The durable goal arrives through `useProjection('goal')` (seeded by the history tail page and updated by `session/projection` frames). The inject face carries a registrant-private activation hook source plus the four mutation verbs. That source starts only while the framework hook observes it, reads `ctx.remote.goals.get`, subscribes to `goal/activation-changed`, and refreshes on running-state or connection resets. Live-event epochs invalidate in-flight reads, so a stale HTTP result cannot overwrite a newer activation edge; running refreshes retain the last known activation until the read resolves. The strip owns no domain store or cross-plugin cache. Each mutation reads the CAS ref from the session's current projected value at call time, and the RPC's compare-and-set is the staleness guard. The strip single-flights mutations synchronously because a pending render cannot fence same-frame clicks. The command-input projection is a separate Conversation Definition that builds a `command-input` Chat Node before the generic command result Node; it never creates `user/message` or a model turn.
|
||||
|
||||
Activation reads hold a temporary `goalActivation` Client reference and send `goals.get` only after that Session's initial history open succeeds. A stale binding or failed open sends no RPC. Rejected reads are logged without changing the projected goal or last known activation; the temporary reference is released when the read settles.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
@@ -45,6 +45,8 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激
|
||||
|
||||
持久 goal 经 `useProjection('goal')` 到达(由历史尾页播种、`session/projection` 帧更新)。注入面携带注册方私有的激活钩子源与四个变更动词。该源仅在框架钩子观察它时启动;启动后会读取 `ctx.remote.goals.get`、订阅 `goal/activation-changed`,并在 running 状态或连接重置时刷新。实时事件 epoch 会让在途读取失效,因此较旧的 HTTP 响应不能覆盖较新的 activation 变化;running 刷新会保留最后一次已知 activation,直到读取完成。条带不持有领域存储或跨插件缓存。每个变更在调用时从会话当前投影值读取 CAS ref,比较并交换(RPC 的 CAS)就是陈旧性护栏。由于 React 的 pending 渲染无法拦住同一帧内的点击,条带会同步为变更建立 single-flight 防护。指令输入投影是独立的 Conversation Definition,在通用命令结果 Node 之前构建 `command-input` Chat Node;它绝不创建 `user/message` 或模型轮次。
|
||||
|
||||
激活状态读取持有临时的 `goalActivation` 客户端引用,仅在该会话首次历史打开成功后发送 `goals.get`。binding 已失效或打开失败时不发送 RPC。读取被拒绝时记录错误,不改变投影中的目标或最后一次已知激活状态;读取结束时释放临时引用。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface GoalActivationDeps {
|
||||
readonly projection: HostObservable<GoalProjection | null | undefined>
|
||||
/** Session snapshot; running flips trigger a fresh authoritative read. */
|
||||
readonly session: HostObservable<{ readonly running: boolean }>
|
||||
/** Read the current live goal at call time. */
|
||||
/** Read live activation after initial history; rejected reads leave the current snapshot unchanged. */
|
||||
readonly getGoal: () => Promise<RemoteResult<GoalView | undefined>>
|
||||
/** Subscribe to activation edges after the transport delivers them. */
|
||||
readonly subscribeActivation: (listener: (goal: GoalActivationChanged['goal']) => void) => () => void
|
||||
@@ -69,6 +69,8 @@ export function createGoalActivationSource(deps: GoalActivationDeps): HostObserv
|
||||
return
|
||||
}
|
||||
publish({ id: goal.id, revision: goal.revision, activation: goal.activation })
|
||||
}, (error: unknown) => {
|
||||
console.warn('[ui-goal] goal activation read failed:', error)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* `useProjection('goal')`. A registrant-private activation hook source owns
|
||||
* the live Remote read and event subscription; the inject face carries that
|
||||
* hook plus the four mutation verbs through the generated Goal Remote API.
|
||||
* Activation reads retain the Client Session through completion and wait
|
||||
* for its initial history open to succeed before contacting the Host.
|
||||
* This plugin does not create goals; deployments may expose /goal separately.
|
||||
*/
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
@@ -38,6 +40,13 @@ export type {
|
||||
} from './slots.ts'
|
||||
export type { GoalKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-api-session-controller/client' {
|
||||
interface SessionReferenceSourceMap {
|
||||
/** A live goal read waiting for initial history and its RPC result. */
|
||||
goalActivation: unknown
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The goal strip's copy. */
|
||||
@@ -91,7 +100,18 @@ export function apply(ctx: ClientContext): void {
|
||||
const goalActivation = createGoalActivationSource({
|
||||
projection: binding.session.projections.faceOf('goal') as HostObservable<GoalProjection | null | undefined>,
|
||||
session: binding.session,
|
||||
getGoal: () => ctx.remote.goals.get(sessionId),
|
||||
getGoal: async () => {
|
||||
if (sessions.binding(sessionId) !== binding) {
|
||||
throw new Error(`ui-goal: session "${sessionId}" is unavailable`)
|
||||
}
|
||||
return sessions.using(sessionId, { source: 'goalActivation' }, async (reference) => {
|
||||
const state = reference.binding.session.getSnapshot()
|
||||
if (state.openState !== 'open') {
|
||||
throw state.openError ?? new Error(`session "${sessionId}" is not open`)
|
||||
}
|
||||
return ctx.remote.goals.get(sessionId)
|
||||
})
|
||||
},
|
||||
subscribeActivation: listener => ctx.remote.$on('goal/activation-changed', (event) => {
|
||||
if (event.sessionId === sessionId) listener(event.goal)
|
||||
}),
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* plugin fiber (HMR safety), and the node half stays inert.
|
||||
*/
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
@@ -18,7 +18,8 @@ import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { GoalActivation, GoalId, GoalProjection, GoalView } from '@deepseek-ai/dsh-goal/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { makeTranslate, RemoteError, TestSessions } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionFixture } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { GoalActivationSnapshot, GoalBarActions, GoalBarInjected } from '../src/client/slots.ts'
|
||||
@@ -52,25 +53,25 @@ async function bench(options: {
|
||||
projection?: GoalProjection | null | undefined
|
||||
activation?: GoalActivation
|
||||
failWith?: RemoteFailure
|
||||
initialOpen?: SessionFixture['initialOpen']
|
||||
snapshot?: SessionFixture['snapshot']
|
||||
} = {}) {
|
||||
const ctx = new Context()
|
||||
const calls: { method: string; args: unknown[] }[] = []
|
||||
const sessions = {
|
||||
binding: (id: SessionId) => id === sid('missing') ? undefined : ({
|
||||
sessionId: id,
|
||||
session: {
|
||||
getSnapshot: () => ({ running: false }),
|
||||
subscribe: () => () => {},
|
||||
projections: { faceOf: (key: string) => ({
|
||||
getSnapshot: () => (key === 'goal' ? options.projection : undefined),
|
||||
subscribe: () => () => {},
|
||||
}) },
|
||||
},
|
||||
ctx,
|
||||
}),
|
||||
}
|
||||
const sessions = new TestSessions(async (action) => { await action() }, ctx)
|
||||
onTestFinished(async () => {
|
||||
await sessions.disposeScopes()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
await sessions.add({
|
||||
id: 's1',
|
||||
...options.initialOpen === undefined ? {} : { initialOpen: options.initialOpen },
|
||||
...options.snapshot === undefined ? {} : { snapshot: options.snapshot },
|
||||
})
|
||||
await sessions.setProjection('s1', 'goal', options.projection)
|
||||
const owner = sessions.retainFor(ctx, sid('s1'))
|
||||
ctx.provide('sessions', sessions)
|
||||
const conversationEvents = new UiConversation(ctx, sessions as never).events
|
||||
const conversationEvents = new UiConversation(ctx, sessions).events
|
||||
function answer<T>(method: string, value: T) {
|
||||
return (...args: unknown[]) => {
|
||||
calls.push({ method, args })
|
||||
@@ -145,6 +146,8 @@ async function bench(options: {
|
||||
ctx,
|
||||
fiber,
|
||||
calls,
|
||||
sessions,
|
||||
owner,
|
||||
emitActivation: remote.emitActivation.bind(remote),
|
||||
definitions: () => conversationEvents.entries(),
|
||||
remountGoals: () => { activeGoals = goals('remounted-goals') },
|
||||
@@ -163,6 +166,65 @@ async function bench(options: {
|
||||
}
|
||||
|
||||
describe('ui-goal browser plugin', () => {
|
||||
it('waits for initial history before reading activation and releases the read reference', async () => {
|
||||
const opened = Promise.withResolvers<undefined>()
|
||||
const b = await bench({ projection: makeProjection(), activation: 'disarmed', initialOpen: () => opened.promise })
|
||||
await b.fiber.await()
|
||||
const source = b.entry()!.inject!(sid('s1')).hooks.goalActivation
|
||||
const dispose = source.subscribe(() => {})
|
||||
try {
|
||||
expect(b.calls).toEqual([])
|
||||
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().retainedBy.goalActivation).toBe(1)
|
||||
opened.resolve(undefined)
|
||||
await waitFor(() => { expect(source.getSnapshot().activation).toBe('disarmed') })
|
||||
expect(b.calls).toEqual([{ method: 'goals/get', args: [sid('s1')] }])
|
||||
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1)
|
||||
} finally {
|
||||
opened.resolve(undefined)
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([true, false])('keeps projected goal state after an unsuccessful history open (reported error: %s)', async (reported) => {
|
||||
const error = new RemoteError('gateway/internal', 'history unavailable', {})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
onTestFinished(() => { warn.mockRestore() })
|
||||
const b = await bench({ projection: makeProjection(), snapshot: { openState: 'error', openError: reported ? error : null } })
|
||||
await b.fiber.await()
|
||||
const source = b.entry()!.inject!(sid('s1')).hooks.goalActivation
|
||||
const dispose = source.subscribe(() => {})
|
||||
try {
|
||||
await waitFor(() => { expect(warn).toHaveBeenCalledOnce() })
|
||||
expect(warn).toHaveBeenCalledWith('[ui-goal] goal activation read failed:', expect.objectContaining({
|
||||
message: reported ? 'history unavailable' : 'session "s1" is not open',
|
||||
}))
|
||||
expect(source.getSnapshot()).toEqual({ id: GOAL_ID, revision: 3 })
|
||||
expect(b.calls).toEqual([])
|
||||
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses activation reads captured from a replaced Session binding', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
onTestFinished(() => { warn.mockRestore() })
|
||||
const b = await bench({ projection: makeProjection() })
|
||||
await b.fiber.await()
|
||||
const source = b.entry()!.inject!(sid('s1')).hooks.goalActivation
|
||||
b.owner.release()
|
||||
b.sessions.retainFor(b.ctx, sid('s1'))
|
||||
const dispose = source.subscribe(() => {})
|
||||
try {
|
||||
await waitFor(() => { expect(warn).toHaveBeenCalledWith('[ui-goal] goal activation read failed:', expect.objectContaining({
|
||||
message: 'ui-goal: session "s1" is unavailable',
|
||||
})) })
|
||||
expect(b.calls).toEqual([])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('registers the GoalBar dock, command input Definition, and keyed Chat renderer', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: 7ee357f9f405db0c14662b69e5edac1cd84d4424
|
||||
README.zh.md: a36172a3857d007c2695f44ee4b6729e1026355c
|
||||
README.md: 205f942f6199a718eb360f6dec6f5bb9425cc4e5
|
||||
README.zh.md: 577453d1c583f4981c26ca5dc702eded004a67b4
|
||||
@@ -44,7 +44,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
|
||||
| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. |
|
||||
| `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. |
|
||||
| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader. `aria-hidden`, so the render site owns the name. |
|
||||
| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. |
|
||||
| `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. |
|
||||
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. |
|
||||
| `Modal` | Centered dialog over a page mask. |
|
||||
|
||||
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user