mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
feat(liveshare-protocol): add the shared wire frames and identity contract
The owner bundle and the Relay must agree on the frames they exchange and on how one viewer identity is keyed, without either depending on the other. This package carries both halves: the owner/worker IPC frames, the gateway frames the Relay forwards without interpreting, and the provider-neutral identity contract with its loopback mock. `parseGatewayFrame` answers undefined for input this build does not understand instead of failing the connection, and `unsupportedFrameRefusal` turns such a frame into the refusal to send back. The package is registered as a release member, a doc-standard library, a tsconfig host and client program, and carries the coverage lane the wire codec needs. Its Further Exploration list names the owner bundle and the Relay without linking to packages this layer does not add yet.
This commit is contained in:
1 parent
d6d51be4cb
commit
27ff469ea5
48 files changed
+2470
-2
No files matched your search
@@ -18,8 +18,8 @@
|
||||
en: b55aeb927737b90d
|
||||
zh: 0b215fcb5c2c7908
|
||||
/packages-experimental/packages:
|
||||
en: 895f169f8f3ebbc7
|
||||
zh: 62786c4876d97055
|
||||
en: 2825a698f15cd1e3
|
||||
zh: a60de9f9ff8bbb18
|
||||
/packages-experimental/related-documentation:
|
||||
en: 1cb9d3448d44e48d
|
||||
zh: cd326d70a746b896
|
||||
|
||||
@@ -66,6 +66,7 @@ Promotion requires a named product role, documented public behavior and limitati
|
||||
| [`api-speech-to-text`](api-speech-to-text/README.md) | Authenticated transient transcription Remote | `ctx.speechController` |
|
||||
| [`client-ui-voice-input`](client-ui-voice-input/README.md) | Microphone capture and guarded draft insertion | — |
|
||||
| [`plugin-market-bundle`](plugin-market-bundle/README.md) | Default-disabled optional custom plugin indexes: the index service and the Discover tab | — |
|
||||
| [`liveshare-protocol`](liveshare-protocol/README.md) | Wire frames and the identity contract shared by the owner bundle and the Relay | library and protocol — no ctx key |
|
||||
| [`voice-input-bundle`](voice-input-bundle/README.md) | Default-disabled optional voice input composition | — |
|
||||
| [`agent-team-profile`](agent-team-profile/README.md) | Agent Teams collaboration, tools, and Web UI bundle | — |
|
||||
| [`agent-team`](agent-team/README.md) | Named teammates with direct messages and a durable shared task board | `ctx.agentTeams` |
|
||||
|
||||
@@ -66,6 +66,7 @@ kind: "package-group"
|
||||
| [`api-speech-to-text`](api-speech-to-text/README.zh.md) | 带认证的临时转写 Remote | `ctx.speechController` |
|
||||
| [`client-ui-voice-input`](client-ui-voice-input/README.zh.md) | 麦克风录音与版本检查后的草稿插入 | — |
|
||||
| [`plugin-market-bundle`](plugin-market-bundle/README.zh.md) | 默认禁用的可选自定义插件索引:索引服务和「发现」标签页 | — |
|
||||
| [`liveshare-protocol`](liveshare-protocol/README.zh.md) | owner bundle 与 Relay 共用的线路帧与身份契约 | 库与协议,不使用 ctx key |
|
||||
| [`voice-input-bundle`](voice-input-bundle/README.zh.md) | 默认禁用的可选语音输入组合 | — |
|
||||
| [`agent-team-profile`](agent-team-profile/README.zh.md) | Agent Teams 协作、工具与 Web UI 组合包 | — |
|
||||
| [`agent-team`](agent-team/README.zh.md) | 具名 teammate,成员之间直接消息与持久共享任务板 | `ctx.agentTeams` |
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/liveshare-protocol/README.md
|
||||
/:
|
||||
en: efeee80d1665d7fc
|
||||
zh: 18c481ee3920a247
|
||||
/deepseek-ai-dsh-experimental-liveshare-protocol:
|
||||
en: 023b0ec3130c921a
|
||||
zh: 83a7492f544eed39
|
||||
/deepseek-ai-dsh-experimental-liveshare-protocol/summary:
|
||||
en: 7cab2a1bc2ae0661
|
||||
zh: 04e22b2531a2cb4e
|
||||
/deepseek-ai-dsh-experimental-liveshare-protocol/table-of-contents:
|
||||
en: 80b31a934611cb4a
|
||||
zh: 05f3f7f9cb2fd881
|
||||
/deepseek-ai-dsh-experimental-liveshare-protocol/use-this-package:
|
||||
en: bbff250eba0f1b89
|
||||
zh: 0a618f05cc88ee2b
|
||||
/deepseek-ai-dsh-experimental-liveshare-protocol/use-this-package/when-to-use-it:
|
||||
en: d6c56fd1c60b71cd
|
||||
zh: ec4c5105034cb8c4
|
||||
/deepseek-ai-dsh-experimental-liveshare-protocol/use-this-package/entry-point:
|
||||
en: 9526407bbe718081
|
||||
zh: e618b41ffe4bb621
|
||||
/deepseek-ai-dsh-experimental-liveshare-protocol/understand-the-implementation:
|
||||
en: 13f0de10394fc3d9
|
||||
zh: dd188db5db7cf6b5
|
||||
/deepseek-ai-dsh-experimental-liveshare-protocol/further-exploration:
|
||||
en: d80868b3f0050542
|
||||
zh: 56bad4bab440b2bd
|
||||
/deepseek-ai-dsh-experimental-liveshare-protocol/known-limitations-and-deferred-work:
|
||||
en: 5ea4e854fb94bed0
|
||||
zh: ba0ff1468f3ee498
|
||||
/deepseek-ai-dsh-experimental-liveshare-protocol/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: e88aeb617ffb74ea
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
description: "Liveshare gateway frame codecs and the provider-neutral identity contract shared by the owner bundle and the Relay."
|
||||
kind: "package-library"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-liveshare-protocol
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`dsh-experimental-liveshare-protocol` gives the Liveshare owner bundle and the Relay one copy of what they must agree on: the owner/worker IPC frames, the gateway frames the Relay forwards without interpreting, and the provider-neutral identity contract and the loopback mock implementation. Both packages import it, so neither depends on the other and the Relay never loads owner code. Its only DSH dependency is the stateless `dsh-brand` primitive library.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Use this package](#use-this-package)
|
||||
- [Understand the implementation](#understand-the-implementation)
|
||||
- [Further Exploration](#further-exploration)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
### When to use it
|
||||
|
||||
Import `./protocol` and `./identity` from this package whenever code must speak the Liveshare wire or resolve one viewer identity: the owner host and its gateway worker, the Relay server and its acceptance tests. This is a library — no profile mounts it and no `cordis.yml` row names it.
|
||||
|
||||
### Entry point
|
||||
|
||||
```ts
|
||||
import { parseGatewayFrame, rawText, sendFrame } from '@deepseek-ai/dsh-experimental-liveshare-protocol/protocol'
|
||||
import { identityKeyOf, normalizeIdentity } from '@deepseek-ai/dsh-experimental-liveshare-protocol/identity'
|
||||
```
|
||||
|
||||
`parseGatewayFrame` returns one validated frame and answers `undefined` for malformed or unknown input, so a gateway this build does not understand cannot fail the connection. A caller that needs to answer the peer instead of leaving it waiting calls `unsupportedFrameRefusal`, which turns a frame this build cannot serve into the refusal to send back. `identityKeyOf` renders one actor as the `providerId:tenantId:subjectId` key every ACL comparison uses, and `normalizeIdentity` fills the documented `default` tenant for a provider that issues none.
|
||||
|
||||
Relay compatibility uses declared versions, capabilities, and explicit refusals, not per-release frame translation or common-subset negotiation. Independently deployed peers have no agreed historical support window, so version-specific shims would add an unbounded set of behaviors to maintain. Published optional Relay fields remain optional; capability names are added only for implemented behavior and are never renamed or reinterpreted.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
`tunnel-protocol.ts` defines the frames and nothing else: `sendFrame` takes any socket exposing `readyState` and `send`, so the package needs no socket dependency and both the owner gateway and the Relay pass their own `ws` instance. `identity/` holds the provider contract, the canonical ACL key codec, and the loopback `mock` provider. Neither half imports owner code, so the Relay can depend on this package without depending on the owner bundle.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Both faces re-exported for callers that want one import |
|
||||
| [`src/tunnel-protocol.ts`](src/tunnel-protocol.ts) | Owner/worker IPC frames, gateway frames, and their codecs |
|
||||
| [`src/identity/types.ts`](src/identity/types.ts) | Provider-neutral identity triple and `IdentityProvider` contract |
|
||||
| [`src/identity/keys.ts`](src/identity/keys.ts) | Canonical ACL key codec and the tenant normalization adapters apply before storing an identity |
|
||||
| [`src/identity/mock.ts`](src/identity/mock.ts) | Loopback identity provider for local runs and tests |
|
||||
| — | No runtime invariant companion is published because this package holds no mutable state and no registrations; this package's own specs exercise these codecs. |
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
- [Experimental packages](../README.md) — experimental package status.
|
||||
- The Liveshare owner bundle and the Relay consume these frames and ship separately.
|
||||
|
||||
-----
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Gateway requests require the Relay's authenticated actor outside the opaque body; frames without that actor are refused.
|
||||
- The Owner Host and worker ship together. The supervisor mints a branded UUID `registrationToken` per logical add, invalidates it on removal, and replays it only for the same retained registration after process restart. Lifecycle, requests, and cancellations echo that token with the share id; per-request `TunnelRequestToken` remains independent. IPC decoders refuse missing identities, and neither token enters Relay URLs or wire frames. Same-package IPC does not negotiate versions; Relay wire frames retain numeric ids and protocol version 1.
|
||||
- Owners declaring `share-access-state` receive committed, id-less ACL/mode/expiry snapshots with a durable monotonic version. The worker forwards snapshots only from its current socket; the Host clears authority on socket loss and refreshes the authenticated requests snapshot before external execution admission. An unavailable snapshot fails closed instead of borrowing persisted ACLs.
|
||||
- **Historical release support is not verified** — `tests/frames/v1` records concrete frames and refusal obligations, but does not identify a published Owner release. The gateway declares `LIVESHARE_PROTOCOL_VERSION` and implemented capabilities; the Relay rejects a declared mismatch and sends optional lifecycle frames only to declared consumers. These checks preserve recorded fields and explicit refusals, not a per-release translation or an unbounded support window.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
description: "owner bundle 与 Relay 共用的 Liveshare gateway 帧编解码,以及与 provider 无关的身份契约。"
|
||||
kind: "package-library"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-liveshare-protocol
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
`dsh-experimental-liveshare-protocol` 让 Liveshare owner bundle 与 Relay 共用同一份必须一致的定义:owner/worker 的 IPC 帧、Relay 不解析只转发的 gateway 帧,以及与 provider 无关的身份契约及回环 mock 实现。两个包都依赖它,因此谁也不依赖谁,Relay 也不会加载 owner 侧代码。它唯一的 DSH 依赖是无状态的 `dsh-brand` 基础类型库。
|
||||
|
||||
## 目录
|
||||
|
||||
- [使用本包](#use-this-package)
|
||||
- [理解实现](#understand-the-implementation)
|
||||
- [进一步探索](#further-exploration)
|
||||
- [已知限制与延期工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
### 何时使用
|
||||
|
||||
凡是需要说 Liveshare 线路协议或解析某个 viewer 身份的代码,都从本包导入 `./protocol` 与 `./identity`:owner host 及其 gateway worker、Relay server 及其验收测试。这是一个库——没有 profile 会挂载它,也没有 `cordis.yml` 行引用它。
|
||||
|
||||
### 入口
|
||||
|
||||
```ts
|
||||
import { parseGatewayFrame, rawText, sendFrame } from '@deepseek-ai/dsh-experimental-liveshare-protocol/protocol'
|
||||
import { identityKeyOf, normalizeIdentity } from '@deepseek-ai/dsh-experimental-liveshare-protocol/identity'
|
||||
```
|
||||
|
||||
`parseGatewayFrame` 返回一个校验通过的帧,对畸形或未知输入返回 `undefined`,因此本构建不认识的 gateway 帧不会让连接失败。需要回答对端而不是让它空等的调用方使用 `unsupportedFrameRefusal`,它把一个本构建无法服务的帧变成要回送的拒绝。`identityKeyOf` 把一个 actor 渲染成所有 ACL 比较使用的 `providerId:tenantId:subjectId` 键,`normalizeIdentity` 为不签发 tenant 的 provider 填入文档化的 `default`。
|
||||
|
||||
Relay 兼容性依靠声明版本、能力和明确拒绝,不提供逐发布版本的帧翻译或共同子集协商。独立部署的对端没有约定的历史支持窗口,因此逐版本适配会引入数量无界、需要持续维护的行为。已发布的可选 Relay 字段保持可选;能力名仅为已经实现的行为新增,绝不重命名或重新解释。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
`tunnel-protocol.ts` 只定义帧:`sendFrame` 接收任何带 `readyState` 与 `send` 的 socket,因此本包不需要 socket 依赖,owner gateway 与 Relay 各自传入自己的 `ws` 实例。`identity/` 持有 provider 契约、规范 ACL 身份键编解码,以及 loopback 用的 `mock` provider。两半都不导入 owner 代码,因此 Relay 可以依赖本包而不依赖 owner bundle。
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 为只想一次导入的调用方复出两个面 |
|
||||
| [`src/tunnel-protocol.ts`](src/tunnel-protocol.ts) | owner/worker IPC 帧、gateway 帧及其编解码 |
|
||||
| [`src/identity/types.ts`](src/identity/types.ts) | 与 provider 无关的身份三元组与 `IdentityProvider` 契约 |
|
||||
| [`src/identity/keys.ts`](src/identity/keys.ts) | 规范 ACL 身份键编解码,以及 adapter 存入身份前的 tenant 规范化 |
|
||||
| [`src/identity/mock.ts`](src/identity/mock.ts) | 供本地运行与测试使用的 loopback 身份 provider |
|
||||
| — | 本包不持有可变状态、不做注册,因此不发布 runtime invariant companion;这些编解码由本包自己的测试覆盖。 |
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 进一步探索
|
||||
|
||||
- [实验性包](../README.zh.md)——实验性包状态。
|
||||
- Liveshare owner bundle 与 Relay 消费这些帧,单独发布。
|
||||
|
||||
-----
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 网关请求必须在不透明请求体之外携带 Relay 已认证主体;没有该主体的帧被拒绝。
|
||||
- Owner Host 与 worker 同包发布。Supervisor 为每次逻辑添加生成品牌化 UUID `registrationToken`,移除时使其失效,进程重启只为仍保留的同一注册重播该身份。生命周期、请求及取消事件回传该 token 与分享 ID;逐请求 `TunnelRequestToken` 独立存在。IPC 编解码拒绝缺失身份,两种 token 都不进入 Relay URL 或线协议帧。同包 IPC 不协商版本;Relay 线协议帧保留数字 ID 与协议版本 1。
|
||||
- 声明 `share-access-state` 的 owner 接收已提交、无 id 的 ACL、模式与到期时间快照,快照携带持久单调版本。worker 只转发当前 socket 的快照;Host 在 socket 丢失后清空授权,并在外部执行准入前刷新已认证的请求快照。快照不可用时默认拒绝,不借用持久化 ACL。
|
||||
- **历史发布版本支持尚未核实**——`tests/frames/v1` 记录具体帧和拒绝义务,但没有标识已发布的 Owner 版本。网关声明 `LIVESHARE_PROTOCOL_VERSION` 与已实现能力;Relay 拒绝已声明的版本不匹配,仅向已声明消费者发送可选生命周期帧。这些检查保留已录字段和明确拒绝,不提供逐发布版本翻译或无限支持窗口。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者工作上下文——点击展开</summary>
|
||||
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-experimental-liveshare-protocol",
|
||||
"description": "Liveshare wire frames and the identity contract shared by the owner bundle and the Relay",
|
||||
"version": "0.2.1-rc.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/experimental/liveshare-protocol"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./identity": {
|
||||
"types": "./lib/types/identity/index.d.ts",
|
||||
"default": "./lib/identity/index.js"
|
||||
},
|
||||
"./protocol": {
|
||||
"types": "./lib/types/tunnel-protocol.d.ts",
|
||||
"default": "./lib/tunnel-protocol.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/identity/index.js",
|
||||
"lib/tunnel-protocol.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-brand": "workspace:*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './types.ts'
|
||||
export * from './mock.ts'
|
||||
export * from './keys.ts'
|
||||
@@ -0,0 +1,71 @@
|
||||
/** Canonical ACL key for one identity triple, and the normalization every adapter applies first. */
|
||||
|
||||
import type { IdentityTriple } from './types.ts'
|
||||
|
||||
/**
|
||||
* Build the canonical identity key stored in share ACLs.
|
||||
* @param identity Provider, tenant, and subject triple.
|
||||
* @returns Percent-encoded stable key with no user-controlled formatting.
|
||||
*/
|
||||
export function identityKeyOf(identity: IdentityTriple): string {
|
||||
return [
|
||||
encodeURIComponent(identity.providerId),
|
||||
encodeURIComponent(identity.tenantId),
|
||||
encodeURIComponent(identity.subjectId),
|
||||
].join(':')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a canonical identity key created by {@link identityKeyOf}.
|
||||
* @param key Percent-encoded provider, tenant, and subject key.
|
||||
* @returns Identity triple, or undefined when the key is not canonical.
|
||||
*/
|
||||
export function identityFromKey(key: string): IdentityTriple | undefined {
|
||||
const parts = key.split(':')
|
||||
if (parts.length !== 3) return undefined
|
||||
const [providerPart, tenantPart, subjectPart] = parts
|
||||
if (providerPart === undefined || tenantPart === undefined || subjectPart === undefined) return undefined
|
||||
try {
|
||||
const providerId = decodeURIComponent(providerPart)
|
||||
const tenantId = decodeURIComponent(tenantPart)
|
||||
const subjectId = decodeURIComponent(subjectPart)
|
||||
if (providerId === '' || tenantId === '' || subjectId === '') return undefined
|
||||
return { providerId, tenantId, subjectId }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant id recorded for a provider that issues no tenant of its own, such as a
|
||||
* DeepSeek account adapter. Providers that own a tenant must reject an empty
|
||||
* tenant instead of using this placeholder; see {@link requireIdentityTenant}.
|
||||
*/
|
||||
export const IDENTITY_TENANT_PLACEHOLDER = 'default'
|
||||
|
||||
/**
|
||||
* Normalize one adapter-produced identity before Relay state stores it.
|
||||
* @param identity Provider-produced triple.
|
||||
* @param tenantFallback Tenant id used when the provider issued none.
|
||||
* @returns Normalized triple, or undefined when the provider or subject id is empty.
|
||||
*/
|
||||
export function normalizeIdentity<T extends IdentityTriple>(
|
||||
identity: T,
|
||||
tenantFallback: string = IDENTITY_TENANT_PLACEHOLDER,
|
||||
): T | undefined {
|
||||
if (identity.providerId === '' || identity.subjectId === '') return undefined
|
||||
if (identity.tenantId !== '') return identity
|
||||
if (tenantFallback === '') return undefined
|
||||
return { ...identity, tenantId: tenantFallback }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one adapter-produced identity whose provider must issue a tenant id.
|
||||
* @param identity Provider-produced triple.
|
||||
* @returns The same triple, or undefined when the provider, tenant, or subject id is empty.
|
||||
*/
|
||||
export function requireIdentityTenant<T extends IdentityTriple>(identity: T): T | undefined {
|
||||
return identity.providerId === '' || identity.tenantId === '' || identity.subjectId === ''
|
||||
? undefined
|
||||
: identity
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { normalizeIdentity } from './keys.ts'
|
||||
import type {
|
||||
IdentityActor,
|
||||
IdentityAuthorizationRequest,
|
||||
IdentityExchangeRequest,
|
||||
IdentityProvider,
|
||||
} from './types.ts'
|
||||
|
||||
/** One fixed local identity used by tests and loopback demos. */
|
||||
export interface MockIdentityPersona {
|
||||
readonly id: string
|
||||
readonly tenantId?: string
|
||||
readonly displayName?: string
|
||||
/** Avatar a provider would publish; absent stands for a provider that issues none. */
|
||||
readonly avatarUrl?: string
|
||||
}
|
||||
|
||||
/** Deterministic provider for tests; not an authentication boundary. */
|
||||
export class MockIdentityProvider implements IdentityProvider {
|
||||
readonly scopes: readonly string[] = []
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
private readonly personas: readonly MockIdentityPersona[],
|
||||
) {
|
||||
if (id === '') throw new Error('mock identity provider id must not be empty')
|
||||
if (personas.length === 0) throw new Error('mock identity provider requires at least one persona')
|
||||
}
|
||||
|
||||
/** Redirect back to the Relay callback with the selected fixed persona code. */
|
||||
authorizationUrl(request: IdentityAuthorizationRequest): URL {
|
||||
const selected = request.loginHint ?? this.personas[0]?.id
|
||||
if (selected === undefined) throw new Error('mock identity provider has no selectable persona')
|
||||
const url = new URL(request.redirectUri)
|
||||
url.searchParams.set('code', selected)
|
||||
url.searchParams.set('state', request.state)
|
||||
return url
|
||||
}
|
||||
|
||||
/** Resolve a fixed persona code into a provider-neutral actor. */
|
||||
exchange(request: IdentityExchangeRequest): Promise<IdentityActor> {
|
||||
const persona = this.personas.find(candidate => candidate.id === request.code)
|
||||
if (persona === undefined) return Promise.reject(new Error('mock identity callback code is unknown'))
|
||||
const actor = normalizeIdentity({
|
||||
providerId: this.id,
|
||||
subjectId: persona.id,
|
||||
tenantId: persona.tenantId ?? 'mock',
|
||||
displayName: persona.displayName ?? persona.id,
|
||||
...(persona.avatarUrl === undefined ? {} : { avatarUrl: persona.avatarUrl }),
|
||||
}, 'mock')
|
||||
if (actor === undefined) return Promise.reject(new Error('mock identity persona id must not be empty'))
|
||||
return Promise.resolve(actor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Provider, tenant, and subject triple that identifies one person across providers. */
|
||||
export interface IdentityTriple {
|
||||
/** Identity provider that owns the subject. */
|
||||
readonly providerId: string
|
||||
/** Provider tenant that contains the subject. */
|
||||
readonly tenantId: string
|
||||
/** Provider-scoped subject id. */
|
||||
readonly subjectId: string
|
||||
}
|
||||
|
||||
/** Provider-neutral viewer identity resolved by a Relay. */
|
||||
export interface IdentityActor extends IdentityTriple {
|
||||
readonly displayName: string
|
||||
/**
|
||||
* Provider-issued avatar URL, when the provider publishes one. Presentation only: it carries no
|
||||
* credential, and a provider that issues none leaves it absent rather than inventing a value.
|
||||
*/
|
||||
readonly avatarUrl?: string
|
||||
}
|
||||
|
||||
/** Request facts needed to build one provider authorization redirect. */
|
||||
export interface IdentityAuthorizationRequest {
|
||||
readonly state: string
|
||||
readonly redirectUri: string
|
||||
readonly scopes: readonly string[]
|
||||
readonly loginHint?: string
|
||||
}
|
||||
|
||||
/** Request facts needed to exchange a provider callback code. */
|
||||
export interface IdentityExchangeRequest {
|
||||
readonly code: string
|
||||
readonly redirectUri: string
|
||||
}
|
||||
|
||||
/** Generic browser OAuth contract implemented by Feishu and local mocks. */
|
||||
export interface IdentityProvider {
|
||||
readonly id: string
|
||||
readonly scopes: readonly string[]
|
||||
authorizationUrl(request: IdentityAuthorizationRequest): URL
|
||||
exchange(request: IdentityExchangeRequest): Promise<IdentityActor>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Liveshare wire protocol and identity contract shared by the owner bundle and the Relay.
|
||||
* @module @deepseek-ai/dsh-experimental-liveshare-protocol
|
||||
*/
|
||||
|
||||
export * from './tunnel-protocol.ts'
|
||||
export * from './identity/index.ts'
|
||||
export * from './viewer-carrier.ts'
|
||||
@@ -0,0 +1,525 @@
|
||||
/** IPC frames between the owner host plugin and its one tunnel worker, plus the Relay wire frames. */
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
export * from './viewer-states.ts'
|
||||
|
||||
/** Worker-issued identity for one request; never reused across sockets or worker processes. */
|
||||
export type TunnelRequestToken = Branded<'TunnelRequestToken'>
|
||||
|
||||
/** Supervisor-issued UUID for one logical add; retained on process restart, invalidated by removal. Never sent to the Relay. */
|
||||
export type TunnelRegistrationToken = Branded<'TunnelRegistrationToken'>
|
||||
|
||||
/** Relay wire version; Host/worker IPC ships together and is not negotiated by this number. */
|
||||
export const LIVESHARE_PROTOCOL_VERSION = 1
|
||||
|
||||
/**
|
||||
* Optional behaviors this build offers the Relay, beyond the frames themselves.
|
||||
*
|
||||
* A peer that declares none is a build older than this one. Each name states behavior the peer can
|
||||
* observe on the wire, so a Relay relies only on what it has been told rather than on the release it
|
||||
* happens to run beside.
|
||||
*/
|
||||
export const LIVESHARE_CAPABILITIES: readonly string[] = [
|
||||
'session-mode',
|
||||
'fork',
|
||||
'acl-subjects',
|
||||
'expiry',
|
||||
'owner-credential',
|
||||
'scoped-rpc-response',
|
||||
'unsupported-frame-refusal',
|
||||
'share-access-state',
|
||||
]
|
||||
|
||||
/** One share the owner host asks its gateway worker to carry to the Relay. */
|
||||
export interface TunnelShareSpec {
|
||||
readonly shareId: string
|
||||
readonly sessionId: string
|
||||
readonly sessionTitle: string
|
||||
readonly accessMode: 'open' | 'approval'
|
||||
readonly relay: string
|
||||
readonly gatewayToken: string
|
||||
readonly ownerProviderId?: string
|
||||
readonly ownerTenantId?: string
|
||||
readonly ownerDisplayName: string
|
||||
/** Version of the owner's DSH build, shown on the Relay share picker. */
|
||||
readonly dshVersion?: string
|
||||
/** Stable id of the owner DSH instance that published this share. */
|
||||
readonly ownerInstanceId?: string
|
||||
/**
|
||||
* Whether this share lets an admitted viewer fork it.
|
||||
*
|
||||
* Its own switch, independent of {@link TunnelShareSpec.sessionMode}: a view-tier share may allow
|
||||
* forking and a writable one may refuse it. Absent means no forking.
|
||||
*/
|
||||
readonly allowsFork?: boolean
|
||||
/**
|
||||
* Tier this share was created with.
|
||||
*
|
||||
* `view` reads the shared Session and drives its loop inside the granted workspace without writing it;
|
||||
* `write` also modifies the files inside that workspace. `fork-readonly` is the withdrawn tier an older
|
||||
* owner still names, and it reads back as `view` with that owner's switch deciding the forking half.
|
||||
* Absent means the share predates tiers and reads as `view`.
|
||||
*/
|
||||
readonly sessionMode?: 'view' | 'fork-readonly' | 'write'
|
||||
/**
|
||||
* Relay-issued owner credential proving which logged-in subject publishes this share. The Relay
|
||||
* refuses a new share without it and ignores self-reported identity fields when it is present.
|
||||
*/
|
||||
readonly ownerCredential?: string
|
||||
readonly allowedSubjectIds: readonly string[]
|
||||
/** ISO time after which the Relay denies viewers; absent means the share never expires. */
|
||||
readonly expiresAt?: string
|
||||
}
|
||||
|
||||
/** Commands sent from the owner host plugin to its tunnel worker. */
|
||||
export type TunnelWorkerCommand =
|
||||
| { readonly type: 'add-share'; readonly registrationToken: TunnelRegistrationToken; readonly share: TunnelShareSpec }
|
||||
| { readonly type: 'remove-share'; readonly shareId: string }
|
||||
| {
|
||||
readonly type: 'share-rpc-response'
|
||||
readonly registrationToken: TunnelRegistrationToken
|
||||
/** Echo the request's token; Relay ids alone do not identify an IPC request. */
|
||||
readonly token: TunnelRequestToken
|
||||
readonly id: number
|
||||
readonly shareId: string
|
||||
readonly status: number
|
||||
readonly body?: unknown
|
||||
}
|
||||
| { readonly type: 'shutdown' }
|
||||
|
||||
interface TunnelRegistrationIdentity {
|
||||
readonly registrationToken: TunnelRegistrationToken
|
||||
readonly shareId: string
|
||||
}
|
||||
|
||||
/** Lifecycle and request events echo their add's registration token; neither IPC token enters Relay frames. */
|
||||
export type TunnelWorkerEvent =
|
||||
| { readonly type: 'ready' }
|
||||
| {
|
||||
readonly type: 'share-rpc-request'
|
||||
readonly registrationToken: TunnelRegistrationToken
|
||||
readonly token: TunnelRequestToken
|
||||
readonly id: number
|
||||
readonly shareId: string
|
||||
/** Opaque viewer request the Relay forwarded; the owner host is its only reader. */
|
||||
readonly body: unknown
|
||||
/** Viewer identity the Relay authenticated, which the owner host reads instead of the body. */
|
||||
readonly actor: ShareRpcActor
|
||||
}
|
||||
| {
|
||||
readonly type: 'share-rpc-cancel'
|
||||
readonly registrationToken: TunnelRegistrationToken
|
||||
readonly token: TunnelRequestToken
|
||||
readonly id: number
|
||||
readonly shareId: string
|
||||
}
|
||||
| (ShareAccessStateFrame & TunnelRegistrationIdentity)
|
||||
| ({ readonly type: 'share-connected' } & TunnelRegistrationIdentity)
|
||||
| ({ readonly type: 'share-error'; readonly message: string } & TunnelRegistrationIdentity)
|
||||
| ({ readonly type: 'share-closed' } & TunnelRegistrationIdentity)
|
||||
|
||||
/** One opaque viewer request the Relay routes to the owner gateway. */
|
||||
export interface ShareRpcFrame {
|
||||
readonly type: 'share-rpc'
|
||||
readonly id: number
|
||||
/** Viewer request the Relay forwards without reading it. */
|
||||
readonly body: unknown
|
||||
/**
|
||||
* Viewer identity the Relay authenticated for this request.
|
||||
*
|
||||
* This is the only viewer identity an owner may act on: the Relay holds the session and asserts it,
|
||||
* the browser never does, and it travels outside the opaque body so the owner learns who is asking
|
||||
* without interpreting the request itself.
|
||||
*/
|
||||
readonly actor: ShareRpcActor
|
||||
}
|
||||
|
||||
/** Authenticated viewer identity the Relay asserts for one forwarded request. */
|
||||
export interface ShareRpcActor {
|
||||
readonly providerId: string
|
||||
readonly tenantId: string
|
||||
readonly subjectId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Routing fields an opaque viewer request carries, for logging only.
|
||||
*
|
||||
* The Relay and the owner worker both forward the same request body without reading it, and both
|
||||
* need the same two fields to correlate one read across hops; every other field stays opaque.
|
||||
* @param body Request body as forwarded.
|
||||
* @returns The operation name and stream id, or placeholders when the body carries neither.
|
||||
*/
|
||||
export function viewerRequestFields(body: unknown): { readonly op: string; readonly streamId: string } {
|
||||
if (typeof body !== 'object' || body === null) return { op: 'unknown', streamId: '-' }
|
||||
const value = body as { readonly op?: unknown; readonly id?: unknown }
|
||||
return {
|
||||
op: typeof value.op === 'string' ? value.op : 'unknown',
|
||||
streamId: typeof value.id === 'number' ? String(value.id) : '-',
|
||||
}
|
||||
}
|
||||
|
||||
/** The owner gateway's opaque answer to one routed viewer request. */
|
||||
export interface ShareRpcResFrame {
|
||||
readonly type: 'share-rpc-res'
|
||||
readonly id: number
|
||||
readonly status: number
|
||||
readonly body?: unknown
|
||||
}
|
||||
|
||||
/** Cancel one in-flight viewer request after its viewer went away. */
|
||||
export interface ShareRpcCancelFrame {
|
||||
readonly type: 'share-rpc-cancel'
|
||||
readonly id: number
|
||||
}
|
||||
|
||||
/** Committed Relay authority for one share, scoped to the socket carrying it. */
|
||||
export interface ShareAccessStateFrame {
|
||||
readonly type: 'share-access-state'
|
||||
/** Monotonic durable Relay store revision. */
|
||||
readonly version: number
|
||||
readonly accessMode: 'open' | 'approval'
|
||||
readonly allowedSubjectIds: readonly string[]
|
||||
/**
|
||||
* Tier the share carries.
|
||||
*
|
||||
* `fork-readonly` is the withdrawn tier an older Relay or a stored row may still name; a reader of
|
||||
* this frame treats it as `view`, because the forking half is {@link ShareAccessStateFrame.allowsFork}.
|
||||
*/
|
||||
readonly sessionMode: 'view' | 'fork-readonly' | 'write'
|
||||
/**
|
||||
* Whether this share lets an admitted viewer fork it.
|
||||
*
|
||||
* Absent on a Relay older than the split between the tier and the switch, and then a `fork-readonly`
|
||||
* tier stands for it: that is what the withdrawn name granted.
|
||||
*/
|
||||
readonly allowsFork?: boolean
|
||||
readonly expiresAt?: string
|
||||
}
|
||||
|
||||
/** Every frame exchanged between the Relay and one owner gateway. */
|
||||
export type Frame = ShareRpcFrame | ShareRpcResFrame | ShareRpcCancelFrame | ShareAccessStateFrame
|
||||
|
||||
/** The part of a socket `sendFrame` needs, satisfied by a `ws` WebSocket. */
|
||||
export interface FrameSocket {
|
||||
readonly readyState: number
|
||||
send(data: string): void
|
||||
}
|
||||
|
||||
/** `WebSocket.OPEN`, restated so the shared protocol package carries no socket dependency. */
|
||||
const SOCKET_OPEN = 1
|
||||
|
||||
/**
|
||||
* Send one frame when the socket is open.
|
||||
* @param socket Owner gateway or viewer socket.
|
||||
* @param frame Wire frame.
|
||||
*/
|
||||
export function sendFrame(socket: FrameSocket, frame: Frame): void {
|
||||
if (socket.readyState === SOCKET_OPEN) socket.send(JSON.stringify(frame))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one WebSocket data frame without relying on its representation.
|
||||
* @param data Message bytes supplied by ws.
|
||||
* @returns UTF-8 text for one complete message.
|
||||
*/
|
||||
export function rawText(data: Buffer | ArrayBuffer | Buffer[]): 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')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one raw frame's envelope: the JSON value and the request id every accepted frame carries.
|
||||
*
|
||||
* Both directions state the same envelope, so one decoder owns it — a frame's id is what correlates an
|
||||
* answer with its request, and a second copy of these rules is a second place for that to drift.
|
||||
* @param text - Raw socket text.
|
||||
* @returns The decoded value with its id, or undefined when the text is not an accepted envelope.
|
||||
*/
|
||||
function decodeFrameEnvelope(text: string): { readonly value: Record<string, unknown>; readonly id: number } | undefined {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(text)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (!isRecord(value) || !Number.isSafeInteger(value.id) || Number(value.id) <= 0) return undefined
|
||||
return { value, id: Number(value.id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one gateway-to-Relay frame; unknown or malformed frames are dropped.
|
||||
* @param text Raw socket text.
|
||||
* @returns Parsed frame, or undefined when the frame is not accepted.
|
||||
*/
|
||||
export function parseGatewayFrame(text: string): Frame | undefined {
|
||||
const envelope = decodeFrameEnvelope(text)
|
||||
if (envelope === undefined) return undefined
|
||||
const { value, id } = envelope
|
||||
if (value.type === 'share-rpc-res') {
|
||||
if (!Number.isSafeInteger(value.status) || Number(value.status) < 100 || Number(value.status) > 599) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
type: 'share-rpc-res',
|
||||
id,
|
||||
status: Number(value.status),
|
||||
...(value.body === undefined ? {} : { body: value.body }),
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one Relay-to-gateway frame; unknown or malformed frames are dropped.
|
||||
* @param text Raw socket text.
|
||||
* @returns Parsed frame, or undefined when the frame is not accepted.
|
||||
*/
|
||||
export function parseRelayFrame(text: string): Frame | undefined {
|
||||
let lifecycle: unknown
|
||||
try {
|
||||
lifecycle = JSON.parse(text)
|
||||
} catch (_error) {
|
||||
// Invalid wire JSON carries neither a lifecycle state nor an RPC.
|
||||
return undefined
|
||||
}
|
||||
const state = parseShareAccessState(lifecycle)
|
||||
if (state !== undefined) return state
|
||||
const envelope = decodeFrameEnvelope(text)
|
||||
if (envelope === undefined) return undefined
|
||||
const { value, id } = envelope
|
||||
if (value.type === 'share-rpc') {
|
||||
if (value.body === undefined) return undefined
|
||||
// A frame without the Relay's asserted actor is refused, so an owner never sees a request whose
|
||||
// caller it cannot attribute.
|
||||
if (!isShareRpcActor(value.actor)) return undefined
|
||||
return { type: 'share-rpc', id, body: value.body, actor: value.actor }
|
||||
}
|
||||
if (value.type === 'share-rpc-cancel') return { type: 'share-rpc-cancel', id }
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal this build answers with when it reads a frame it cannot serve.
|
||||
*
|
||||
* A frame whose type this build does not know, or a known request whose fields it refuses, otherwise
|
||||
* leaves the peer waiting for an answer that will never come: the read holds until the peer's own
|
||||
* budget expires, and a version mismatch reaches the reader as a timeout rather than as a fact.
|
||||
* Answering states the one thing the peer can act on — this build cannot serve that frame — while a
|
||||
* frame with no usable envelope states no request id to correlate an answer with, and a frame that
|
||||
* answers or cancels something is not a request to answer at all.
|
||||
* @param text Raw socket text the Relay sent.
|
||||
* @returns The refusal to send, or undefined when this frame is not one to answer.
|
||||
*/
|
||||
export function unsupportedFrameRefusal(text: string): ShareRpcResFrame | undefined {
|
||||
// A frame this build serves is not one to refuse, so the answer holds whoever calls this.
|
||||
if (parseRelayFrame(text) !== undefined) return undefined
|
||||
const envelope = decodeFrameEnvelope(text)
|
||||
if (envelope === undefined) return undefined
|
||||
const type = envelope.value.type
|
||||
if (type === 'share-rpc') {
|
||||
// The Relay's own request frame, carrying fields the gateway refuses: the peer sent something
|
||||
// this build reads and rejects, so the answer names that rather than an unknown frame.
|
||||
return {
|
||||
type: 'share-rpc-res',
|
||||
id: envelope.id,
|
||||
status: 400,
|
||||
body: { error: 'invalid-request' },
|
||||
}
|
||||
}
|
||||
if (type === 'share-rpc-res' || type === 'share-rpc-cancel') return undefined
|
||||
if (typeof type !== 'string' || type === '') return undefined
|
||||
// A frame type this build does not know: the peer is newer, and saying so is what lets the Relay
|
||||
// report a version mismatch instead of holding the read.
|
||||
return {
|
||||
type: 'share-rpc-res',
|
||||
id: envelope.id,
|
||||
status: 501,
|
||||
body: { error: 'unsupported-frame' },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The share one rejected IPC command names, so its host learns the command was refused.
|
||||
*
|
||||
* A command this build does not accept is otherwise dropped in silence: an interactive share then
|
||||
* fails after its own wait, and a share restored at startup stays offline with nothing said.
|
||||
* @param value Raw IPC message payload the worker refused.
|
||||
* @returns The share the command named, or undefined when the payload names none.
|
||||
*/
|
||||
export function rejectedCommandShareId(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) return undefined
|
||||
if (typeof value.shareId === 'string' && value.shareId !== '') return value.shareId
|
||||
const share = value.share
|
||||
if (isRecord(share) && typeof share.shareId === 'string' && share.shareId !== '') return share.shareId
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one host-to-worker IPC command; unknown or malformed commands are dropped.
|
||||
*
|
||||
* The worker and its host run in separate processes, so the message crosses a process boundary and
|
||||
* every field the worker reads is checked here rather than asserted.
|
||||
* @param value Raw IPC message payload.
|
||||
* @returns Parsed command, or undefined when the payload is not an accepted command.
|
||||
*/
|
||||
export function parseTunnelWorkerCommand(value: unknown): TunnelWorkerCommand | undefined {
|
||||
if (!isRecord(value)) return undefined
|
||||
switch (value.type) {
|
||||
case 'shutdown':
|
||||
return { type: 'shutdown' }
|
||||
case 'remove-share':
|
||||
return typeof value.shareId === 'string' && value.shareId !== ''
|
||||
? { type: 'remove-share', shareId: value.shareId }
|
||||
: undefined
|
||||
case 'add-share':
|
||||
return isRegistrationToken(value.registrationToken) && isTunnelShareSpec(value.share)
|
||||
? { type: 'add-share', registrationToken: value.registrationToken, share: value.share } : undefined
|
||||
case 'share-rpc-response': {
|
||||
if (!isTunnelRequestIdentity(value) || !Number.isSafeInteger(value.status)
|
||||
|| Number(value.status) < 100 || Number(value.status) > 599) return undefined
|
||||
return {
|
||||
type: 'share-rpc-response',
|
||||
registrationToken: value.registrationToken,
|
||||
token: value.token,
|
||||
id: value.id,
|
||||
shareId: value.shareId,
|
||||
status: Number(value.status),
|
||||
...(value.body === undefined ? {} : { body: value.body }),
|
||||
}
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one worker-to-host IPC event; unknown or malformed events are dropped.
|
||||
* @param value Raw IPC message payload.
|
||||
* @returns Parsed event, or undefined when the payload is not an accepted event.
|
||||
*/
|
||||
export function parseTunnelWorkerEvent(value: unknown): TunnelWorkerEvent | undefined {
|
||||
if (!isRecord(value)) return undefined
|
||||
switch (value.type) {
|
||||
case 'ready':
|
||||
return { type: 'ready' }
|
||||
case 'share-connected':
|
||||
case 'share-closed': {
|
||||
const type = value.type
|
||||
return isTunnelRegistrationIdentity(value)
|
||||
? { type, registrationToken: value.registrationToken, shareId: value.shareId }
|
||||
: undefined
|
||||
}
|
||||
case 'share-access-state': {
|
||||
const state = parseShareAccessState(value)
|
||||
return state !== undefined && isTunnelRegistrationIdentity(value)
|
||||
? { ...state, registrationToken: value.registrationToken, shareId: value.shareId } : undefined
|
||||
}
|
||||
case 'share-error':
|
||||
return isTunnelRegistrationIdentity(value) && typeof value.message === 'string'
|
||||
? { type: 'share-error', registrationToken: value.registrationToken, shareId: value.shareId, message: value.message }
|
||||
: undefined
|
||||
case 'share-rpc-cancel':
|
||||
return isTunnelRequestIdentity(value)
|
||||
? { type: 'share-rpc-cancel', registrationToken: value.registrationToken, token: value.token, id: value.id, shareId: value.shareId }
|
||||
: undefined
|
||||
case 'share-rpc-request':
|
||||
return isTunnelRequestIdentity(value) && value.body !== undefined && isShareRpcActor(value.actor)
|
||||
? {
|
||||
type: 'share-rpc-request', registrationToken: value.registrationToken, token: value.token, id: value.id,
|
||||
shareId: value.shareId, body: value.body, actor: value.actor,
|
||||
}
|
||||
: undefined
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one share spec the owner host asked its worker to carry.
|
||||
* @param value Candidate spec.
|
||||
* @returns True when every required field is present with the declared type.
|
||||
*/
|
||||
function isTunnelShareSpec(value: unknown): value is TunnelShareSpec {
|
||||
if (!isRecord(value)) return false
|
||||
const mode = value.sessionMode
|
||||
return typeof value.shareId === 'string' && value.shareId !== ''
|
||||
&& typeof value.sessionId === 'string'
|
||||
&& typeof value.sessionTitle === 'string'
|
||||
&& (value.accessMode === 'open' || value.accessMode === 'approval')
|
||||
&& typeof value.relay === 'string'
|
||||
&& typeof value.gatewayToken === 'string'
|
||||
&& typeof value.ownerDisplayName === 'string'
|
||||
&& (value.ownerProviderId === undefined || typeof value.ownerProviderId === 'string')
|
||||
&& (value.ownerTenantId === undefined || typeof value.ownerTenantId === 'string')
|
||||
&& (value.dshVersion === undefined || typeof value.dshVersion === 'string')
|
||||
&& (value.ownerInstanceId === undefined || typeof value.ownerInstanceId === 'string')
|
||||
&& (value.allowsFork === undefined || typeof value.allowsFork === 'boolean')
|
||||
&& (mode === undefined || mode === 'view' || mode === 'fork-readonly' || mode === 'write')
|
||||
&& (value.ownerCredential === undefined || typeof value.ownerCredential === 'string')
|
||||
&& Array.isArray(value.allowedSubjectIds)
|
||||
&& value.allowedSubjectIds.every(subject => typeof subject === 'string' && subject !== '')
|
||||
&& (value.expiresAt === undefined || typeof value.expiresAt === 'string')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the actor the Relay asserts on one forwarded request.
|
||||
* @param value Candidate actor.
|
||||
* @returns True when it carries a provider, tenant, and subject.
|
||||
*/
|
||||
function isShareRpcActor(value: unknown): value is ShareRpcActor {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const actor = value as Record<string, unknown>
|
||||
return typeof actor.providerId === 'string' && actor.providerId !== ''
|
||||
&& typeof actor.tenantId === 'string'
|
||||
&& typeof actor.subjectId === 'string' && actor.subjectId !== ''
|
||||
}
|
||||
|
||||
function isRegistrationToken(value: unknown): value is TunnelRegistrationToken {
|
||||
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||
}
|
||||
|
||||
function isTunnelRegistrationIdentity(value: Record<string, unknown>): value is Record<string, unknown> & TunnelRegistrationIdentity {
|
||||
return isRegistrationToken(value.registrationToken) && typeof value.shareId === 'string' && value.shareId !== ''
|
||||
}
|
||||
|
||||
/** Validate request correlation fields at the IPC parser. */
|
||||
function isTunnelRequestIdentity(value: Record<string, unknown>): value is Record<string, unknown> & {
|
||||
readonly registrationToken: TunnelRegistrationToken
|
||||
readonly token: TunnelRequestToken
|
||||
readonly id: number
|
||||
readonly shareId: string
|
||||
} {
|
||||
return isTunnelRegistrationIdentity(value) && typeof value.token === 'string' && value.token !== ''
|
||||
&& Number.isSafeInteger(value.id) && Number(value.id) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a committed Relay authority snapshot at wire, IPC, or authenticated HTTP ingress.
|
||||
* @param value - Untrusted snapshot fields.
|
||||
* @returns Owned snapshot fields, or undefined when any authority field is invalid.
|
||||
*/
|
||||
export function parseShareAccessState(value: unknown): ShareAccessStateFrame | undefined {
|
||||
if (!isRecord(value) || value.type !== 'share-access-state'
|
||||
|| !Number.isSafeInteger(value.version) || Number(value.version) < 0
|
||||
|| (value.accessMode !== 'open' && value.accessMode !== 'approval')
|
||||
|| !isSubjectIds(value.allowedSubjectIds)
|
||||
|| (value.sessionMode !== 'view' && value.sessionMode !== 'fork-readonly' && value.sessionMode !== 'write')
|
||||
|| (value.allowsFork !== undefined && typeof value.allowsFork !== 'boolean')
|
||||
|| (value.expiresAt !== undefined && (typeof value.expiresAt !== 'string' || !Number.isFinite(Date.parse(value.expiresAt))))) {
|
||||
return undefined
|
||||
}
|
||||
return { type: 'share-access-state', version: Number(value.version), accessMode: value.accessMode,
|
||||
allowedSubjectIds: [...value.allowedSubjectIds], sessionMode: value.sessionMode,
|
||||
...(value.allowsFork === undefined ? {} : { allowsFork: value.allowsFork }),
|
||||
...(value.expiresAt === undefined ? {} : { expiresAt: value.expiresAt }) }
|
||||
}
|
||||
|
||||
function isSubjectIds(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every(id => typeof id === 'string' && id !== '')
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Viewer page carrier: the script the Relay places at the top of the owner's served index document.
|
||||
*
|
||||
* It installs the page's Connection transport over the Relay read channel and names the share the
|
||||
* page presents. It performs no Liveshare business: grants, page status, and refusal handling belong
|
||||
* to the Liveshare client plugin, which reads them through ordinary reads. The carrier only converts
|
||||
* Connection calls into `call` requests, Gateway streams into `stream-open`/`stream-next`/
|
||||
* `stream-cancel` polls, and marks each failure as a retryable carrier loss or a terminal refusal.
|
||||
*
|
||||
* It also forwards the page-origin owner routes the Relay cannot answer, so app code that calls `fetch`
|
||||
* directly reaches the owner instead of the Relay's tail. That set is explicit and read-only; every
|
||||
* other request keeps the page's own `fetch`.
|
||||
*
|
||||
* {@link installViewerCarrier} runs in the browser from its own source text, so it must reference no
|
||||
* module binding: every constant and helper it needs is declared inside its body.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-experimental-liveshare-protocol/src/viewer-carrier
|
||||
*/
|
||||
|
||||
/** Page global naming the share one viewer page presents; the Liveshare client plugin reads it. */
|
||||
export const LIVESHARE_VIEWER_GLOBAL = '__DSH_LIVESHARE_VIEWER__'
|
||||
|
||||
/** Share one served viewer page presents. */
|
||||
export interface ViewerPageTarget {
|
||||
/** Public Relay base path every read is posted under; empty at the Relay root. */
|
||||
readonly basePath: string
|
||||
readonly shareId: string
|
||||
/** Session the share presents; the page opens it once the session list carries it. */
|
||||
readonly sessionId: string
|
||||
}
|
||||
|
||||
/** Carrier inputs: the page target plus the page's own read budget. */
|
||||
export interface ViewerCarrierOptions extends ViewerPageTarget {
|
||||
/** Longest wait for one Relay answer before the read counts as lost. */
|
||||
readonly readTimeoutMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the viewer globals in the current page. Runs before the shell entry so the Connection
|
||||
* plugin finds `__DSH_TRANSPORT__` when it installs.
|
||||
* @param options - Page target and read budget.
|
||||
*/
|
||||
export function installViewerCarrier(options: ViewerCarrierOptions): void {
|
||||
// Statuses whose definition says the read may be tried again.
|
||||
const carrierStatus = new Set([408, 429, 502, 503, 504])
|
||||
// The only owner codes meaning the logical stream was lost rather than refused. Every other code — an
|
||||
// owner refusal such as `remote-access/denied`, whose `details.reason` carries the policy's own
|
||||
// `liveshare/read-only` — is terminal, so the consumer reports it once instead of re-opening.
|
||||
const carrierCodes = new Set(['stream-not-found', 'gateway/cancelled'])
|
||||
const codeShaped = /^[a-z][a-z0-9-]*(?:\/[a-z0-9-]+)*$/u
|
||||
const target = options.shareId === ''
|
||||
? `${options.basePath}/__share/rpc`
|
||||
: `${options.basePath}/__share/rpc?shareId=${encodeURIComponent(options.shareId)}`
|
||||
// Each page numbers its streams in its own far-apart window, so two pages reading one share never
|
||||
// replace each other's streams.
|
||||
let nextStreamId = (crypto.getRandomValues(new Uint32Array(1))[0] ?? 0) % 0x7fff_ff00 + 1
|
||||
|
||||
type Answer = { readonly status: number; readonly body: unknown }
|
||||
type Marked = Error & { dshRemoteStreamFailure?: object }
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
const carrier = (message: string): Error => {
|
||||
const error: Marked = new Error(message)
|
||||
error.dshRemoteStreamFailure = { kind: 'carrier' }
|
||||
return error
|
||||
}
|
||||
const refusal = (answer: Answer): { readonly code?: string; readonly message?: string } => {
|
||||
if (!isRecord(answer.body)) return {}
|
||||
const value = answer.body.error
|
||||
const nested = isRecord(value)
|
||||
const code = nested ? value.code : value
|
||||
const message = nested ? value.message : answer.body.message
|
||||
return {
|
||||
...(typeof code === 'string' && codeShaped.test(code) ? { code } : {}),
|
||||
...(typeof message === 'string' ? { message } : {}),
|
||||
}
|
||||
}
|
||||
const remote = (answer: Answer, subject: string): Error => {
|
||||
const { code = 'liveshare/read-refused', message = `${subject} refused (HTTP ${String(answer.status)})` } = refusal(answer)
|
||||
const error: Marked = new Error(message)
|
||||
error.name = code
|
||||
error.dshRemoteStreamFailure = { kind: 'remote', code, details: { status: answer.status } }
|
||||
return error
|
||||
}
|
||||
const post = async (request: object, subject: string, signal?: AbortSignal): Promise<Answer> => {
|
||||
const timeout = AbortSignal.timeout(options.readTimeoutMs)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(target, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
|
||||
})
|
||||
} catch (error) {
|
||||
// A read the browser could not send never reached the peer; the caller's own cancellation is
|
||||
// not a loss.
|
||||
if (signal?.aborted === true) throw error
|
||||
throw carrier(`${subject}: Relay unreachable`)
|
||||
}
|
||||
const body: unknown = await response.json().catch(() => undefined)
|
||||
return { status: response.status, body }
|
||||
}
|
||||
const readable = (answer: Answer, subject: string): Record<string, unknown> => {
|
||||
if (answer.status === 200) {
|
||||
if (isRecord(answer.body)) return answer.body
|
||||
throw carrier(`${subject}: Relay answered without a body`)
|
||||
}
|
||||
const code = refusal(answer).code
|
||||
if (answer.status !== 401 && answer.status !== 403
|
||||
&& (carrierStatus.has(answer.status) || (code !== undefined && carrierCodes.has(code)))) {
|
||||
throw carrier(`${subject}: ${code ?? `HTTP ${String(answer.status)}`}`)
|
||||
}
|
||||
throw remote(answer, subject)
|
||||
}
|
||||
const failure = (frame: Record<string, unknown>, subject: string): Error | undefined => {
|
||||
const value = frame.failure
|
||||
if (!isRecord(value)) return undefined
|
||||
const code = typeof value.code === 'string' ? value.code : 'liveshare/read-refused'
|
||||
const message = typeof value.message === 'string' ? value.message : subject
|
||||
if (carrierCodes.has(code)) return carrier(message)
|
||||
const error: Marked = new Error(message)
|
||||
error.name = code
|
||||
error.dshRemoteStreamFailure = { kind: 'remote', code, details: isRecord(value.details) ? value.details : {} }
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-origin owner routes the Relay serves no answer for, forwarded instead of left to the Relay.
|
||||
*
|
||||
* Each is a read the shell makes for itself and the Relay cannot serve: the owner route exists on the
|
||||
* owner's own server, so a direct `fetch` from this page would leave the Relay origin and 404 at its
|
||||
* tail. Only read-only routes belong here: a route that acts on the owner's machine stays out.
|
||||
*/
|
||||
const forwardedRoutes = ['/open-in-app/apps', '/api/present.host']
|
||||
/**
|
||||
* Owner path one page request forwards, matched by suffix because the page URL carries the Relay's own
|
||||
* base path in front of it.
|
||||
* @param pathname - Resolved request pathname.
|
||||
* @param method - Request method, already upper-cased.
|
||||
* @returns The owner path to post, or undefined when this request is not one of the forwarded routes.
|
||||
*/
|
||||
const forwardedRoute = (pathname: string, method: string): string | undefined => {
|
||||
if (method !== 'GET') return undefined
|
||||
for (const route of forwardedRoutes) {
|
||||
const at = pathname.lastIndexOf(route)
|
||||
if (at !== -1) return pathname.slice(at)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
/**
|
||||
* Read one forwarded owner route and rebuild its answer as an ordinary HTTP response.
|
||||
* @param path - Owner path to post.
|
||||
* @param init - The page request's own init, for its signal.
|
||||
* @returns The owner's status and body, JSON-encoded.
|
||||
*/
|
||||
const forwardRoute = async (path: string, init: RequestInit | undefined): Promise<Response> => {
|
||||
const body: unknown = typeof init?.body === 'string' ? JSON.parse(init.body) : {}
|
||||
const answer = await post({ op: 'call', path, body }, path, init?.signal ?? undefined)
|
||||
return new Response(JSON.stringify(answer.body ?? null), {
|
||||
status: answer.status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Installed over the page's own fetch so app code that does not go through `__DSH_TRANSPORT__` still
|
||||
// reaches the owner. Every request outside the forwarded set is handed to the fetch this replaced.
|
||||
const pageFetch = globalThis.fetch
|
||||
const carrierFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
let pathname: string | undefined
|
||||
let method = 'GET'
|
||||
try {
|
||||
const pageBase = (globalThis as { document?: { readonly baseURI: string } }).document?.baseURI
|
||||
pathname = new URL(input instanceof Request ? input.url : String(input), pageBase).pathname
|
||||
method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase()
|
||||
} catch {
|
||||
// A target this page base cannot resolve is not one of the forwarded routes.
|
||||
pathname = undefined
|
||||
}
|
||||
const path = pathname === undefined ? undefined : forwardedRoute(pathname, method)
|
||||
return path === undefined ? await pageFetch(input, init) : await forwardRoute(path, init)
|
||||
}
|
||||
globalThis.fetch = carrierFetch
|
||||
|
||||
const fetchCall = async (input: RequestInfo | URL, init: RequestInit): Promise<Response> => {
|
||||
// The Connection caller posts the document-relative route; the owner reads the `/api/...` path.
|
||||
const pageBase = (globalThis as { document?: { readonly baseURI: string } }).document?.baseURI
|
||||
const pathname = new URL(input instanceof Request ? input.url : input, pageBase).pathname
|
||||
const marker = pathname.lastIndexOf('/api/')
|
||||
const path = marker === -1 ? pathname : pathname.slice(marker)
|
||||
const body: unknown = typeof init.body === 'string' ? JSON.parse(init.body) : undefined
|
||||
const answer = await post({ op: 'call', path, body }, path, init.signal ?? undefined)
|
||||
if (answer.status === 200 && isRecord(answer.body)) return Response.json(answer.body)
|
||||
// A refusal is the owner's answer, not a transport failure: the caller receives the Connection
|
||||
// failure result the owner's code names.
|
||||
const rpcId = isRecord(body) && typeof body.rpcId === 'string' ? body.rpcId : ''
|
||||
const { code = 'liveshare/relay-error', message = `Relay answered HTTP ${String(answer.status)}` } = refusal(answer)
|
||||
return Response.json({
|
||||
type: 'server-response',
|
||||
rpcId,
|
||||
result: { ok: false, error: { code, message, details: { status: answer.status } } },
|
||||
})
|
||||
}
|
||||
|
||||
const openStream = (endpoint: string, payload: unknown, signal: AbortSignal): AsyncIterable<unknown> => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
const id = nextStreamId++
|
||||
signal.throwIfAborted()
|
||||
// Each path below sends this at most once: the abort listener only runs on abort, the final
|
||||
// block only without one, and the open paths return before either is armed.
|
||||
const cancel = (): void => {
|
||||
void post({ op: 'stream-cancel', id }, endpoint).catch(() => undefined)
|
||||
}
|
||||
let opened: Answer
|
||||
try {
|
||||
opened = await post({ op: 'stream-open', id, endpoint, payload }, endpoint, signal)
|
||||
} catch (error) {
|
||||
// An open the page abandoned may still have reached the owner, which would hold the stream.
|
||||
if (signal.aborted) cancel()
|
||||
throw error
|
||||
}
|
||||
if (signal.aborted) {
|
||||
cancel()
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
// A refused open is answered as a 200 failure frame rather than a status, and the owner registers
|
||||
// no stream for it: polling that id would only read `stream-not-found`, which looks like a lost
|
||||
// logical stream and would be re-opened forever. The frame's own code decides instead — an owner
|
||||
// refusal such as `remote-access/denied` is terminal, while a lost stream stays re-openable.
|
||||
const refusedOpen = failure(readable(opened, endpoint), endpoint)
|
||||
if (refusedOpen !== undefined) throw refusedOpen
|
||||
signal.addEventListener('abort', cancel, { once: true })
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
const frame = readable(await post({ op: 'stream-next', id }, endpoint, signal), endpoint)
|
||||
signal.throwIfAborted()
|
||||
const failed = failure(frame, endpoint)
|
||||
if (failed !== undefined) throw failed
|
||||
if (frame.end === true) return
|
||||
if (frame.pending === true) continue
|
||||
if (Array.isArray(frame.values)) {
|
||||
for (const value of frame.values) {
|
||||
signal.throwIfAborted()
|
||||
yield value
|
||||
}
|
||||
continue
|
||||
}
|
||||
yield frame.value
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', cancel)
|
||||
if (!signal.aborted) cancel()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const page = globalThis as Record<string, unknown>
|
||||
page.__DSH_LIVESHARE_VIEWER__ = { basePath: options.basePath, shareId: options.shareId, sessionId: options.sessionId }
|
||||
page.__DSH_TRANSPORT__ = { fetch: fetchCall, openStream }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the classic inline script the Relay places first in the served viewer document.
|
||||
* @param options - Page target and read budget.
|
||||
* @returns Script text: {@link installViewerCarrier}'s own source applied to `options`.
|
||||
*/
|
||||
export function viewerCarrierScript(options: ViewerCarrierOptions): string {
|
||||
// `<` is escaped so no option value can close the surrounding script element.
|
||||
const argument = JSON.stringify(options).replace(/</g, '\\u003c')
|
||||
return `(${installViewerCarrier.toString()})(${argument});`
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The conditions one viewer read can end in, and the answer that carries each.
|
||||
*
|
||||
* A page that cannot name why a read failed states one failure for every cause: a link that never
|
||||
* named a share, a share the owner stopped, an owner whose machine is asleep, and a sign-in that
|
||||
* expired all reach a reader as the same silence. These are the conditions the Relay may answer a
|
||||
* viewer with, each paired with the answer that means it and nothing else, so a page, a test, and an
|
||||
* operator all read the same name for what happened.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-experimental-liveshare-protocol/viewer-states
|
||||
*/
|
||||
|
||||
/** One condition a viewer read can end in. */
|
||||
export type ViewerState =
|
||||
/** The link names no share: it was never valid, or the owner deleted it. */
|
||||
| 'share-not-found'
|
||||
/** The owner stopped the share, which ends every viewer's access to it. */
|
||||
| 'share-stopped'
|
||||
/** The share's own deadline passed, which denies like a stop but states its own reason. */
|
||||
| 'share-expired'
|
||||
/** No live owner gateway holds the share's route, or one stopped answering a held read. */
|
||||
| 'owner-offline'
|
||||
/** The reader's sign-in expired, so the Relay cannot say who is asking. */
|
||||
| 'sign-in-expired'
|
||||
/** The reader is signed in and admitted to the deployment, but this share does not admit it. */
|
||||
| 'access-denied'
|
||||
/** The viewer page could not reach the Relay at all: the request never arrived. */
|
||||
| 'relay-unreachable'
|
||||
|
||||
/** Whether the page may ask again, or the answer is the peer's verdict. */
|
||||
export type ViewerStateReach = 'retryable' | 'terminal'
|
||||
|
||||
/**
|
||||
* One answer that carries exactly one condition.
|
||||
*
|
||||
* The two carriers are the two places a viewer page learns anything: a refused read, and the copy the
|
||||
* served page states for a failure that never reached the Relay.
|
||||
*/
|
||||
export type ViewerStateAnswer =
|
||||
| {
|
||||
readonly wire: 'http'
|
||||
readonly state: ViewerState
|
||||
/** HTTP status of the refused read. */
|
||||
readonly status: number
|
||||
/** `error` code the JSON body carries. */
|
||||
readonly code: string
|
||||
readonly reach: ViewerStateReach
|
||||
}
|
||||
| {
|
||||
readonly wire: 'page'
|
||||
readonly state: ViewerState
|
||||
/** Copy the served page states when the failure never reached the Relay. */
|
||||
readonly copy: string
|
||||
readonly reach: ViewerStateReach
|
||||
}
|
||||
|
||||
/**
|
||||
* Every answer a viewer page must tell apart, one entry per condition and carrier.
|
||||
*
|
||||
* This list is the whole of it: a page that knows these names can state what happened, and a check
|
||||
* over this list proves no two conditions share an answer.
|
||||
*/
|
||||
export const VIEWER_STATE_ANSWERS: readonly ViewerStateAnswer[] = [
|
||||
{ wire: 'http', state: 'share-not-found', status: 404, code: 'share-not-found', reach: 'terminal' },
|
||||
{ wire: 'http', state: 'share-stopped', status: 410, code: 'share-stopped', reach: 'terminal' },
|
||||
{ wire: 'http', state: 'share-expired', status: 403, code: 'share-expired', reach: 'terminal' },
|
||||
{ wire: 'http', state: 'owner-offline', status: 503, code: 'owner-offline', reach: 'retryable' },
|
||||
{ wire: 'http', state: 'sign-in-expired', status: 401, code: 'authentication-required', reach: 'terminal' },
|
||||
{ wire: 'http', state: 'access-denied', status: 403, code: 'access-denied', reach: 'terminal' },
|
||||
{ wire: 'page', state: 'relay-unreachable', copy: 'accessDeniedTitle', reach: 'retryable' },
|
||||
]
|
||||
|
||||
/**
|
||||
* The state one refused read carries.
|
||||
* @param status - HTTP status the read was refused with.
|
||||
* @param code - `error` code of the refusing body, when it carried one.
|
||||
* @returns The state that answer means, or undefined for an answer outside this vocabulary.
|
||||
*/
|
||||
export function viewerStateOfRefusal(status: number, code: string | undefined): ViewerState | undefined {
|
||||
return VIEWER_STATE_ANSWERS.find(answer =>
|
||||
answer.wire === 'http' && answer.status === status && answer.code === code)?.state
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dir": "gateway->relay",
|
||||
"note": "an answer whose status is outside the HTTP range the frame accepts",
|
||||
"frame": { "type": "share-rpc-res", "id": 7, "status": 700 }
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dir": "host->worker",
|
||||
"note": "a command this worker does not know: it must be refused rather than acted on",
|
||||
"frame": { "type": "set-share-mode", "shareId": "share-one", "mode": "view" }
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"dir": "host->worker",
|
||||
"note": "a share whose access mode is outside the closed enum fails the whole command instead of widening it",
|
||||
"frame": {
|
||||
"type": "add-share",
|
||||
"share": {
|
||||
"shareId": "share-three",
|
||||
"sessionId": "session-three",
|
||||
"sessionTitle": "Third",
|
||||
"accessMode": "selected",
|
||||
"relay": "wss://relay.example",
|
||||
"gatewayToken": "token-three",
|
||||
"ownerDisplayName": "Owner",
|
||||
"allowedSubjectIds": []
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"dir": "relay->gateway",
|
||||
"note": "a frame type this build does not know: an old gateway must refuse it instead of throwing",
|
||||
"frame": {
|
||||
"type": "share-stream-delta",
|
||||
"id": 11,
|
||||
"body": {
|
||||
"op": "stream-next"
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"status": 501,
|
||||
"body": {
|
||||
"error": "unsupported-frame"
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"dir": "relay->gateway",
|
||||
"note": "an envelope whose id cannot correlate an answer with its request",
|
||||
"frame": {
|
||||
"type": "share-rpc",
|
||||
"id": 0,
|
||||
"body": {
|
||||
"op": "call",
|
||||
"path": "/__share/context",
|
||||
"body": {}
|
||||
},
|
||||
"actor": {
|
||||
"providerId": "feishu",
|
||||
"tenantId": "tenant-a",
|
||||
"subjectId": "ou_viewer"
|
||||
}
|
||||
},
|
||||
"refusal": null
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"dir": "relay->gateway",
|
||||
"note": "a request whose caller the Relay did not assert: the owner may never act on an unattributable request",
|
||||
"frame": {
|
||||
"type": "share-rpc",
|
||||
"id": 12,
|
||||
"body": {
|
||||
"op": "call",
|
||||
"path": "/__share/context",
|
||||
"body": {}
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"error": "invalid-request"
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dir": "worker->host",
|
||||
"note": "an event this host does not know: it must be ignored rather than misread as a known event",
|
||||
"frame": { "type": "share-progress", "shareId": "share-one", "percent": 50 }
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dir": "gateway->relay",
|
||||
"note": "the owner's opaque answer to one routed request",
|
||||
"frame": { "type": "share-rpc-res", "id": 7, "status": 200, "body": { "ok": true } },
|
||||
"required": ["id", "status"],
|
||||
"optional": ["body"]
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"dir": "host->worker",
|
||||
"note": "a share as a build without the optional spec fields publishes it: no sessionMode, no allowsFork, no deadline",
|
||||
"frame": {
|
||||
"type": "add-share",
|
||||
"share": {
|
||||
"shareId": "share-two",
|
||||
"sessionId": "session-two",
|
||||
"sessionTitle": "Second",
|
||||
"accessMode": "open",
|
||||
"relay": "wss://relay.example",
|
||||
"gatewayToken": "token-two",
|
||||
"ownerDisplayName": "Owner",
|
||||
"allowedSubjectIds": []
|
||||
}
|
||||
},
|
||||
"required": ["type", "share"],
|
||||
"optional": []
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"dir": "host->worker",
|
||||
"note": "the owner host publishes one share to its gateway worker, every optional spec field present",
|
||||
"frame": {
|
||||
"type": "add-share",
|
||||
"share": {
|
||||
"shareId": "share-one",
|
||||
"sessionId": "session-one",
|
||||
"sessionTitle": "Review the migration",
|
||||
"accessMode": "approval",
|
||||
"relay": "wss://relay.example",
|
||||
"gatewayToken": "token-one",
|
||||
"ownerDisplayName": "Owner",
|
||||
"allowedSubjectIds": ["ou_viewer", "feishu:tenant-a:ou_other"],
|
||||
"sessionMode": "write",
|
||||
"allowsFork": true,
|
||||
"dshVersion": "0.2.0-rc.2",
|
||||
"ownerInstanceId": "instance-one",
|
||||
"expiresAt": "2026-10-03T00:00:00.000Z"
|
||||
}
|
||||
},
|
||||
"required": ["type", "share"],
|
||||
"optional": []
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dir": "host->worker",
|
||||
"note": "the owner host withdraws one share",
|
||||
"frame": { "type": "remove-share", "shareId": "share-one" },
|
||||
"required": ["type", "shareId"],
|
||||
"optional": []
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"dir": "host->worker",
|
||||
"note": "the owner host hands its answer back for one in-flight request, naming the share it came through",
|
||||
"frame": {
|
||||
"type": "share-rpc-response",
|
||||
"id": 7,
|
||||
"status": 200,
|
||||
"body": {
|
||||
"ok": true
|
||||
},
|
||||
"shareId": "share-one"
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"id",
|
||||
"status"
|
||||
],
|
||||
"optional": [
|
||||
"body",
|
||||
"shareId"
|
||||
]
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dir": "relay->gateway",
|
||||
"note": "the reader left, so the Relay cancels one in-flight request",
|
||||
"frame": { "type": "share-rpc-cancel", "id": 8 },
|
||||
"required": ["id"],
|
||||
"optional": []
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"dir": "relay->gateway",
|
||||
"note": "one viewer request the Relay routed, carrying the identity it asserted",
|
||||
"frame": {
|
||||
"type": "share-rpc",
|
||||
"id": 7,
|
||||
"body": { "op": "call", "path": "/__share/context", "body": {} },
|
||||
"actor": { "providerId": "feishu", "tenantId": "tenant-a", "subjectId": "ou_viewer" }
|
||||
},
|
||||
"required": ["id", "body", "actor"],
|
||||
"optional": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dir": "worker->host",
|
||||
"note": "the gateway worker is up",
|
||||
"frame": { "type": "ready" },
|
||||
"required": ["type"],
|
||||
"optional": []
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dir": "worker->host",
|
||||
"note": "one share is registered on the Relay and reachable",
|
||||
"frame": { "type": "share-connected", "shareId": "share-one" },
|
||||
"required": ["type", "shareId"],
|
||||
"optional": []
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dir": "worker->host",
|
||||
"note": "one share could not be carried, with the reason the worker read",
|
||||
"frame": { "type": "share-error", "shareId": "share-one", "message": "relay refused registration status=401" },
|
||||
"required": ["type", "shareId", "message"],
|
||||
"optional": []
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"dir": "worker->host",
|
||||
"note": "the gateway worker reports one routed request, with the identity the Relay asserted",
|
||||
"frame": {
|
||||
"type": "share-rpc-request",
|
||||
"id": 7,
|
||||
"shareId": "share-one",
|
||||
"body": { "op": "stream-open", "id": 1, "endpoint": "trajectory", "payload": {} },
|
||||
"actor": { "providerId": "feishu", "tenantId": "tenant-a", "subjectId": "ou_viewer" }
|
||||
},
|
||||
"required": ["type", "id", "shareId", "actor"],
|
||||
"optional": ["body"]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
IDENTITY_TENANT_PLACEHOLDER,
|
||||
identityFromKey,
|
||||
identityKeyOf,
|
||||
normalizeIdentity,
|
||||
requireIdentityTenant,
|
||||
type IdentityActor,
|
||||
} from '@deepseek-ai/dsh-experimental-liveshare-protocol/identity'
|
||||
|
||||
const actor: IdentityActor = {
|
||||
providerId: 'feishu',
|
||||
tenantId: 'tenant/a',
|
||||
subjectId: 'user:one',
|
||||
displayName: 'Alice',
|
||||
}
|
||||
|
||||
describe('identity keys', () => {
|
||||
it('derives one stable, encoded identity key from the provider triple', () => {
|
||||
expect(identityKeyOf(actor)).toBe('feishu:tenant%2Fa:user%3Aone')
|
||||
expect(identityKeyOf(actor)).toBe(identityKeyOf({
|
||||
providerId: actor.providerId,
|
||||
tenantId: actor.tenantId,
|
||||
subjectId: actor.subjectId,
|
||||
}))
|
||||
expect(identityFromKey(identityKeyOf(actor))).toEqual({
|
||||
providerId: actor.providerId,
|
||||
tenantId: actor.tenantId,
|
||||
subjectId: actor.subjectId,
|
||||
})
|
||||
expect(identityFromKey('feishu:only-two')).toBeUndefined()
|
||||
expect(identityFromKey('feishu:%E0%A4%A:user')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fills the placeholder tenant only for a provider that issues none', () => {
|
||||
expect(normalizeIdentity({ providerId: 'deepseek', tenantId: '', subjectId: 'user' }))
|
||||
.toEqual({ providerId: 'deepseek', tenantId: IDENTITY_TENANT_PLACEHOLDER, subjectId: 'user' })
|
||||
expect(normalizeIdentity({ providerId: 'deepseek', tenantId: 'tenant-a', subjectId: 'user' }))
|
||||
.toEqual({ providerId: 'deepseek', tenantId: 'tenant-a', subjectId: 'user' })
|
||||
expect(normalizeIdentity({ providerId: '', tenantId: '', subjectId: 'user' })).toBeUndefined()
|
||||
expect(normalizeIdentity({ providerId: 'deepseek', tenantId: '', subjectId: '' })).toBeUndefined()
|
||||
expect(normalizeIdentity({ providerId: 'deepseek', tenantId: '', subjectId: 'user' }, ''))
|
||||
.toBeUndefined()
|
||||
expect(normalizeIdentity({ providerId: 'deepseek', tenantId: '', subjectId: 'user' }, 'tenant-b'))
|
||||
.toEqual({ providerId: 'deepseek', tenantId: 'tenant-b', subjectId: 'user' })
|
||||
})
|
||||
|
||||
it('rejects any triple whose provider must issue a tenant but left it empty', () => {
|
||||
expect(requireIdentityTenant({ providerId: 'feishu', tenantId: 'tenant-a', subjectId: 'user' }))
|
||||
.toEqual({ providerId: 'feishu', tenantId: 'tenant-a', subjectId: 'user' })
|
||||
expect(requireIdentityTenant({ providerId: 'feishu', tenantId: '', subjectId: 'user' })).toBeUndefined()
|
||||
expect(requireIdentityTenant({ providerId: '', tenantId: 'tenant-a', subjectId: 'user' })).toBeUndefined()
|
||||
expect(requireIdentityTenant({ providerId: 'feishu', tenantId: 'tenant-a', subjectId: '' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseTunnelWorkerCommand, parseTunnelWorkerEvent } from '../src/tunnel-protocol.ts'
|
||||
|
||||
const registrationToken = '12345678-1234-4123-8123-123456789abc'
|
||||
const identity = { registrationToken, token: 'worker-unique-request', shareId: 'share', id: 1 }
|
||||
const frames = [
|
||||
{ type: 'share-rpc-response', ...identity, status: 200, body: { answer: true } },
|
||||
{ type: 'share-rpc-request', ...identity, body: { op: 'stream-read' }, actor: { providerId: 'mock', tenantId: '', subjectId: 'alice' } },
|
||||
{ type: 'share-rpc-cancel', ...identity },
|
||||
]
|
||||
const parse = (frame: Record<string, unknown>) => frame.type === 'share-rpc-response'
|
||||
? parseTunnelWorkerCommand(frame)
|
||||
: parseTunnelWorkerEvent(frame)
|
||||
|
||||
describe('Host/worker request tokens', () => {
|
||||
it.each(frames)('accepts $type with complete request identity', (frame) => {
|
||||
expect(parse(frame)).toEqual(frame)
|
||||
})
|
||||
|
||||
it.each(frames)('refuses $type without any correlation field', (frame) => {
|
||||
for (const field of ['registrationToken', 'token', 'id', 'shareId']) {
|
||||
expect(parse(Object.fromEntries(Object.entries(frame).filter(([key]) => key !== field)))).toBeUndefined()
|
||||
}
|
||||
for (const token of ['', null, 1]) expect(parse({ ...frame, token })).toBeUndefined()
|
||||
for (const id of [0, -1, 1.5, '1']) expect(parse({ ...frame, id })).toBeUndefined()
|
||||
expect(parse({ ...frame, shareId: '' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires response status and request actor and body', () => {
|
||||
for (const status of [99, 600, 200.5]) expect(parse({ ...frames[0], status })).toBeUndefined()
|
||||
expect(parse({ ...frames[1], actor: undefined })).toBeUndefined()
|
||||
expect(parse({ ...frames[1], body: undefined })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
const share = { shareId: 'share', sessionId: 'session', sessionTitle: 'Session', accessMode: 'open',
|
||||
relay: 'http://relay.invalid', gatewayToken: 'gateway', ownerDisplayName: 'Owner', allowedSubjectIds: [] }
|
||||
const registrationFrames = [
|
||||
{ type: 'add-share', registrationToken, share },
|
||||
{ type: 'share-connected', registrationToken, shareId: 'share' },
|
||||
{ type: 'share-closed', registrationToken, shareId: 'share' },
|
||||
{ type: 'share-error', registrationToken, shareId: 'share', message: 'offline' },
|
||||
{ type: 'share-access-state', registrationToken, shareId: 'share', version: 1, accessMode: 'open',
|
||||
allowedSubjectIds: [], sessionMode: 'view' },
|
||||
]
|
||||
|
||||
describe('Host/worker registration identity', () => {
|
||||
it('rejects an empty subject in a share registration without rejecting an empty ACL', () => {
|
||||
expect(parseTunnelWorkerCommand({ type: 'add-share', registrationToken, share })).toBeDefined()
|
||||
expect(parseTunnelWorkerCommand({ type: 'add-share', registrationToken,
|
||||
share: { ...share, allowedSubjectIds: [''] } })).toBeUndefined()
|
||||
})
|
||||
const decode = (frame: Record<string, unknown>) => (frame.type === 'add-share' || frame.type === 'share-rpc-response')
|
||||
? parseTunnelWorkerCommand(frame) : parseTunnelWorkerEvent(frame)
|
||||
it.each(registrationFrames)('accepts $type with its original registration UUID', (frame) => {
|
||||
expect(decode(frame)).toEqual(frame)
|
||||
})
|
||||
it.each([...registrationFrames, ...frames])('rejects $type without a valid registration UUID', (frame) => {
|
||||
for (const token of [undefined, '', 'not-a-uuid', 1, null,
|
||||
'12345678-1234-1123-8123-123456789abc', '12345678-1234-4123-0123-123456789abc']) {
|
||||
expect(decode({ ...frame, registrationToken: token })).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
/** Relay wire v1 remains compatible; same-package Host/worker IPC requires request tokens. */
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseGatewayFrame,
|
||||
parseRelayFrame,
|
||||
parseTunnelWorkerCommand,
|
||||
parseTunnelWorkerEvent,
|
||||
rejectedCommandShareId,
|
||||
unsupportedFrameRefusal,
|
||||
} from '@deepseek-ai/dsh-experimental-liveshare-protocol/protocol'
|
||||
|
||||
/** Which two endpoints exchanged a recorded frame, which also names the codec that reads it. */
|
||||
type Direction = 'relay->gateway' | 'gateway->relay' | 'host->worker' | 'worker->host'
|
||||
|
||||
/** One frame recorded from a released build, with the fields that version may omit. */
|
||||
interface RecordedFrame {
|
||||
readonly dir: Direction
|
||||
readonly note: string
|
||||
readonly frame: Record<string, unknown>
|
||||
/** Raw socket text, when the recording pins bytes rather than the frame's fields. */
|
||||
readonly text?: string
|
||||
/** Top-level fields this build always sent. */
|
||||
readonly required: readonly string[]
|
||||
/** Top-level fields this build sent only when it had something to say. */
|
||||
readonly optional?: readonly string[]
|
||||
}
|
||||
|
||||
/** One frame this build must refuse: a later release's addition, or a value the vocabulary rejects. */
|
||||
interface RefusedFrame {
|
||||
readonly dir: Direction
|
||||
readonly note: string
|
||||
readonly frame: Record<string, unknown>
|
||||
readonly text?: string
|
||||
/**
|
||||
* The refusal this build answers with, or null when the frame states nothing to answer: an
|
||||
* unreadable envelope, or a frame that answers another rather than asking for one.
|
||||
*/
|
||||
readonly refusal?: { readonly status: number; readonly body: { readonly error: string } } | null
|
||||
}
|
||||
|
||||
const FRAMES_DIR = join(import.meta.dirname, 'frames')
|
||||
|
||||
/**
|
||||
* Read every recorded frame in one directory.
|
||||
* @param directory - Directory under `frames/`.
|
||||
* @returns Recordings in file-name order.
|
||||
*/
|
||||
function readFrames<T>(directory: string): T[] {
|
||||
return readdirSync(join(FRAMES_DIR, directory))
|
||||
.filter(name => name.endsWith('.json'))
|
||||
.sort()
|
||||
.map(name => JSON.parse(readFileSync(join(FRAMES_DIR, directory, name), 'utf8')) as T)
|
||||
}
|
||||
|
||||
/**
|
||||
* The socket text a recording stands for.
|
||||
* @param recorded - Recording holding either raw text or the frame it carried.
|
||||
* @returns The recorded text.
|
||||
*/
|
||||
function wireText(recorded: { readonly frame: Record<string, unknown>; readonly text?: string }): string {
|
||||
return recorded.text ?? JSON.stringify(recorded.frame)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one recorded frame through the codec its direction names.
|
||||
* @param direction - Endpoints that exchanged the frame.
|
||||
* @param text - Raw socket text the recording carries.
|
||||
* @returns The parsed frame, or undefined when this build refuses it.
|
||||
*/
|
||||
function parseFrame(direction: Direction, text: string): unknown {
|
||||
switch (direction) {
|
||||
case 'relay->gateway':
|
||||
return parseRelayFrame(text)
|
||||
case 'gateway->relay':
|
||||
return parseGatewayFrame(text)
|
||||
// The worker's two directions cross a process boundary as values, not socket text, so the
|
||||
// codecs read the decoded value and the envelope is the caller's JSON parse.
|
||||
case 'host->worker':
|
||||
return parseTunnelWorkerCommand(JSON.parse(text))
|
||||
case 'worker->host':
|
||||
return parseTunnelWorkerEvent(JSON.parse(text))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One recorded frame without one top-level field.
|
||||
* @param frame - Recorded frame.
|
||||
* @param field - Field to remove.
|
||||
* @returns A copy without that field.
|
||||
*/
|
||||
function withoutField(frame: Record<string, unknown>, field: string): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(frame).filter(([key]) => key !== field))
|
||||
}
|
||||
|
||||
describe('recorded frames', () => {
|
||||
it('reads released Relay frames and rejects superseded same-package IPC identity', () => {
|
||||
for (const recorded of readFrames<RecordedFrame>('v1')) {
|
||||
const legacyIdentity = (recorded.dir === 'host->worker' || recorded.dir === 'worker->host')
|
||||
&& recorded.frame.type !== 'ready' && recorded.frame.type !== 'remove-share'
|
||||
if (legacyIdentity) expect(parseFrame(recorded.dir, wireText(recorded)), recorded.note).toBeUndefined()
|
||||
else expect(parseFrame(recorded.dir, wireText(recorded)), recorded.note).toEqual(recorded.frame)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps every recorded optional field optional', () => {
|
||||
for (const recorded of readFrames<RecordedFrame>('v1')) {
|
||||
if (parseFrame(recorded.dir, wireText(recorded)) === undefined) continue
|
||||
for (const field of recorded.optional ?? []) {
|
||||
const withoutOptional = { ...recorded, frame: withoutField(recorded.frame, field) }
|
||||
expect(parseFrame(recorded.dir, wireText(withoutOptional)), `${recorded.note} (without ${field})`)
|
||||
.toEqual(withoutField(recorded.frame, field))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a frame this build does not know instead of throwing', () => {
|
||||
for (const recorded of readFrames<RefusedFrame>('unknown')) {
|
||||
expect(() => parseFrame(recorded.dir, wireText(recorded)), recorded.note).not.toThrow()
|
||||
expect(parseFrame(recorded.dir, wireText(recorded)), recorded.note).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the fields that carry a request identity required', () => {
|
||||
// `id` correlates an answer with its request and `actor` is the caller the owner may act on:
|
||||
// widening either one moves authority rather than convenience, so both stay required without
|
||||
// being recorded as optional elsewhere.
|
||||
const request = readFrames<RecordedFrame>('v1')
|
||||
.find(recorded => recorded.dir === 'relay->gateway' && recorded.frame.type === 'share-rpc')
|
||||
if (request === undefined) throw new Error('the relay->gateway share-rpc recording is missing')
|
||||
for (const field of ['id', 'actor']) {
|
||||
expect(parseFrame(request.dir, wireText({ ...request, frame: withoutField(request.frame, field) })), field)
|
||||
.toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('answers a frame it cannot serve instead of leaving the peer to wait', () => {
|
||||
for (const recorded of readFrames<RefusedFrame>('unknown')) {
|
||||
if (recorded.dir !== 'relay->gateway') continue
|
||||
const refusal = unsupportedFrameRefusal(wireText(recorded))
|
||||
if (recorded.refusal === null || recorded.refusal === undefined) {
|
||||
expect(refusal, recorded.note).toBeUndefined()
|
||||
continue
|
||||
}
|
||||
expect(refusal, recorded.note).toMatchObject({ type: 'share-rpc-res', ...recorded.refusal })
|
||||
}
|
||||
})
|
||||
|
||||
it('answers nothing for a frame it does serve', () => {
|
||||
for (const recorded of readFrames<RecordedFrame>('v1')) {
|
||||
if (recorded.dir !== 'relay->gateway') continue
|
||||
expect(unsupportedFrameRefusal(wireText(recorded)), recorded.note).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('names the share a refused owner command was about', () => {
|
||||
expect(rejectedCommandShareId({ type: 'set-share-mode', shareId: 'share-one' })).toBe('share-one')
|
||||
expect(rejectedCommandShareId({ type: 'add-share', share: { shareId: 'share-two' } })).toBe('share-two')
|
||||
expect(rejectedCommandShareId({ type: 'set-share-mode' })).toBeUndefined()
|
||||
expect(rejectedCommandShareId({ type: 'remove-share', shareId: '' })).toBeUndefined()
|
||||
expect(rejectedCommandShareId({ type: 'add-share', share: { sessionId: 'session-one' } })).toBeUndefined()
|
||||
expect(rejectedCommandShareId(null)).toBeUndefined()
|
||||
expect(rejectedCommandShareId('share-one')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses malformed text without throwing', () => {
|
||||
const malformed = ['', '{', 'null', '[]', '"share-rpc"', '{"type":"share-rpc"}', '{"id":1}', '{"type":"share-rpc","id":"7"}']
|
||||
for (const text of malformed) {
|
||||
expect(() => parseRelayFrame(text), text).not.toThrow()
|
||||
expect(parseRelayFrame(text), text).toBeUndefined()
|
||||
expect(() => parseGatewayFrame(text), text).not.toThrow()
|
||||
expect(parseGatewayFrame(text), text).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Lifecycle authority frames are id-less and validated independently from RPC envelopes. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseGatewayFrame, parseRelayFrame, parseTunnelWorkerEvent, LIVESHARE_CAPABILITIES } from '../src/tunnel-protocol.ts'
|
||||
|
||||
const registrationToken = '12345678-1234-4123-8123-123456789abc'
|
||||
const state = { type: 'share-access-state', version: 12, accessMode: 'approval',
|
||||
allowedSubjectIds: ['identity-key'], sessionMode: 'write', expiresAt: '2026-10-09T00:00:00Z' }
|
||||
|
||||
describe('Relay authority snapshot frame', () => {
|
||||
it('accepts lifecycle wire and IPC snapshots without an RPC id', () => {
|
||||
expect(parseRelayFrame(JSON.stringify(state))).toEqual(state)
|
||||
expect(parseTunnelWorkerEvent({ ...state, registrationToken, shareId: 'share' })).toEqual({ ...state, registrationToken, shareId: 'share' })
|
||||
expect(parseGatewayFrame(JSON.stringify(state))).toBeUndefined()
|
||||
expect(LIVESHARE_CAPABILITIES).toContain('share-access-state')
|
||||
})
|
||||
it.each([
|
||||
{ version: -1 }, { version: 0.5 }, { accessMode: 'selected' }, { sessionMode: 'future' },
|
||||
{ allowedSubjectIds: [1] }, { allowedSubjectIds: [''] }, { expiresAt: 'not-a-time' },
|
||||
])('refuses malformed authority fields %j', (fields) => {
|
||||
expect(parseRelayFrame(JSON.stringify({ ...state, ...fields }))).toBeUndefined()
|
||||
expect(parseTunnelWorkerEvent({ ...state, registrationToken, shareId: 'share', ...fields })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,556 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The viewer page carrier: where it posts each read, how it numbers streams, and how it classifies what
|
||||
* went wrong as a retryable carrier loss or the peer's terminal refusal.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installViewerCarrier, viewerCarrierScript, type ViewerCarrierOptions } from '../src/viewer-carrier.ts'
|
||||
|
||||
/** Page globals the carrier installs. */
|
||||
interface CarrierGlobals {
|
||||
__DSH_LIVESHARE_VIEWER__?: { readonly basePath: string; readonly shareId: string; readonly sessionId: string }
|
||||
__DSH_TRANSPORT__?: {
|
||||
fetch(input: RequestInfo | URL, init: RequestInit): Promise<Response>
|
||||
openStream(endpoint: string, payload: unknown, signal: AbortSignal): AsyncIterable<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
interface MarkedFailure {
|
||||
readonly dshRemoteStreamFailure?: { readonly kind: 'carrier' | 'remote'; readonly code?: string; readonly details?: object }
|
||||
}
|
||||
|
||||
/** One request the carrier posted. */
|
||||
interface Posted {
|
||||
readonly url: string
|
||||
readonly op: string
|
||||
readonly id?: number
|
||||
readonly endpoint?: string
|
||||
readonly payload?: unknown
|
||||
readonly path?: string
|
||||
readonly body?: unknown
|
||||
}
|
||||
|
||||
const OPTIONS: ViewerCarrierOptions = { basePath: '', shareId: 'share-1', sessionId: 'fx-alpha', readTimeoutMs: 30_000 }
|
||||
const page = globalThis as CarrierGlobals
|
||||
const pristineFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
// The carrier replaces `globalThis.fetch` directly, so a test that installed it without a stub
|
||||
// would otherwise leave its wrapper on every later test.
|
||||
globalThis.fetch = pristineFetch
|
||||
delete page.__DSH_LIVESHARE_VIEWER__
|
||||
delete page.__DSH_TRANSPORT__
|
||||
})
|
||||
|
||||
/**
|
||||
* Install the carrier over a stubbed Relay.
|
||||
* @param answer - Answers one posted request; a rejection is a read the browser could not send.
|
||||
* @param options - Carrier options.
|
||||
* @returns The installed transport and every posted request.
|
||||
*/
|
||||
function carrier(
|
||||
answer: (request: Posted, index: number, init: RequestInit) => Promise<Response> | Response,
|
||||
options: ViewerCarrierOptions = OPTIONS,
|
||||
) {
|
||||
const posted: Posted[] = []
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
if (typeof init?.body !== 'string') throw new Error('carrier posted a non-string body')
|
||||
const request = { url: String(input), ...JSON.parse(init.body) as Omit<Posted, 'url'> }
|
||||
posted.push(request)
|
||||
return await answer(request, posted.length - 1, init)
|
||||
}))
|
||||
installViewerCarrier(options)
|
||||
const transport = page.__DSH_TRANSPORT__
|
||||
if (transport === undefined) throw new Error('carrier installed no transport')
|
||||
return { transport, posted }
|
||||
}
|
||||
|
||||
/** A JSON Relay answer. */
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(body === undefined ? '' : JSON.stringify(body), {
|
||||
status, headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one stream against Relay answers given in order; the last one repeats.
|
||||
* @param answers - Answers, in order.
|
||||
* @returns Values the consumer saw, the error it saw, and every posted request.
|
||||
*/
|
||||
async function readStream(answers: readonly { readonly status: number; readonly body: unknown }[]) {
|
||||
const { transport, posted } = carrier((_request, index) => {
|
||||
const answer = answers[Math.min(index, answers.length - 1)] ?? { status: 200, body: { end: true } }
|
||||
return json(answer.body, answer.status)
|
||||
})
|
||||
const values: unknown[] = []
|
||||
try {
|
||||
for await (const value of transport.openStream('session/follow', { address: 'x' }, new AbortController().signal)) {
|
||||
values.push(value)
|
||||
}
|
||||
return { values, error: undefined, posted }
|
||||
} catch (error) {
|
||||
return { values, error, posted }
|
||||
}
|
||||
}
|
||||
|
||||
function markerOf(error: unknown): MarkedFailure['dshRemoteStreamFailure'] {
|
||||
return (error as MarkedFailure | undefined)?.dshRemoteStreamFailure
|
||||
}
|
||||
|
||||
const OPENED = { status: 200, body: { opened: true } }
|
||||
|
||||
describe('viewer carrier installation', () => {
|
||||
it('names the share the page presents and posts every read to that share', async () => {
|
||||
const { posted } = carrier(() => json({ end: true }), { ...OPTIONS, basePath: '/relay', shareId: 'share 1/x' })
|
||||
expect(page.__DSH_LIVESHARE_VIEWER__).toEqual({ basePath: '/relay', shareId: 'share 1/x', sessionId: 'fx-alpha' })
|
||||
for await (const value of page.__DSH_TRANSPORT__!.openStream('session/follow', {}, new AbortController().signal)) void value
|
||||
expect(posted[0]?.url).toBe('/relay/__share/rpc?shareId=share%201%2Fx')
|
||||
})
|
||||
|
||||
it('posts to the bare read channel when the page names no share', async () => {
|
||||
const { transport, posted } = carrier(() => json({ end: true }), { ...OPTIONS, shareId: '' })
|
||||
for await (const value of transport.openStream('session/follow', {}, new AbortController().signal)) void value
|
||||
expect(posted[0]?.url).toBe('/__share/rpc')
|
||||
})
|
||||
|
||||
it('installs both globals from its own script text and escapes markup in option values', () => {
|
||||
const script = viewerCarrierScript({ ...OPTIONS, shareId: '</script><b>' })
|
||||
expect(script).not.toContain('</script>')
|
||||
expect(script).toContain('\\u003c/script>')
|
||||
;(0, eval)(script)
|
||||
expect(page.__DSH_LIVESHARE_VIEWER__).toEqual({ basePath: '', shareId: '</script><b>', sessionId: 'fx-alpha' })
|
||||
expect(typeof page.__DSH_TRANSPORT__?.fetch).toBe('function')
|
||||
expect(typeof page.__DSH_TRANSPORT__?.openStream).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewer carrier stream ids', () => {
|
||||
/**
|
||||
* Open `count` streams on one page and report the ids they opened under.
|
||||
* @param random - What the page's id seed draw writes, or undefined for the environment's own.
|
||||
* @param count - Streams to open.
|
||||
* @returns The `stream-open` ids in order.
|
||||
*/
|
||||
async function pageStreamIds(random: ((array: Uint32Array) => unknown) | undefined, count: number): Promise<number[]> {
|
||||
if (random !== undefined) vi.stubGlobal('crypto', { getRandomValues: random })
|
||||
const { transport, posted } = carrier(() => json({ end: true }))
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
for await (const value of transport.openStream('session/follow', {}, new AbortController().signal)) void value
|
||||
}
|
||||
return posted.filter(request => request.op === 'stream-open').map(request => request.id ?? -1)
|
||||
}
|
||||
const seeded = (seed: number) => (array: Uint32Array): Uint32Array => {
|
||||
array[0] = seed
|
||||
return array
|
||||
}
|
||||
|
||||
it('numbers a page from its own seeded id, not from one', async () => {
|
||||
expect(await pageStreamIds(seeded(0x1000_0000), 2)).toEqual([0x1000_0001, 0x1000_0002])
|
||||
})
|
||||
|
||||
it('gives two pages of one share disjoint id windows', async () => {
|
||||
const first = await pageStreamIds(seeded(0x1000_0000), 3)
|
||||
const second = await pageStreamIds(seeded(0x2000_0000), 3)
|
||||
expect(second.filter(id => first.includes(id))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps every id inside the positive window', async () => {
|
||||
expect(await pageStreamIds(seeded(0xffff_ffff), 1)).toEqual([0xffff_ffff % 0x7fff_ff00 + 1])
|
||||
expect(await pageStreamIds(() => [], 1)).toEqual([1])
|
||||
})
|
||||
|
||||
it('seeds the id space from the page environment', async () => {
|
||||
const ids = await pageStreamIds(undefined, 2)
|
||||
expect(ids[0]).toBeGreaterThanOrEqual(1)
|
||||
expect(ids[1]).toBe((ids[0] ?? 0) + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewer carrier streams', () => {
|
||||
it('opens, polls through pending answers, yields batches and single values, and stops at the end', async () => {
|
||||
const { values, error, posted } = await readStream([
|
||||
OPENED,
|
||||
{ status: 200, body: { pending: true } },
|
||||
{ status: 200, body: { values: [1, 2] } },
|
||||
{ status: 200, body: { value: 3 } },
|
||||
{ status: 200, body: { end: true } },
|
||||
])
|
||||
expect(error).toBeUndefined()
|
||||
expect(values).toEqual([1, 2, 3])
|
||||
expect(posted.map(request => request.op)).toEqual([
|
||||
'stream-open', 'stream-next', 'stream-next', 'stream-next', 'stream-next', 'stream-cancel',
|
||||
])
|
||||
expect(posted[0]).toMatchObject({ endpoint: 'session/follow', payload: { address: 'x' } })
|
||||
})
|
||||
|
||||
it('reports a released stream as re-openable and stops asking for its id', async () => {
|
||||
const { error, posted } = await readStream([OPENED, { status: 404, body: { error: 'stream-not-found' } }])
|
||||
expect(markerOf(error)).toEqual({ kind: 'carrier' })
|
||||
expect(posted.map(request => request.op)).toEqual(['stream-open', 'stream-next', 'stream-cancel'])
|
||||
})
|
||||
|
||||
it('reports a stream the shared vocabulary cancelled as re-openable', async () => {
|
||||
const { error } = await readStream([
|
||||
OPENED, { status: 200, body: { failure: { code: 'gateway/cancelled', message: 'owner gateway replaced' } } },
|
||||
])
|
||||
expect(markerOf(error)).toEqual({ kind: 'carrier' })
|
||||
expect((error as Error).message).toBe('owner gateway replaced')
|
||||
})
|
||||
|
||||
it('reports a Relay that cannot answer or is failing as re-openable', async () => {
|
||||
for (const status of [408, 429, 502, 503, 504]) {
|
||||
const answer = await readStream([{ status, body: { error: 'gateway unavailable' } }])
|
||||
expect([status, markerOf(answer.error)]).toEqual([status, { kind: 'carrier' }])
|
||||
}
|
||||
const silent = await readStream([OPENED, { status: 503, body: undefined }])
|
||||
expect(markerOf(silent.error)).toEqual({ kind: 'carrier' })
|
||||
})
|
||||
|
||||
it('does not assume a plain server error is transient', async () => {
|
||||
const failed = await readStream([{ status: 500, body: { error: 'gateway/internal' } }])
|
||||
expect(markerOf(failed.error)).toEqual({ kind: 'remote', code: 'gateway/internal', details: { status: 500 } })
|
||||
const unshaped = await readStream([{ status: 500, body: { error: 'Internal failure.' } }])
|
||||
expect(markerOf(unshaped.error)).toEqual({ kind: 'remote', code: 'liveshare/read-refused', details: { status: 500 } })
|
||||
expect((unshaped.error as Error).message).toBe('session/follow refused (HTTP 500)')
|
||||
})
|
||||
|
||||
it('never yields an event a peer never answered', async () => {
|
||||
const { transport, posted } = carrier((_request, index) => new Response(index === 0 ? '{"opened":true}' : 'not json'))
|
||||
const values: unknown[] = []
|
||||
let error: unknown
|
||||
try {
|
||||
for await (const value of transport.openStream('session/follow', {}, new AbortController().signal)) values.push(value)
|
||||
} catch (caught) {
|
||||
error = caught
|
||||
}
|
||||
expect(values).toEqual([])
|
||||
expect(markerOf(error)).toEqual({ kind: 'carrier' })
|
||||
expect(posted.map(request => request.op)).toEqual(['stream-open', 'stream-next', 'stream-cancel'])
|
||||
const empty = await readStream([{ status: 200, body: [] }])
|
||||
expect(markerOf(empty.error)).toEqual({ kind: 'carrier' })
|
||||
expect(empty.posted.map(request => request.op)).toEqual(['stream-open'])
|
||||
})
|
||||
|
||||
it('keeps an authentication refusal terminal whatever code its body carries', async () => {
|
||||
for (const status of [401, 403]) {
|
||||
for (const code of ['stream-not-found', 'gateway/cancelled']) {
|
||||
const refused = await readStream([{ status, body: { error: code } }])
|
||||
expect([status, code, markerOf(refused.error)]).toEqual([status, code, { kind: 'remote', code, details: { status } }])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('states a refusal as terminal, in either body shape, so the page cannot reconnect around it', async () => {
|
||||
const denied = await readStream([{ status: 403, body: { error: 'share-not-found' } }])
|
||||
expect(markerOf(denied.error)).toEqual({ kind: 'remote', code: 'share-not-found', details: { status: 403 } })
|
||||
expect((denied.error as Error).name).toBe('share-not-found')
|
||||
const nested = await readStream([{ status: 403, body: { error: { code: 'share-stopped', message: 'stopped' } } }])
|
||||
expect(markerOf(nested.error)).toEqual({ kind: 'remote', code: 'share-stopped', details: { status: 403 } })
|
||||
expect((nested.error as Error).message).toBe('stopped')
|
||||
const unauthorized = await readStream([{ status: 401, body: { error: 'authentication required' } }])
|
||||
expect(markerOf(unauthorized.error)).toEqual({ kind: 'remote', code: 'liveshare/read-refused', details: { status: 401 } })
|
||||
const bare = await readStream([{ status: 401, body: 'denied' }])
|
||||
expect(markerOf(bare.error)).toEqual({ kind: 'remote', code: 'liveshare/read-refused', details: { status: 401 } })
|
||||
})
|
||||
|
||||
it('keeps the code and details a refused stream frame names, and names a refusal that names none', async () => {
|
||||
const refused = await readStream([
|
||||
OPENED, { status: 200, body: { failure: { code: 'gateway/forbidden', message: 'not authorized', details: { a: 1 } } } },
|
||||
])
|
||||
expect(markerOf(refused.error)).toEqual({ kind: 'remote', code: 'gateway/forbidden', details: { a: 1 } })
|
||||
expect((refused.error as Error).name).toBe('gateway/forbidden')
|
||||
const unnamed = await readStream([OPENED, { status: 200, body: { failure: {} } }])
|
||||
expect(markerOf(unnamed.error)).toEqual({ kind: 'remote', code: 'liveshare/read-refused', details: {} })
|
||||
expect((unnamed.error as Error).message).toBe('session/follow')
|
||||
})
|
||||
|
||||
it('reports a refused stream open once, classified by the owner code rather than as a lost stream', async () => {
|
||||
// The owner answers a refused open as a 200 failure frame and registers no stream for that id, so the
|
||||
// carrier must surface the refusal instead of polling an id that would read `stream-not-found`.
|
||||
const refused = () => json({ failure: {
|
||||
code: 'remote-access/denied',
|
||||
message: 'remote access: job/list: access denied (liveshare/read-only)',
|
||||
details: { action: 'job/list', reason: 'liveshare/read-only' },
|
||||
} })
|
||||
const { transport, posted } = carrier(refused)
|
||||
let error: unknown
|
||||
try {
|
||||
for await (const value of transport.openStream('job/list', {}, new AbortController().signal)) void value
|
||||
} catch (caught) {
|
||||
error = caught
|
||||
}
|
||||
expect(markerOf(error)).toEqual({
|
||||
kind: 'remote',
|
||||
code: 'remote-access/denied',
|
||||
details: { action: 'job/list', reason: 'liveshare/read-only' },
|
||||
})
|
||||
// One attempt: no `stream-next` poll, and no re-open a supervisor could drive from a carrier marker.
|
||||
expect(posted.map(request => request.op)).toEqual(['stream-open'])
|
||||
})
|
||||
|
||||
it('classifies an open-time failure frame by its exact owner code', async () => {
|
||||
/**
|
||||
* Open one stream whose open is refused with one code.
|
||||
* @param code - owner code the failure frame carries.
|
||||
* @returns the failure marker the consumer saw and the ops the carrier posted.
|
||||
*/
|
||||
const refusedWith = async (code: string) => {
|
||||
const { transport, posted } = carrier(() => json({ failure: { code, message: code } }))
|
||||
try {
|
||||
for await (const value of transport.openStream('job/list', {}, new AbortController().signal)) void value
|
||||
return { marker: undefined, ops: posted.map(request => request.op) }
|
||||
} catch (error) {
|
||||
return { marker: markerOf(error), ops: posted.map(request => request.op) }
|
||||
}
|
||||
}
|
||||
// Lost-stream codes keep the supervisor's reopen rule; every other code is an owner refusal, so the
|
||||
// carrier posts nothing after the open and the failure is reported once.
|
||||
expect((await refusedWith('stream-not-found')).marker).toEqual({ kind: 'carrier' })
|
||||
expect((await refusedWith('gateway/cancelled')).marker).toEqual({ kind: 'carrier' })
|
||||
expect(await refusedWith('remote-access/denied')).toEqual({
|
||||
marker: { kind: 'remote', code: 'remote-access/denied', details: {} },
|
||||
ops: ['stream-open'],
|
||||
})
|
||||
expect(await refusedWith('liveshare/read-only')).toEqual({
|
||||
marker: { kind: 'remote', code: 'liveshare/read-only', details: {} },
|
||||
ops: ['stream-open'],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a refused, revoked, or expired read terminal', async () => {
|
||||
for (const [status, code] of [
|
||||
[401, 'viewer-not-authorized'], [403, 'share-stopped'], [403, 'share-revoked'], [403, 'share-expired'],
|
||||
] as const) {
|
||||
const refused = await readStream([{ status, body: { error: code } }])
|
||||
expect([status, code, markerOf(refused.error)]).toEqual([status, code, { kind: 'remote', code, details: { status } }])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewer carrier read losses and cancellation', () => {
|
||||
it('reports a read the browser could not send, or its own expired budget, as re-openable', async () => {
|
||||
for (const failure of [
|
||||
new TypeError('Failed to fetch'),
|
||||
new DOMException('The operation was aborted due to timeout', 'TimeoutError'),
|
||||
]) {
|
||||
const { transport, posted } = carrier(() => Promise.reject(failure))
|
||||
let error: unknown
|
||||
try {
|
||||
for await (const value of transport.openStream('session/follow', {}, new AbortController().signal)) void value
|
||||
} catch (caught) {
|
||||
error = caught
|
||||
}
|
||||
expect(markerOf(error)).toEqual({ kind: 'carrier' })
|
||||
expect(posted.map(request => request.op)).toEqual(['stream-open'])
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a read the network dropped after the stream opened as re-openable, and swallows its cancel', async () => {
|
||||
const { transport, posted } = carrier((_request, index) => index === 0
|
||||
? json({ opened: true })
|
||||
: Promise.reject(new TypeError('NetworkError when attempting to fetch resource.')))
|
||||
let error: unknown
|
||||
try {
|
||||
for await (const value of transport.openStream('session/follow', {}, new AbortController().signal)) void value
|
||||
} catch (caught) {
|
||||
error = caught
|
||||
}
|
||||
expect(markerOf(error)).toEqual({ kind: 'carrier' })
|
||||
await vi.waitFor(() => {
|
||||
expect(posted.map(request => request.op)).toEqual(['stream-open', 'stream-next', 'stream-cancel'])
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses to open a stream whose consumer already left', async () => {
|
||||
const { transport, posted } = carrier(() => json({ opened: true }))
|
||||
const left = new AbortController()
|
||||
left.abort(new Error('left'))
|
||||
await expect(transport.openStream('session/follow', {}, left.signal)[Symbol.asyncIterator]().next())
|
||||
.rejects.toThrow('left')
|
||||
expect(posted).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['stream-open', 'stream-next'] as const)('ignores a late %s answer after cancellation and cancels once', async (pendingOp) => {
|
||||
const requested = Promise.withResolvers<undefined>()
|
||||
const answer = Promise.withResolvers<Response>()
|
||||
const { transport, posted } = carrier((request) => {
|
||||
if (request.op === pendingOp) {
|
||||
requested.resolve(undefined)
|
||||
return answer.promise
|
||||
}
|
||||
return json({ opened: true })
|
||||
})
|
||||
const leaving = new AbortController()
|
||||
const reading = transport.openStream('session/control', {}, leaving.signal)[Symbol.asyncIterator]().next()
|
||||
await requested.promise
|
||||
const failure = new Error('viewer left the page')
|
||||
leaving.abort(failure)
|
||||
answer.resolve(json(pendingOp === 'stream-open' ? { opened: true } : { value: 'late' }))
|
||||
await expect(reading).rejects.toBe(failure)
|
||||
await vi.waitFor(() => { expect(posted.filter(request => request.op === 'stream-cancel')).toHaveLength(1) })
|
||||
})
|
||||
|
||||
it('stops a received batch at the consumer\u2019s cancellation', async () => {
|
||||
const { transport } = carrier(request => json(request.op === 'stream-next' ? { values: ['a', 'b'] } : { opened: true }))
|
||||
const leaving = new AbortController()
|
||||
const iterator = transport.openStream('session/control', {}, leaving.signal)[Symbol.asyncIterator]()
|
||||
expect(await iterator.next()).toEqual({ done: false, value: 'a' })
|
||||
const failure = new Error('viewer switched Session')
|
||||
leaving.abort(failure)
|
||||
await expect(iterator.next()).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('cancels the stream when its consumer stops reading early', async () => {
|
||||
const { transport, posted } = carrier(request => json(request.op === 'stream-next' ? { value: 'a' } : { opened: true }))
|
||||
for await (const value of transport.openStream('session/control', {}, new AbortController().signal)) {
|
||||
expect(value).toBe('a')
|
||||
break
|
||||
}
|
||||
expect(posted.map(request => request.op)).toEqual(['stream-open', 'stream-next', 'stream-cancel'])
|
||||
})
|
||||
|
||||
it('keeps the caller\u2019s own cancellation out of the reconnect path and releases the abandoned open', async () => {
|
||||
const { transport, posted } = carrier((_request, _index, init) => new Promise<Response>((_resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () => { reject(new Error('aborted by the caller')) }, { once: true })
|
||||
}))
|
||||
const leaving = new AbortController()
|
||||
const reading = (async (): Promise<unknown> => {
|
||||
try {
|
||||
for await (const value of transport.openStream('session/follow', {}, leaving.signal)) void value
|
||||
return undefined
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
})()
|
||||
await Promise.resolve()
|
||||
leaving.abort(new Error('viewer left the page'))
|
||||
const error = await reading
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(markerOf(error)).toBeUndefined()
|
||||
// The open may already have reached the owner, so the page releases that stream id.
|
||||
expect(posted.map(request => request.op)).toEqual(['stream-open', 'stream-cancel'])
|
||||
expect(posted[1]?.id).toBe(posted[0]?.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewer carrier unary calls', () => {
|
||||
/**
|
||||
* Post one Connection call through the carrier.
|
||||
* @param answer - The Relay's answer.
|
||||
* @param input - Route the Connection caller posts.
|
||||
* @param body - Request body; `null` posts none.
|
||||
* @returns The envelope the caller parses, and the request the carrier posted.
|
||||
*/
|
||||
async function call(answer: Response, input: string | URL | Request = 'api/session/prompt', body: unknown = {
|
||||
type: 'client-request', rpcId: 'rpc-1', method: 'session/prompt', payload: { args: {} },
|
||||
}) {
|
||||
const { transport, posted } = carrier(() => answer)
|
||||
const response = await transport.fetch(input, {
|
||||
method: 'POST', ...(body === null ? {} : { body: JSON.stringify(body) }),
|
||||
})
|
||||
return { envelope: await response.json() as Record<string, unknown>, posted }
|
||||
}
|
||||
|
||||
it('posts the owner\u2019s /api path and returns the owner\u2019s answer unchanged', async () => {
|
||||
const { envelope, posted } = await call(json({ type: 'server-response', rpcId: 'rpc-1', result: { ok: true } }),
|
||||
'https://relay.example/share/x/api/session/list')
|
||||
expect(envelope).toEqual({ type: 'server-response', rpcId: 'rpc-1', result: { ok: true } })
|
||||
expect(posted[0]).toMatchObject({ op: 'call', path: '/api/session/list', body: { rpcId: 'rpc-1' } })
|
||||
const relative = await call(json({ ok: true }))
|
||||
expect(relative.posted[0]?.path).toBe('/api/session/prompt')
|
||||
const request = await call(json({ ok: true }), new Request('https://relay.example/share/x/api/session/page'))
|
||||
expect(request.posted[0]?.path).toBe('/api/session/page')
|
||||
const unmarked = await call(json({ ok: true }), 'https://relay.example/health', null)
|
||||
expect(unmarked.posted[0]).toEqual({ url: '/__share/rpc?shareId=share-1', op: 'call', path: '/health' })
|
||||
})
|
||||
|
||||
it('rebuilds a refused call as the Connection failure the peer\u2019s code names', async () => {
|
||||
for (const code of ['share-not-writable', 'source-write-unavailable']) {
|
||||
for (const body of [{ error: code, message: 'refused by the host' }, { error: { code, message: 'refused by the host' } }]) {
|
||||
const { envelope } = await call(json(body, 403))
|
||||
expect(envelope).toEqual({
|
||||
type: 'server-response',
|
||||
rpcId: 'rpc-1',
|
||||
result: { ok: false, error: { code, message: 'refused by the host', details: { status: 403 } } },
|
||||
})
|
||||
}
|
||||
}
|
||||
const unnamed = await call(json({ message: 'refused' }, 403))
|
||||
expect(unnamed.envelope.result).toEqual({
|
||||
ok: false, error: { code: 'liveshare/relay-error', message: 'refused', details: { status: 403 } },
|
||||
})
|
||||
const sentence = await call(json({ error: 'the owner said no', message: 'refused' }, 403))
|
||||
expect(sentence.envelope.result).toMatchObject({ error: { code: 'liveshare/relay-error', message: 'refused' } })
|
||||
const silent = await call(json(undefined, 502), 'api/session/list', [])
|
||||
expect(silent.envelope).toEqual({
|
||||
type: 'server-response',
|
||||
rpcId: '',
|
||||
result: { ok: false, error: { code: 'liveshare/relay-error', message: 'Relay answered HTTP 502', details: { status: 502 } } },
|
||||
})
|
||||
const unreadable = await call(json(['not', 'a', 'record']))
|
||||
expect(unreadable.envelope.result).toMatchObject({ ok: false, error: { details: { status: 200 } } })
|
||||
})
|
||||
|
||||
it('reports a call the browser could not send as re-openable, and the caller\u2019s cancellation as itself', async () => {
|
||||
const { transport } = carrier(() => Promise.reject(new TypeError('Failed to fetch')))
|
||||
await expect(transport.fetch('api/session/list', { method: 'POST', body: '{}' })).rejects.toSatisfy(
|
||||
(error: unknown) => markerOf(error)?.kind === 'carrier',
|
||||
)
|
||||
const aborted = new AbortController()
|
||||
aborted.abort()
|
||||
const reason = new Error('caller left')
|
||||
const cancelled = carrier(() => Promise.reject(reason))
|
||||
await expect(cancelled.transport.fetch('api/session/list', { method: 'POST', body: '{}', signal: aborted.signal }))
|
||||
.rejects.toBe(reason)
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewer carrier forwarded page routes', () => {
|
||||
/**
|
||||
* Install the carrier over a page fetch that answers everything itself.
|
||||
* @returns The Fetch replacement the carrier captured, and every request it received.
|
||||
*/
|
||||
function directFetch() {
|
||||
const direct = vi.fn(async (input: unknown) => new Response(`direct:${String(input)}`))
|
||||
vi.stubGlobal('fetch', direct)
|
||||
installViewerCarrier(OPTIONS)
|
||||
return direct
|
||||
}
|
||||
|
||||
it('forwards a page-origin owner route the Relay serves no answer for', async () => {
|
||||
const { posted } = carrier(() => json({ apps: ['finder'] }))
|
||||
const response = await globalThis.fetch('open-in-app/apps')
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ apps: ['finder'] })
|
||||
expect(posted[0]).toMatchObject({ op: 'call', path: '/open-in-app/apps', body: {} })
|
||||
})
|
||||
|
||||
it('forwards the /api owner route a direct fetch never sends through the Connection transport', async () => {
|
||||
const { posted } = carrier(() => json({ name: 'owner-host', available: true, fileManager: 'finder' }))
|
||||
expect(await (await globalThis.fetch('/api/present.host')).json()).toMatchObject({ name: 'owner-host' })
|
||||
expect(posted[0]).toMatchObject({ op: 'call', path: '/api/present.host' })
|
||||
})
|
||||
|
||||
it('reads the owner path under the Relay\u2019s own base path and keeps the owner\u2019s refusal status', async () => {
|
||||
const { posted } = carrier(() => json({ error: 'route-unavailable' }, 403))
|
||||
const response = await globalThis.fetch('/relay/s/abc/open-in-app/apps')
|
||||
expect(response.status).toBe(403)
|
||||
expect(await response.json()).toEqual({ error: 'route-unavailable' })
|
||||
expect(posted[0]).toMatchObject({ op: 'call', path: '/open-in-app/apps' })
|
||||
})
|
||||
|
||||
it('leaves every other request to the page\u2019s own fetch', async () => {
|
||||
const direct = directFetch()
|
||||
expect(await (await globalThis.fetch('health')).text()).toBe('direct:health')
|
||||
expect(direct).toHaveBeenCalledExactlyOnceWith('health', undefined)
|
||||
})
|
||||
|
||||
it('does not forward a route that acts on the owner\u2019s machine', async () => {
|
||||
const direct = directFetch()
|
||||
expect(await (await globalThis.fetch('open-in-app/open', { method: 'POST', body: '{"app":"finder"}' })).text())
|
||||
.toBe('direct:open-in-app/open')
|
||||
expect(direct).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../util/brand" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { UserConfig } from 'tsdown'
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Dependency-free Host code shared by the owner bundle and the Relay: no browser face. */
|
||||
export default defineConfig(({ env }) => {
|
||||
if (env?.DSH_BUILD_FACE === 'client') return { entry: '' } satisfies UserConfig
|
||||
return {
|
||||
entry: [
|
||||
'lib/types/index.js',
|
||||
'lib/types/identity/index.js',
|
||||
'lib/types/tunnel-protocol.js',
|
||||
],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
} satisfies UserConfig
|
||||
})
|
||||
@@ -193,6 +193,12 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
'@deepseek-ai/dsh-cua': ['lib/remote-types.js', 'lib/api.js', 'lib/bridge.js', 'lib/native-client.js', 'lib/protocol.js'],
|
||||
'@deepseek-ai/dsh-experimental-cua-macos': ['native/bin', 'native/Package.swift', 'native/Sources', 'native/tests/swift', 'native/tests/test_swift.py', 'native/LICENSE.cua-driver', 'native/upstream.json', 'native/README.md', 'native/README.zh.md', 'native/protocol.schema.json'],
|
||||
'@deepseek-ai/dsh-webhook-github': ['examples/github-review/cordis.yml', 'examples/github-review/github-ready-review-rule.mjs'],
|
||||
// The shared protocol package publishes its identity subpath and frame codec
|
||||
// beside the package entry.
|
||||
'@deepseek-ai/dsh-experimental-liveshare-protocol': [
|
||||
'lib/identity/index.js',
|
||||
'lib/tunnel-protocol.js',
|
||||
],
|
||||
// Owned Worker bundles import this public bootstrap before their business entry.
|
||||
'@deepseek-ai/dsh-app-boot': ['lib/worker/profile-resolution-bootstrap.js'],
|
||||
// Statically linked client libraries keep their stylesheets next to the emitted
|
||||
|
||||
@@ -68,6 +68,7 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
|
||||
'packages/experimental/webworker-packer': 'Build-time VFS image packer and command library.',
|
||||
'packages/experimental/webworker-runtime': 'Browser worker runtime library with explicit host entry points.',
|
||||
'packages/experimental/hook-protocol': 'Shared wire-protocol library between the hook bridges.',
|
||||
'packages/experimental/liveshare-protocol': 'Shared wire frames and identity contract; plain module exports with no plugin entry.',
|
||||
'packages/identity/anonymous-user-id': 'Harness-home identity helper with no plugin registration.',
|
||||
'packages/sandbox/sandbox-windows-acl': 'Windows ACL sandbox library consumed by sandbox-local.',
|
||||
'packages/sdk/client': 'Client-process library; the spawned runtime owns plugin behavior.',
|
||||
|
||||
@@ -79,6 +79,7 @@ describe('release families', () => {
|
||||
'@deepseek-ai/dsh-hooks-codex',
|
||||
'@deepseek-ai/dsh-experimental-inspector-profile',
|
||||
'@deepseek-ai/dsh-experimental-inspector',
|
||||
'@deepseek-ai/dsh-experimental-liveshare-protocol',
|
||||
'@deepseek-ai/dsh-personalized-recommendations',
|
||||
'@deepseek-ai/dsh-experimental-plugin-market-bundle',
|
||||
'@deepseek-ai/dsh-plugin-recommendations',
|
||||
|
||||
@@ -32,6 +32,7 @@ interface SentenceContract {
|
||||
const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
|
||||
'packages/util/file-drag': 'The package resolves export names and declares native file operations; it does not construct model input.',
|
||||
'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.',
|
||||
'packages/experimental/liveshare-protocol': 'The package only defines wire frames and identity values that its consumers exchange; it registers nothing model-facing.',
|
||||
'packages/util/brand': 'The package only constructs plain string values and registers nothing model-facing.',
|
||||
'packages/util/home-paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.',
|
||||
'packages/util/launch-environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.',
|
||||
|
||||
@@ -374,6 +374,9 @@
|
||||
"@deepseek-ai/dsh-experimental-session-inspector": ["./packages/experimental/session-inspector/src"],
|
||||
"@deepseek-ai/dsh-experimental-session-inspector/client": ["./packages/experimental/session-inspector/src/client/index.ts"],
|
||||
"@deepseek-ai/dsh-experimental-inspector/client": ["./packages/experimental/inspector/src/client/index.ts"],
|
||||
"@deepseek-ai/dsh-experimental-liveshare-protocol": ["./packages/experimental/liveshare-protocol/src"],
|
||||
"@deepseek-ai/dsh-experimental-liveshare-protocol/identity": ["./packages/experimental/liveshare-protocol/src/identity/index.ts"],
|
||||
"@deepseek-ai/dsh-experimental-liveshare-protocol/protocol": ["./packages/experimental/liveshare-protocol/src/tunnel-protocol.ts"],
|
||||
"@deepseek-ai/dsh-experimental-ptc-runtime-python": ["./packages/experimental/ptc-runtime-python/src"],
|
||||
"@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"],
|
||||
"@deepseek-ai/dsh-util-values": ["./packages/util/values/src"],
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
{ "path": "./packages/client/ui-primitives" },
|
||||
{ "path": "./packages/client/modules" },
|
||||
{ "path": "./packages/experimental/inspector/tsconfig.client.json" },
|
||||
{ "path": "./packages/experimental/liveshare-protocol" },
|
||||
{ "path": "./packages/experimental/session-inspector" },
|
||||
{ "path": "./packages/client/hmr" },
|
||||
{ "path": "./packages/client/connection/tsconfig.client.json" },
|
||||
|
||||
@@ -528,6 +528,7 @@
|
||||
{ "path": "./packages/experimental/agent-team" },
|
||||
{ "path": "./packages/experimental/agent-team-profile" },
|
||||
{ "path": "./packages/experimental/inspector-profile" },
|
||||
{ "path": "./packages/experimental/liveshare-protocol" },
|
||||
{ "path": "./packages/experimental/tool-agent-team" },
|
||||
{ "path": "./packages/workflow/workflow" },
|
||||
{ "path": "./packages/workflow/workflow-ptc" },
|
||||
|
||||
@@ -385,6 +385,10 @@ export default defineConfig({
|
||||
// by decision: its correctness signal is its uninstrumented suite and
|
||||
// the packer's end-to-end image spec.
|
||||
'packages/experimental/webworker-runtime/src/**/*.ts',
|
||||
// The wire codec and identity providers this layer publishes are the
|
||||
// same surfaces, and their value-consuming half runs inside the
|
||||
// separately deployed Relay package, so the same lane owns them.
|
||||
'packages/experimental/liveshare-protocol/src/**/*.ts',
|
||||
// Projection/command round: executor lifecycle branches and the
|
||||
// registry's drive tails need the same maturing lanes. TODO(gui):
|
||||
// cover and remove with the client test lane above.
|
||||
|
||||
Reference in new issue
Block a user