diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml
index 00652d3b09..2cd0ed343b 100644
--- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml
@@ -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-18-session-history-and-event-transport.md
-2026-08-18-session-history-and-event-transport.md: 808565ff7df60b8aa6aa3f18820c1139b8bf5362
-2026-08-18-session-history-and-event-transport.zh.md: 8bd00def4531afa9cdf77ae7f689f2e77908545e
+2026-08-18-session-history-and-event-transport.md: 5f4aba19d147eae3f49fcefc9a8006d0f557dc6c
+2026-08-18-session-history-and-event-transport.zh.md: dcf7e7ffc5ad325756b1fb37dd5ad60d2f0b3147
diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
index 808565ff7d..5f4aba19d1 100644
--- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
+++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
@@ -62,6 +62,8 @@ API Proxy owns neither the Session or Workspace Remote namespace nor the Host do
The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently on activation and connects to `/api/remote.mux` immediately. The physical WebSocket remains resident even when there is no business logical stream.
+The Host sends one RFC 6455 Ping control frame to every open mux socket at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default). The browser replies with Pong at the protocol layer; neither control frame enters the Remote stream JSON union or changes Connection generation state. The Host imposes no Pong deadline, so half-open detection remains with TCP and network intermediaries.
+
After an initial connection failure or the loss of a connected socket, the mux rebuilds the physical connection with capped jittered backoff. Logical streams not yet opened share that reconnect loop; streams already open end their current physical generation with `RemoteStreamCarrierError`.
In-process `connection.rpc.open` uses the same logical endpoint semantics while bypassing the browser WebSocket mux.
@@ -76,7 +78,7 @@ Unexpected normal completion of `$events`, a Host error, a malformed opening fra
Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state.
-Plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers.
+Host plugin disposal stops the heartbeat timer, terminates mux sockets, and waits for active iterators. Client plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers.
### General Remote stream model
@@ -320,13 +322,15 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re
**Use an independent physical WebSocket or duplex stream for Remote Event.** Gateway mux already provides authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect. Downlink `$events` plus HTTP `$events/result` expresses request/response without a third connection.
+**Send application-level JSON heartbeat frames.** This would expand the strict Remote stream message union and require browser handling for traffic with no business meaning. WebSocket Ping/Pong provides carrier activity without changing logical-stream semantics.
+
**Retain API Proxy's Host mux.** This keeps the handwritten union, schema, response envelope, and second stream lifecycle, and prevents Session and Workspace Controllers from owning their data protocols independently.
**Update Session list time from aggregate `session/event`.** List correctness would depend on which Sessions a browser consumes and would mistake arbitrary plugin events for user activity. The durable `lastPromptAt` projection expresses the ordering fact directly.
## Verification
-Gateway mux tests pin connection without logical streams, idle residency, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal.
+Gateway mux tests pin connection without logical streams, idle residency, configurable Ping/Pong without application messages, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal.
Connection tests pin missing, duplicate, and withdrawn generation sources; the race between `$events` ready and `host.describe`; and description withdrawal and rebuilding after generation failure.
@@ -364,6 +368,8 @@ Durable logs repair a missing suffix by sequence number and page; Session contro
Gateway owns only transport, generation, pending waterfalls, and strict wire validation, not Session or Workspace business fields. A domain Controller supplies only openers, cursor rules, baseline reducers, and error presentation.
+Each resident browser connection adds one empty Ping/Pong exchange per configured interval. Deployments can shorten the interval for stricter idle timeouts without changing the Remote stream protocol or browser code.
+
Session and Workspace Host APIs, stream adapters, and Client data models each have an explicit owner. API Proxy is no longer their intermediary.
The general stream objects add three explicit layers while deleting the retry, cancellation, generation, baseline, and gap-repair shells previously duplicated by each Controller.
diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md
index 8bd00def45..dcf7e7ffc5 100644
--- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md
@@ -62,6 +62,8 @@ API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host
浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻。
+Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。Host 不设置 Pong deadline,因此半开检测仍由 TCP 与网络中间层承担。
+
首次建连失败或已连接 socket 丢失后,mux 使用有上限的抖动退避重建物理连接。尚未打开的 logical stream 共享该重连循环;已经打开的 stream 以 `RemoteStreamCarrierError` 结束当前物理 generation。
进程内 `connection.rpc.open` 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。
@@ -76,7 +78,7 @@ Host event source 在返回首帧前同步安装增量 listener。Gateway 随后
Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。
-插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 静默退出。
+Host 插件销毁会停止心跳定时器、终止 mux socket,并等待活跃 iterator 完成。Client 插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 完全停稳。
### 通用 Remote stream 模型
@@ -320,13 +322,15 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace
**给 Remote Event 使用独立物理 WebSocket 或 duplex stream。** Gateway mux 已提供认证升级、复用、取消、错误映射和重连;下行 `$events` 加上 HTTP `$events/result` 足以表达 request/response,不需要第三条连接。
+**发送应用层 JSON 心跳帧。** 这会扩展严格的 Remote stream message union,并要求浏览器处理没有业务含义的流量。WebSocket Ping/Pong 无需改变 logical stream 语义即可保持 carrier 活跃。
+
**继续保留 API Proxy 的 Host mux。** 这会保留手写 union、schema、响应 envelope 和第二套 stream 生命周期,并使 Session 与 Workspace Controller 不能独立拥有自己的数据协议。
**从聚合 `session/event` 更新 Session 列表时间。** 列表正确性会依赖浏览器正在消费哪些 Session,并把任意插件事件误判为用户活跃;持久 `lastPromptAt` 投影直接表达排序事实。
## 验证
-Gateway mux 测试固定无 logical stream 时建连、空闲常驻、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。
+Gateway mux 测试固定无 logical stream 时建连、空闲常驻、可配置且不产生应用消息的 Ping/Pong、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。
Connection 测试固定 generation source 缺失、重复注册、撤回、`$events` ready 与 `host.describe` 的竞争,以及 generation 失败后的 description 撤回和重建。
@@ -364,6 +368,8 @@ Remote Event Client 测试固定实例私有 key、Cordis 注册顺序、Agent C
Gateway 只拥有 transport、generation、pending waterfall 和严格 wire 校验,不拥有 Session 或 Workspace 业务字段。领域 Controller 只提供 opener、cursor 规则、baseline reducer 和错误呈现。
+每条常驻浏览器连接会按配置间隔增加一次空载荷 Ping/Pong 交换。面对更严格的空闲超时,部署方可缩短间隔,而无需改变 Remote stream 协议或浏览器代码。
+
Session 与 Workspace 的 Host API、stream adapter 和 Client 数据模型各有明确 owner;API Proxy 不再是它们之间的中介。
通用 stream 对象增加了三个明确层级,但删除了每个 Controller 各自复制的 retry、cancel、generation、baseline 和 gap-repair 外壳。
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 2407817a57..750dab17ed 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -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: 57b7f120ff09459e02f998806f6335f2b45d1b1b
-config-catalog.zh.md: f0911b9d004d885868758351e16e3783b660eb18
+config-catalog.md: ab16221ff6c13768c9b0fb6a8189e30565dcc289
+config-catalog.zh.md: 8c9d956ad3ad5f672f73e5b4dd02aaed938c8667
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 57b7f120ff..ab16221ff6 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -274,6 +274,22 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md)
Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts)
+
+
+## `@deepseek-ai/dsh-api-gateway`
+
+Requires: `typert`
+
+```ts config-catalog
+/** Gateway transport configuration. */
+export interface Config {
+ /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */
+ readonly websocketHeartbeatIntervalMs?: number
+}
+```
+
+Source: [`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts)
+
## `@deepseek-ai/dsh-api-session-controller`
@@ -3314,7 +3330,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts))
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
-- `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts))
- `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
- `@deepseek-ai/dsh-api-settings-controller` ([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts))
- `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts))
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index f0911b9d00..8c9d956ad3 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -276,6 +276,22 @@ export interface Config {
来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts)
+
+
+## `@deepseek-ai/dsh-api-gateway`
+
+需要:`typert`
+
+```ts config-catalog
+/** Gateway transport configuration. */
+export interface Config {
+ /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */
+ readonly websocketHeartbeatIntervalMs?: number
+}
+```
+
+来源:[`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts)
+
## `@deepseek-ai/dsh-api-session-controller`
@@ -3316,7 +3332,6 @@ export interface Config {
- `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts))
- `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
-- `@deepseek-ai/dsh-api-gateway` — 需要 `typert`([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts))
- `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
- `@deepseek-ai/dsh-api-settings-controller`([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts))
- `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts))
diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml
index 9bc399b963..8353039833 100644
--- a/packages/api/gateway/README.i18n.yaml
+++ b/packages/api/gateway/README.i18n.yaml
@@ -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: 2e0cb32e4db6c1bee8576a5addd5492fb7ef53ac
-README.zh.md: f67e13f6b1f789da02796397a121e59a55427cca
+README.md: 504ff95494d8c374426c18d0a77fb238457561e3
+README.zh.md: e556b4981ce5789e6fe9f74bb7d4dc9c5217ae4c
diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md
index 2e0cb32e4d..504ff95494 100644
--- a/packages/api/gateway/README.md
+++ b/packages/api/gateway/README.md
@@ -32,7 +32,7 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api
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 validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it.
+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 validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it.
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, and per-Client queues. Its source factory attaches incremental listeners synchronously; Gateway then yields `{ type: 'ready' }` before iterating the source, so the Client starts baseline reads only after incremental delivery is ready.
@@ -68,6 +68,7 @@ No direct effect; invoked business Services own any model-visible result.
- `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay.
- 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.
+- WebSocket heartbeats keep idle intermediaries active but do not require a timely Pong or terminate an unresponsive peer. Half-open carriers remain subject to TCP or intermediary failure detection before the Client reconnects.
diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md
index f67e13f6b1..e556b4981c 100644
--- a/packages/api/gateway/README.zh.md
+++ b/packages/api/gateway/README.zh.md
@@ -32,7 +32,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
-流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。
+流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote stream frame。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。
Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验和每 Client 队列由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener;Gateway 随后先产出 `{ type: 'ready' }`,再迭代 source,让 Client 只在增量投递就绪后开始 baseline 读取。
@@ -68,6 +68,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source
- `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events`;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。
- lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。
- 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。
+- WebSocket 心跳用于保持空闲中间层活跃,但不会要求及时收到 Pong,也不会主动终止无响应对端。半开 carrier 仍需等待 TCP 或中间层检测失败后,Client 才会重连。
diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json
index e74d19946f..13c7e402ae 100644
--- a/packages/api/gateway/package.json
+++ b/packages/api/gateway/package.json
@@ -56,7 +56,9 @@
],
"license": "MIT",
"dependencies": {
+ "@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
+ "@deepseek-ai/schemastery": "workspace:^",
"ws": "^8.21.0"
},
"peerDependencies": {
diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts
index 86a71d834a..ec4eb1eaef 100644
--- a/packages/api/gateway/src/index.ts
+++ b/packages/api/gateway/src/index.ts
@@ -9,6 +9,8 @@ import { randomUUID } from 'node:crypto'
import { Context, Service, symbols } from '@deepseek-ai/cordis'
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
+import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
+import z from '@deepseek-ai/schemastery'
import {
remoteMethods,
TypertLookupFailure,
@@ -106,6 +108,17 @@ interface PendingRemoteEvent {
type ConnectionRpcResult = Awaited>
type ConnectionRpcError = Extract['error']
const NEVER_ABORTED_SIGNAL = new AbortController().signal
+const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 30_000
+
+/** Gateway transport configuration. */
+export interface Config {
+ /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */
+ readonly websocketHeartbeatIntervalMs?: number
+}
+
+interface ResolvedConfig extends Config {
+ readonly websocketHeartbeatIntervalMs: number
+}
/** Dispatch failure produced outside the invoked business method. */
export class TypertGatewayError extends Error {
@@ -156,6 +169,10 @@ class RemoteInvocationCancelled extends Error {
*/
export class TypertGatewayService extends Service implements TypertGateway {
static inject = ['typert']
+ static Config: z = z.object({
+ websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS)
+ .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS),
+ })
/** Carrier adapter shared by the WebSocket mux and local Host transports. */
readonly wireStream: TypertGatewayWireStream = {
@@ -171,9 +188,11 @@ export class TypertGatewayService extends Service implements TypertGateway {
/**
* Register the Gateway against the active Typert registry.
* @param ctx - owning Host Context with Typert registry access.
+ * @param config - validated Gateway transport configuration.
*/
- constructor(ctx: Context) {
+ constructor(ctx: Context, config: Config) {
super(ctx, 'typertGateway')
+ const resolved = config as ResolvedConfig
ctx.on('internal/service', () => {
this.srcClaims = undefined
})
@@ -188,6 +207,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
const mux = new RemoteStreamMuxServer(
(endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal),
this.wireStream.failure,
+ resolved.websocketHeartbeatIntervalMs,
)
webCtx.effect(() => {
const route: WebUpgradeRoute = {
diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts
index 04b0df0427..28a3589542 100644
--- a/packages/api/gateway/src/stream-server.ts
+++ b/packages/api/gateway/src/stream-server.ts
@@ -23,14 +23,17 @@ export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure
export class RemoteStreamMuxServer {
private readonly server = new WebSocketServer({ noServer: true })
private readonly connections = new Set>()
+ private heartbeatTimer: NodeJS.Timeout | undefined
/**
* @param open - Gateway stream dispatcher.
* @param failure - Gateway error-to-wire mapper.
+ * @param heartbeatIntervalMs - interval between WebSocket Ping control frames.
*/
constructor(
private readonly open: RemoteStreamOpener,
private readonly failure: RemoteStreamFailureMapper,
+ private readonly heartbeatIntervalMs: number,
) {}
/**
@@ -41,6 +44,7 @@ export class RemoteStreamMuxServer {
*/
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
this.server.handleUpgrade(req, socket, head, (websocket) => {
+ this.startHeartbeat()
const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure)
const done = connection.run()
this.connections.add(done)
@@ -50,6 +54,8 @@ export class RemoteStreamMuxServer {
/** Terminate all sockets and wait until every iterator has returned. */
async close(): Promise {
+ clearInterval(this.heartbeatTimer)
+ this.heartbeatTimer = undefined
for (const socket of this.server.clients) socket.terminate()
const closed = Promise.withResolvers()
this.server.close((error) => {
@@ -59,6 +65,17 @@ export class RemoteStreamMuxServer {
await closed.promise
await Promise.all(this.connections)
}
+
+ /** Start one `unref()` timer after the first upgrade; it spans empty-client periods until close(). */
+ private startHeartbeat(): void {
+ if (this.heartbeatTimer !== undefined) return
+ this.heartbeatTimer = setInterval(() => {
+ for (const socket of this.server.clients) {
+ if (socket.readyState === WebSocket.OPEN) socket.ping()
+ }
+ }, this.heartbeatIntervalMs)
+ this.heartbeatTimer.unref()
+ }
}
interface ActiveStream {
diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts
index d7289783d3..debb6763d5 100644
--- a/packages/api/gateway/tests/gateway-stream.host.spec.ts
+++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts
@@ -5,6 +5,7 @@ import WebSocket, { type RawData } from 'ws'
import { Context, Service, 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,
@@ -17,6 +18,7 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { provideBrowserCredentials } from './browser-credentials.ts'
import TypertGatewayService, {
TypertGatewayError,
+ type Config as GatewayConfig,
type TypertRemoteEventDispatch,
type TypertRemoteEventInvocation,
type TypertRemoteEventOutcome,
@@ -211,6 +213,15 @@ afterEach(async () => {
})
describe('Typert Remote streams', () => {
+ it('validates the WebSocket heartbeat timer range', () => {
+ expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 30_000 })
+ expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS }))
+ .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS })
+ for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) {
+ expect(() => TypertGatewayService.Config({ websocketHeartbeatIntervalMs })).toThrow()
+ }
+ })
+
it('opens decoded carrier payloads through the in-process wire adapter', async () => {
const { ctx } = await setup(false)
const source = await ctx.typertGateway.wireStream.open(
@@ -280,6 +291,19 @@ describe('Typert Remote streams', () => {
})).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial)
})
+ it('uses the configured WebSocket heartbeat interval', { timeout: 1_000 }, async () => {
+ const { ctx } = await setup(true, { websocketHeartbeatIntervalMs: 20 })
+ const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
+ headers: { cookie: browserCookie(ctx) },
+ })
+ const ping = once(socket, 'ping')
+ await once(socket, 'open')
+ expect((await ping)[0]).toEqual(Buffer.alloc(0))
+
+ socket.close()
+ await once(socket, 'close')
+ })
+
it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => {
const { ctx, service } = await setup(true)
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
@@ -964,7 +988,10 @@ describe('Typert Remote streams', () => {
})
})
-async function setup(transport: boolean): Promise<{ readonly ctx: Context; readonly service: FeedService }> {
+async function setup(
+ transport: boolean,
+ gatewayConfig: GatewayConfig = {},
+): Promise<{ readonly ctx: Context; readonly service: FeedService }> {
const ctx = new Context()
roots.push(ctx)
if (transport) {
@@ -972,7 +999,7 @@ async function setup(transport: boolean): Promise<{ readonly ctx: Context; reado
provideBrowserCredentials(ctx)
}
await ctx.plugin(TypertRegistry)
- await ctx.plugin(TypertGatewayService)
+ await ctx.plugin(TypertGatewayService, gatewayConfig)
if (transport) {
await ctx.plugin({ inject: [...connectionInject], apply: applyConnection })
}
diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts
index 9746943b63..2cc4c99f8c 100644
--- a/packages/api/gateway/tests/stream-server.host.spec.ts
+++ b/packages/api/gateway/tests/stream-server.host.spec.ts
@@ -25,6 +25,31 @@ 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 client = await connect(entry.url)
+ const serverSocket = acceptedSocket(entry.mux)
+ const messages = vi.fn()
+ client.on('message', messages)
+
+ const ping = once(client, 'ping')
+ const pong = once(serverSocket, 'pong')
+ expect((await ping)[0]).toEqual(Buffer.alloc(0))
+ expect((await pong)[0]).toEqual(Buffer.alloc(0))
+ expect(messages).not.toHaveBeenCalled()
+
+ const closingPing = vi.spyOn(serverSocket, 'ping')
+ client.pause()
+ serverSocket.close()
+ expect(serverSocket.readyState).toBe(WebSocket.CLOSING)
+ await new Promise((resolve) => { setTimeout(resolve, 25) })
+ expect(closingPing).not.toHaveBeenCalled()
+
+ const closed = once(client, 'close')
+ client.resume()
+ await closed
+ })
+
it('rejects binary, malformed, and duplicate logical-stream messages', async () => {
const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal))
@@ -168,8 +193,8 @@ const mapFailure: RemoteStreamFailureMapper = error => ({
details: {},
})
-async function startMux(open: RemoteStreamOpener): Promise {
- const mux = new RemoteStreamMuxServer(open, mapFailure)
+async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 30_000): Promise {
+ const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs)
const http = createServer()
http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) })
await new Promise((resolve, reject) => {
diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json
index 46d1b3a88d..54d5f964f3 100644
--- a/packages/api/gateway/tsconfig.host.json
+++ b/packages/api/gateway/tsconfig.host.json
@@ -19,6 +19,9 @@
{
"path": "../../../vendor/cordis"
},
+ {
+ "path": "../../../vendor/schemastery"
+ },
{
"path": "../../runtime-diagnostics/invariants"
},
@@ -30,6 +33,9 @@
},
{
"path": "../../typert/protocol"
+ },
+ {
+ "path": "../../util/timeout"
}
]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0a1cf4046a..83e99c0ed7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -627,9 +627,15 @@ importers:
packages/api/gateway:
dependencies:
+ '@deepseek-ai/dsh-timeout':
+ specifier: workspace:^
+ version: link:../../util/timeout
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:^
version: link:../../typert/protocol
+ '@deepseek-ai/schemastery':
+ specifier: link:../../../vendor/schemastery
+ version: link:../../../vendor/schemastery
ws:
specifier: ^8.21.0
version: 8.21.0