45 KiB
Agent Note: Remote 双工流:一种流、上行通道与调用上下文
Status: implemented
English | 中文
问题
现状
dsh-api-gateway 在 /api/remote.mux 的一条 WebSocket 上复用 Typert Remote 流。Host 方法通过 @Remote({ mode: 'stream' }) 和 RemoteStream<Out, In> 声明两个方向;生成的 Client 方法返回 RemoteStreamHandle<Out, In>。既有的 Iterable 和 AsyncIterable 返回类型声明仅下行的方法。可选的末位 signal: AbortSignal 由 Gateway 提供,不进入线路参数。
两个方向共用一个 streamId:
| 方向 | 帧 | 定义 |
|---|---|---|
| Client 到 Host | { type: 'open', streamId, endpoint, payload } |
stream-protocol.ts |
| Client 到 Host | { type: 'item', streamId, value? } |
stream-protocol.ts |
| Client 到 Host | { type: 'end', streamId } |
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 |
Client 句柄的 send() 提供上行项,end() 半关闭上行,dispose() 取消逻辑流。Host 通过 this.ctx.invocation.uplink<In>() 读取解码后的项;调用上下文同时携带获准接入的 Peer 和取消信号。
传输层负责校验、取消和有界 inbox。消费者协议负责输入权限、确认以及断线后的恢复。
上行消费者
| 功能 | 上行方式 | 位置 | 代价 |
|---|---|---|---|
| Web 终端输入与尺寸调整 | terminal.attach 承载上行 TerminalInput 和下行 TerminalFrame;Client 连续发送命令,并在收到 ack 后释放原始输入字节预算 |
packages/api/terminal-controller/src/client/model.ts;Host src/terminal.ts |
当前附件拥有输入权;Client 预算耗尽时报 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 记录了由此产生的竞态 |
这些消费者都需要在相关操作仍进行时从 Client 向 Host 传送数据。Web 终端使用双工流;文件上传和事件回答保留各自的传输方式。
消费者职责
- 输入权限。终端通过 Host 本地的 follower 持有控制附件。快照和状态帧报告各附件的
writable状态;控制权转移后,旧附件继续接收输出。 - 顺序与确认。终端输入和尺寸调整共用连续命令序列及终端操作队列。
ack报告提供者已接受操作或输入权限拒绝,绝不授权断线后重放。 - 独立生命周期。取消
attach会停止其输入和输出传递、丢弃尚未开始的命令,并等待在途输入操作收尾。PTY 的生命周期由保留机制和显式关闭独立管理。 - 协调字节限额。Gateway 通过
invocation.uplinkWindow提供完整帧 inbox 容量及本流的 JSON 封装开销。终端在快照中公布窗口,并通过提供者确认释放在途额度。窗口满时,输入和尺寸调整命令在有界 Client 队列中等待。maxInputBytes限制原始 UTF-8 输入;maxBufferedBytes分别限制 Client 保留的完整编码输入及每个 follower 保留的输出。 - 仍独立的载体。审批回答仍通过
clientId和eventId将$events与$events/resultRPC 关联。将该协议迁入双工流仍属后续工作。
与后台 job 的关系
后台 job 的进程形双工流面(已存档为草稿 PR,分支 worktree/job-stdio-stream-pair-archive)把 job 建成一对镜像的流面,浏览器要接它就需要一条既下行又上行的 Remote 流。它是本 Note 的动机之一,但不是本 Note 的范围:job 要不要一种专用的读写流,是 jobs 那条线自己的架构问题。本 Note 只覆盖传输层。
目标
- 传输层只有一种流,天生双工:下行
item/end/error,上行item/end,一个streamId,一个代际。 - 上行类型与下行类型写在同一个位置:方法的返回类型。
- Host 方法通过调用上下文取上行;调用上下文同时携带调用者(Peer)。
- 客户端拿到的不再是裸
AsyncIterable,而是一个能读、能写、能关的句柄。 - 上层协议(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, window)
│ │ 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 | 一条连接代表的一方,一个携带 principal 的 PeerScope;所有浏览器连接共享 connection.browserPeer,进程内代码经 connection.createPeer() 自建(Peer 身份属性) |
| 调用上下文 | RemoteInvocation:本次调用的请求、接收服务、Peer、取消信号与上行入口 |
类型签名
// @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 面
/** 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; every carrier names it explicitly. */
readonly peer: PeerScope
/** Carrier cancellation: Client cancel, socket closure, uplink failure. */
readonly signal: AbortSignal
readonly uplinkWindow?: RemoteUplinkWindow | undefined
/**
* 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
}
}
规则:
- 任何
mode: 'stream'方法都可以调用this.ctx.invocation.uplink();unary 方法也可以,但它的流在方法返回时结束,上行项只在方法运行期间可读。 uplink()未被调用时,上行项在 inbox 里攒着,直到超限(见背压)或流结束被丢弃。这是刻意的:不读上行的方法不用知道上行存在。In缺省为never时描述符没有 uplink codec;客户端句柄的send类型为never,编译期就不能发。运行时若仍有帧到达(手工构造的客户端),按unknown交付。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
/** The opaque identity of one Peer. */
export type PeerId = Branded<'PeerId'>
/** One Peer's session on this Host: opened and released by its creator; ctx owns connection-lifetime registrations. */
export interface PeerScope {
readonly id: PeerId
readonly ctx: Context
/** Caller identity fixed at creation. */
readonly principal: Principal
/** Aborted once the Peer is disposed. */
readonly signal: AbortSignal
/** Participation in forwarded interactions; independent of RBAC. */
readonly interactions: 'answer' | 'none'
dispose(): Promise<void>
}
@deepseek-ai/dsh-client-connection 实现唯一的 Peer 类 Peer(owner, { principal, interactions? }),用 createScope(owner, peer) 建立,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,peer.ctx 承接连接期效果,scope 结束时 peer.signal 中止,dispose() 让 fiber 静默。Connection 在服务 apply 时以 owner principal 创建 connection.browserPeer;principal、createPeer() 与访问检查归 Peer 身份属性与 Remote 方法级访问控制。
| 成员 | 语义 |
|---|---|
connection.browserPeer: PeerScope |
所有通过认证的浏览器连接共享的 Peer;principal 为 owner;随 connection 生灭 |
connection.admit(request): PeerAdmission |
检查 Host/Origin 与浏览器或 API 签名 Cookie;返回 { rejection: 401 | 403 } 或凭据对应的存活 Peer;API Peer 在 connect 时发布,并由其生命周期 socket 负责释放 |
网关 handleUpgrade(req, socket, head, peer) 为该 socket 绑一个固定 Peer 的 opener,并在 peer.ctx.effect 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。InvokeRemoteRequest.peer、RemoteStreamOpener 与 wireStream.open 的 peer 形参均为 required,每个进程内调用方都显式给出 Peer,网关不自建 Peer。按 id 查 Peer、Peer 的开关事件,都等第一个需要它们的消费者出现时再加。
Peer 携带自己的 principal;该 principal 能调用什么,由 Peer 身份属性与 Remote 方法级访问控制 定义的 role、binding 与 policy 判定。
Client 面
/** 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透出到这些上层协议是后续的事。
线路帧
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 上的其他流 |
取消等待的生命周期
cancellableStream、UplinkDecoder.next() 和 Client 上行泵各自为单次读取创建取消或停止 Promise。读取完成后,Promise.race 不会移除另一输入上的 reaction:复用流级未完成 Promise 会保留已完成的竞争及其读取结果,直到该 Promise 完成或不可达。单次读取的 Promise 使取消状态的保留量不随已交付项数增长。
Host 下行在每次读取后清除当前拒绝回调,并在再次读取前检查取消状态。上行解码器只保留尚未完成的读取:取消时拒绝这些读取,正常关闭则以 done 唤醒全部读取,包括重叠的读取。Client 泵在每次读取后移除停止 Promise,停止后不再发起读取。源迭代器的释放仍由原有所有者负责;Host 仍先关闭上行,再返回方法迭代器。
顺序保证
同一条 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;worker 隧道的页内 Host 传 connection.browserPeer。
与 $events 的关系
$events 是一条保留端点的流;有了上行帧,waterfall 的回答可以成为同一 streamId 上的上行项,clientId 因 streamId 已标识代际而多余,$events/result 与 index.ts:527-529 的竞态一起消失。不在本 Note 范围,见「后续」。
类型与线路定义
typert protocol(packages/typert/protocol/src)
// 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; readonly principal: Principal; readonly signal: AbortSignal; 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.tsremoteResultType:对mode: 'stream',接受的返回类型包装器为Iterable<Out>、AsyncIterable<Out>、RemoteStream<Out, In?>。识别RemoteStream的方式与识别标准库AsyncIterable相同:符号名加声明所在文件(@deepseek-ai/dsh-typert-protocol的types.ts)。第一个类型参数是下行项,第二个存在且不是never时生成uplinkboundary,键名${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)
// 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; required on every path. */
readonly peer: PeerScope
readonly signal?: AbortSignal
}
export interface TypertGatewayWireStream {
open: (endpoint: string, payload: unknown, uplink: AsyncIterable<unknown>, peer: PeerScope, 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,
window: RemoteUplinkWindow,
) => 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:
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,
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)
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(peer.ts、rpc.ts、rpc-host.ts、index.ts) |
Peer 类;connection.browserPeer 与 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) |
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 载体与进程内载体:
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']
考虑过的替代方案
三处读取循环共用一个取消控制器。 每处循环已有自己的所有者和不同的关闭行为。单次读取的 Promise 能限制保留状态,不需要把这些职责迁入新抽象。
要求源迭代器自行取消 next()。 Gateway 接受任意 iterable,其待处理读取不一定响应取消或 return()。Gateway 仍需独立于源清理来唤醒自己的等待者。
独立的 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 就够。方法级访问控制后来归入 Peer 身份属性与 Remote 方法级访问控制,该部分由其 supersede。
继续用 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是正确行为。- Web 终端使用
attach传输屏幕输出和输入命令,并提供提供者确认和附件专属可写状态;Client 保留当前代次的句柄,绝不重放输入。 - inbox 上限对所有流生效:一个不读上行的方法收到大量上行帧会整流失败。这是刻意的显式失败,README 记明。
- 所有浏览器连接共享
browserPeer,admit是唯一的网络接纳点;进程内代码经connection.createPeer()创建其他 Peer。 - 上行与下行之间没有跨方向顺序保证;需要请求应答配对的协议自带序号。
- 上层监督器仅暴露下行:
$stream、snapshot 和 journal 不暴露send;需要写入上行的领域保留该代次的生成物理句柄。 - worker 隧道与 WebSocket 载体行为不同:没有 inbox 上限,
end之后的项丢弃,因为页面到 worker 是同源可信边界。
测试
gateway-stream.host.spec.ts 和 gateway.client.spec.ts 中的保留对象用例在流保持打开时进行全量 GC,再统计存活的迭代结果。生命周期用例覆盖正常关闭唤醒多个待处理上行读取、两次读取之间的取消、源同步取消后抛错,以及 Client 发送期间流结束。
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 与browserPeer三种结果;browserPeer经桥与共享处理器到达;随 connection 释放。 - remote-mock、worker 隧道:上行透传;直接代理返回真句柄。
- 既有 stream 与 unary 方法的测试不改期望值即通过。
后续
$stream/ snapshot / journal 把句柄的send透出给上层协议。- 双工
$events:waterfall 的回答成为同一streamId上的上行项,$events/result与clientId退场。 - credit 帧:等需要持续大流量上行的消费者出现;inbox 上限就是初始授信。
- Host 发起的半关闭帧(Host 不再读上行但继续写下行):没有消费者,不加。
- unary 方法的
uplink():能读到方法运行期间到达的项,语义成立但用处存疑;允许,README 注明。
相关
- Remote 事件投递:
$events/result的 unary 回答路径;「后续」里的双工$events落地后部分取代它。 - 会话历史与事件传输:
$stream()监督器与其上的 journal / snapshot 协议,本 Note 不改它们。 - Web 侧边栏终端:
attach流承载输入命令、提供者确认和附件专属可写状态;重连绝不重放输入。