virlen-remote 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +192 -0
  4. package/dist/index.d.ts +44 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +34 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/protocol/answer.d.ts +48 -0
  9. package/dist/protocol/answer.d.ts.map +1 -0
  10. package/dist/protocol/answer.js +65 -0
  11. package/dist/protocol/answer.js.map +1 -0
  12. package/dist/protocol/api.d.ts +576 -0
  13. package/dist/protocol/api.d.ts.map +1 -0
  14. package/dist/protocol/api.js +38 -0
  15. package/dist/protocol/api.js.map +1 -0
  16. package/dist/protocol/endpoint.d.ts +76 -0
  17. package/dist/protocol/endpoint.d.ts.map +1 -0
  18. package/dist/protocol/endpoint.js +307 -0
  19. package/dist/protocol/endpoint.js.map +1 -0
  20. package/dist/protocol/errors.d.ts +32 -0
  21. package/dist/protocol/errors.d.ts.map +1 -0
  22. package/dist/protocol/errors.js +56 -0
  23. package/dist/protocol/errors.js.map +1 -0
  24. package/dist/protocol/frame.d.ts +51 -0
  25. package/dist/protocol/frame.d.ts.map +1 -0
  26. package/dist/protocol/frame.js +154 -0
  27. package/dist/protocol/frame.js.map +1 -0
  28. package/dist/protocol/hello.d.ts +73 -0
  29. package/dist/protocol/hello.d.ts.map +1 -0
  30. package/dist/protocol/hello.js +37 -0
  31. package/dist/protocol/hello.js.map +1 -0
  32. package/dist/protocol/host.d.ts +116 -0
  33. package/dist/protocol/host.d.ts.map +1 -0
  34. package/dist/protocol/host.js +52 -0
  35. package/dist/protocol/host.js.map +1 -0
  36. package/dist/protocol/identity.d.ts +107 -0
  37. package/dist/protocol/identity.d.ts.map +1 -0
  38. package/dist/protocol/identity.js +143 -0
  39. package/dist/protocol/identity.js.map +1 -0
  40. package/dist/protocol/ids.d.ts +2 -0
  41. package/dist/protocol/ids.d.ts.map +1 -0
  42. package/dist/protocol/ids.js +20 -0
  43. package/dist/protocol/ids.js.map +1 -0
  44. package/dist/protocol/pairing.d.ts +43 -0
  45. package/dist/protocol/pairing.d.ts.map +1 -0
  46. package/dist/protocol/pairing.js +78 -0
  47. package/dist/protocol/pairing.js.map +1 -0
  48. package/dist/testing/index.d.ts +6 -0
  49. package/dist/testing/index.d.ts.map +1 -0
  50. package/dist/testing/index.js +5 -0
  51. package/dist/testing/index.js.map +1 -0
  52. package/dist/testing/mock-host.d.ts +66 -0
  53. package/dist/testing/mock-host.d.ts.map +1 -0
  54. package/dist/testing/mock-host.js +573 -0
  55. package/dist/testing/mock-host.js.map +1 -0
  56. package/dist/transport/broadcast.d.ts +26 -0
  57. package/dist/transport/broadcast.d.ts.map +1 -0
  58. package/dist/transport/broadcast.js +71 -0
  59. package/dist/transport/broadcast.js.map +1 -0
  60. package/dist/transport/ice.d.ts +130 -0
  61. package/dist/transport/ice.d.ts.map +1 -0
  62. package/dist/transport/ice.js +338 -0
  63. package/dist/transport/ice.js.map +1 -0
  64. package/dist/transport/memory.d.ts +52 -0
  65. package/dist/transport/memory.d.ts.map +1 -0
  66. package/dist/transport/memory.js +135 -0
  67. package/dist/transport/memory.js.map +1 -0
  68. package/dist/transport/rtc.d.ts +76 -0
  69. package/dist/transport/rtc.d.ts.map +1 -0
  70. package/dist/transport/rtc.js +310 -0
  71. package/dist/transport/rtc.js.map +1 -0
  72. package/dist/transport/signaling.d.ts +101 -0
  73. package/dist/transport/signaling.d.ts.map +1 -0
  74. package/dist/transport/signaling.js +235 -0
  75. package/dist/transport/signaling.js.map +1 -0
  76. package/dist/transport/types.d.ts +41 -0
  77. package/dist/transport/types.d.ts.map +1 -0
  78. package/dist/transport/types.js +11 -0
  79. package/dist/transport/types.js.map +1 -0
  80. package/package.json +69 -0
  81. package/src/index.ts +155 -0
  82. package/src/protocol/answer.ts +89 -0
  83. package/src/protocol/api.ts +517 -0
  84. package/src/protocol/endpoint.ts +405 -0
  85. package/src/protocol/errors.ts +90 -0
  86. package/src/protocol/frame.ts +196 -0
  87. package/src/protocol/hello.ts +112 -0
  88. package/src/protocol/host.ts +150 -0
  89. package/src/protocol/identity.ts +184 -0
  90. package/src/protocol/ids.ts +20 -0
  91. package/src/protocol/pairing.ts +95 -0
  92. package/src/testing/index.ts +5 -0
  93. package/src/testing/mock-host.ts +691 -0
  94. package/src/transport/broadcast.ts +81 -0
  95. package/src/transport/ice.ts +402 -0
  96. package/src/transport/memory.ts +164 -0
  97. package/src/transport/rtc.ts +344 -0
  98. package/src/transport/signaling.ts +317 -0
  99. package/src/transport/types.ts +50 -0
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Endpoint —— 协议层核心:把「四类接口」统一为 **2 个原语 × 2 个方向**
3
+ * (docs/phone-control-bridge.md §2.1)。
4
+ *
5
+ * ```
6
+ * handle(method, fn) 我实现的、供对方调用 ← 请求接口
7
+ * subscribe(topic, fn) 我订阅的、对方发来的事件 ← 事件接口
8
+ * call(method, params) 我发起的调用 ← 服务接口
9
+ * emit(topic, payload) 我发出的事件 ← 事件接口
10
+ * ```
11
+ * 四类接口是本形状的特例;各写一个模块会得到 4 份重复的序列化/超时/重连代码,且行为必然漂移。
12
+ *
13
+ * 内置可靠性(§3.2):
14
+ * - **分片**:载荷 > 12KB 自动分片,接收侧按 msgId 归组,不完整则整体丢弃;
15
+ * - **幂等**:收到的 CALL 按 `requestId` 去重(LRU + TTL),命中则**回上次结果而非重放**;
16
+ * - **重连重放**:链路恢复时,未决的 CALL 用**同一 requestId** 重发 → 对端去重,避免重复执行;
17
+ * - **超时分层**:链路非 open → 立即 `E_TRANSPORT`;协议层默认 10s(可随 hello 协商调整)。
18
+ */
19
+ import { BridgeError, toBridgeError, type WireError } from './errors'
20
+ import { DEFAULT_MAX_FRAME_PAYLOAD, FrameKind, Reassembler, encodeFrames, PROTOCOL_VERSION } from './frame'
21
+ import { newRequestId } from './ids'
22
+ import type { Transport, TransportState } from '../transport/types'
23
+
24
+ // ───────────────────────────── 线上载荷 ─────────────────────────────
25
+
26
+ interface WireCall {
27
+ requestId: string
28
+ method: string
29
+ params: unknown
30
+ }
31
+
32
+ /**
33
+ * RESULT 载荷。
34
+ *
35
+ * 刻意用**扁平可选字段**而非「判别式联合(`{ok:true,data}` | `{ok:false,error}`)」:
36
+ * 消费端 `virlen-app` 的 tsconfig 有 `strictNullChecks: false`,布尔字面量判别式**不会触发联合收窄**,
37
+ * 而共享包以源码形式被消费端直接类型检查 → 判别式联合会在消费端报错(包内自检却通过)。
38
+ * 扁平信封在两种设置下都合法。
39
+ */
40
+ interface WireResult {
41
+ requestId: string
42
+ ok: boolean
43
+ /** ok=true 时的结果。 */
44
+ data?: unknown
45
+ /** ok=false 时的错误。 */
46
+ error?: WireError
47
+ }
48
+
49
+ interface WireEvent {
50
+ topic: string
51
+ payload: unknown
52
+ }
53
+
54
+ interface WireCtrl {
55
+ subtype: 'ping' | 'pong' | 'bye' | 'flow'
56
+ [key: string]: unknown
57
+ }
58
+
59
+ // ───────────────────────────── 对外类型 ─────────────────────────────
60
+
61
+ export interface CallContext {
62
+ readonly requestId: string
63
+ readonly method: string
64
+ readonly origin: 'remote'
65
+ }
66
+
67
+ export interface EventContext {
68
+ readonly topic: string
69
+ readonly origin: 'remote'
70
+ }
71
+
72
+ export type RpcHandler = (params: unknown, ctx: CallContext) => unknown | Promise<unknown>
73
+ export type EventSubscriber = (payload: unknown, ctx: EventContext) => void
74
+
75
+ export interface CallOptions {
76
+ timeoutMs?: number
77
+ }
78
+
79
+ export interface EndpointOptions {
80
+ transport: Transport
81
+ /** 协议层默认超时;可随 hello 协商调整(§3.2)。 <=0 表示不超时。 */
82
+ defaultTimeoutMs?: number
83
+ maxFramePayload?: number
84
+ /** 幂等去重表容量(LRU)。 */
85
+ dedupeSize?: number
86
+ /** 幂等去重表存活时间。 */
87
+ dedupeTtlMs?: number
88
+ }
89
+
90
+ interface PendingCall {
91
+ requestId: string
92
+ method: string
93
+ frames: Uint8Array[]
94
+ resolve: (value: unknown) => void
95
+ reject: (error: unknown) => void
96
+ timer?: ReturnType<typeof setTimeout>
97
+ }
98
+
99
+ interface DedupeEntry {
100
+ result: WireResult
101
+ at: number
102
+ }
103
+
104
+ const DEFAULT_TIMEOUT_MS = 10_000
105
+ const DEFAULT_DEDUPE_SIZE = 200
106
+ const DEFAULT_DEDUPE_TTL_MS = 5 * 60_000
107
+
108
+ export class Endpoint {
109
+ private readonly transport: Transport
110
+ private readonly defaultTimeoutMs: number
111
+ private readonly maxFramePayload: number
112
+ private readonly dedupeSize: number
113
+ private readonly dedupeTtlMs: number
114
+
115
+ private readonly handlers = new Map<string, RpcHandler>()
116
+ private readonly subscribers = new Map<string, Set<EventSubscriber>>()
117
+ private readonly pending = new Map<string, PendingCall>()
118
+ /** requestId → 上次结果(插入序即 LRU 淘汰序)。 */
119
+ private readonly dedupe = new Map<string, DedupeEntry>()
120
+ private readonly reassembler = new Reassembler()
121
+ private readonly detachers: Array<() => void> = []
122
+
123
+ private msgId = 0
124
+ private lastState: TransportState
125
+ private disposed = false
126
+
127
+ constructor(options: EndpointOptions) {
128
+ this.transport = options.transport
129
+ this.defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS
130
+ this.maxFramePayload = options.maxFramePayload ?? DEFAULT_MAX_FRAME_PAYLOAD
131
+ this.dedupeSize = options.dedupeSize ?? DEFAULT_DEDUPE_SIZE
132
+ this.dedupeTtlMs = options.dedupeTtlMs ?? DEFAULT_DEDUPE_TTL_MS
133
+ this.lastState = options.transport.state
134
+ this.detachers.push(this.transport.onMessage((bytes) => this.onFrame(bytes)))
135
+ this.detachers.push(this.transport.onStateChange((state) => this.onState(state)))
136
+ }
137
+
138
+ // ───────────────────────────── 我实现的、供对方调用 ─────────────────────────────
139
+
140
+ /** 注册一个 handler;返回注销函数。 */
141
+ handle(method: string, fn: RpcHandler): () => void {
142
+ this.handlers.set(method, fn)
143
+ return () => {
144
+ if (this.handlers.get(method) === fn) this.handlers.delete(method)
145
+ }
146
+ }
147
+
148
+ /** 订阅一个 topic;返回取消订阅函数。 */
149
+ subscribe(topic: string, fn: EventSubscriber): () => void {
150
+ let set = this.subscribers.get(topic)
151
+ if (!set) {
152
+ set = new Set()
153
+ this.subscribers.set(topic, set)
154
+ }
155
+ set.add(fn)
156
+ return () => {
157
+ set.delete(fn)
158
+ if (set.size === 0) this.subscribers.delete(topic)
159
+ }
160
+ }
161
+
162
+ // ───────────────────────────── 我发起的调用 / 我发出的事件 ─────────────────────────────
163
+
164
+ /**
165
+ * 发起一次 RPC。链路非 open → **立即失败**(不发请求、不等待)。
166
+ * 长任务不要等它 resolve:见 §3.3,`host.session.send` 只回「投递确认」,过程靠事件推。
167
+ */
168
+ call(method: string, params?: unknown, options?: CallOptions): Promise<unknown> {
169
+ if (this.disposed) {
170
+ return Promise.reject(new BridgeError('E_INTERNAL', 'endpoint disposed'))
171
+ }
172
+ if (this.transport.state !== 'open') {
173
+ return Promise.reject(new BridgeError('E_TRANSPORT', `transport is ${this.transport.state}`))
174
+ }
175
+ const requestId = newRequestId()
176
+ const call: WireCall = { requestId, method, params }
177
+ const frames = encodeFrames(FrameKind.CALL, this.nextMsgId(), call, this.maxFramePayload)
178
+
179
+ return new Promise<unknown>((resolve, reject) => {
180
+ const entry: PendingCall = { requestId, method, frames, resolve, reject }
181
+ const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs
182
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
183
+ entry.timer = setTimeout(() => {
184
+ if (this.pending.delete(requestId)) {
185
+ reject(new BridgeError('E_TIMEOUT', `${method} timed out after ${timeoutMs}ms`))
186
+ }
187
+ }, timeoutMs)
188
+ }
189
+ this.pending.set(requestId, entry)
190
+ this.sendFrames(frames)
191
+ })
192
+ }
193
+
194
+ /** 发出一个事件(fire-and-forget);返回是否发出。 */
195
+ emit(topic: string, payload?: unknown): boolean {
196
+ if (this.disposed || this.transport.state !== 'open') return false
197
+ const event: WireEvent = { topic, payload }
198
+ this.sendFrames(encodeFrames(FrameKind.EVENT, this.nextMsgId(), event, this.maxFramePayload))
199
+ return true
200
+ }
201
+
202
+ /** 发送一个心跳 ping(pong 由对端应答)。 */
203
+ ping(): boolean {
204
+ if (this.disposed || this.transport.state !== 'open') return false
205
+ this.sendCtrl({ subtype: 'ping', ts: Date.now() })
206
+ return true
207
+ }
208
+
209
+ dispose(reason = 'endpoint disposed'): void {
210
+ if (this.disposed) return
211
+ this.disposed = true
212
+ for (const detach of this.detachers) {
213
+ try {
214
+ detach()
215
+ } catch {
216
+ /* 忽略 */
217
+ }
218
+ }
219
+ const error = new BridgeError('E_TRANSPORT', reason)
220
+ for (const entry of this.pending.values()) {
221
+ this.clearPendingTimer(entry)
222
+ entry.reject(error)
223
+ }
224
+ this.pending.clear()
225
+ }
226
+
227
+ get pendingCount(): number {
228
+ return this.pending.size
229
+ }
230
+
231
+ get transportState(): TransportState {
232
+ return this.transport.state
233
+ }
234
+
235
+ // ───────────────────────────── 接收入口 ─────────────────────────────
236
+
237
+ private onFrame(bytes: Uint8Array): void {
238
+ if (this.disposed) return
239
+ let message: ReturnType<Reassembler['accept']>
240
+ try {
241
+ message = this.reassembler.accept(bytes)
242
+ } catch {
243
+ // 坏帧/坏分片直接丢弃,不影响后续
244
+ return
245
+ }
246
+ if (!message) return
247
+ if (message.header.version !== PROTOCOL_VERSION) return // 版本不符:丢弃
248
+
249
+ switch (message.header.kind) {
250
+ case FrameKind.CALL:
251
+ void this.onCall(message.payload as WireCall)
252
+ break
253
+ case FrameKind.RESULT:
254
+ this.onResult(message.payload as WireResult)
255
+ break
256
+ case FrameKind.EVENT:
257
+ this.onEvent(message.payload as WireEvent)
258
+ break
259
+ case FrameKind.CTRL:
260
+ this.onCtrl(message.payload as WireCtrl)
261
+ break
262
+ }
263
+ }
264
+
265
+ private async onCall(call: WireCall): Promise<void> {
266
+ if (!call || typeof call.requestId !== 'string' || typeof call.method !== 'string') return
267
+
268
+ // 幂等:命中去重表则回上次结果,**不重放**
269
+ const cached = this.lookupDedupe(call.requestId)
270
+ if (cached) {
271
+ this.sendResult(cached.result)
272
+ return
273
+ }
274
+
275
+ const handler = this.handlers.get(call.method)
276
+ if (!handler) {
277
+ this.finishCall(call.requestId, {
278
+ requestId: call.requestId,
279
+ ok: false,
280
+ error: new BridgeError('E_UNSUPPORTED', `unsupported method: ${call.method}`).toWire(),
281
+ })
282
+ return
283
+ }
284
+
285
+ let result: WireResult
286
+ try {
287
+ const data = await handler(call.params, {
288
+ requestId: call.requestId,
289
+ method: call.method,
290
+ origin: 'remote',
291
+ })
292
+ result = { requestId: call.requestId, ok: true, data }
293
+ } catch (err) {
294
+ const error = toBridgeError(err)
295
+ result = { requestId: call.requestId, ok: false, error: error.toWire() }
296
+ }
297
+ this.finishCall(call.requestId, result)
298
+ }
299
+
300
+ private onResult(result: WireResult): void {
301
+ if (!result || typeof result.requestId !== 'string') return
302
+ const entry = this.pending.get(result.requestId)
303
+ if (!entry) return // 未知/超时后的迟到响应:忽略
304
+ this.pending.delete(result.requestId)
305
+ this.clearPendingTimer(entry)
306
+ if (result.ok) {
307
+ entry.resolve(result.data)
308
+ } else if (result.error) {
309
+ entry.reject(BridgeError.fromWire(result.error))
310
+ } else {
311
+ entry.reject(new BridgeError('E_INTERNAL', 'malformed RESULT (ok=false without error)'))
312
+ }
313
+ }
314
+
315
+ private onEvent(event: WireEvent): void {
316
+ if (!event || typeof event.topic !== 'string') return
317
+ const set = this.subscribers.get(event.topic)
318
+ if (!set) return
319
+ const ctx: EventContext = { topic: event.topic, origin: 'remote' }
320
+ for (const fn of [...set]) {
321
+ try {
322
+ fn(event.payload, ctx)
323
+ } catch {
324
+ // 单个订阅者抛错不得中断分发
325
+ }
326
+ }
327
+ }
328
+
329
+ private onCtrl(ctrl: WireCtrl): void {
330
+ if (!ctrl || typeof ctrl.subtype !== 'string') return
331
+ if (ctrl.subtype === 'ping') {
332
+ this.sendCtrl({ subtype: 'pong', ts: ctrl.ts })
333
+ }
334
+ // 'pong' 由心跳逻辑消费(M2/M3);一期无自动心跳
335
+ }
336
+
337
+ // ───────────────────────────── 内部工具 ─────────────────────────────
338
+
339
+ private onState(state: TransportState): void {
340
+ const was = this.lastState
341
+ this.lastState = state
342
+ if (state === 'open' && was !== 'open') {
343
+ // 断线期间的分片已失效,重置归组器再重放
344
+ this.reassembler.reset()
345
+ this.replayPending()
346
+ }
347
+ }
348
+
349
+ /** 链路恢复:未决的 CALL 用同一 requestId 重发(对端靠去重表返回上次结果)。 */
350
+ private replayPending(): void {
351
+ for (const entry of this.pending.values()) {
352
+ this.sendFrames(entry.frames)
353
+ }
354
+ }
355
+
356
+ private finishCall(requestId: string, result: WireResult): void {
357
+ this.rememberDedupe(requestId, result)
358
+ this.sendResult(result)
359
+ }
360
+
361
+ private sendResult(result: WireResult): void {
362
+ this.sendFrames(encodeFrames(FrameKind.RESULT, this.nextMsgId(), result, this.maxFramePayload))
363
+ }
364
+
365
+ private sendCtrl(ctrl: WireCtrl): void {
366
+ this.sendFrames(encodeFrames(FrameKind.CTRL, this.nextMsgId(), ctrl, this.maxFramePayload))
367
+ }
368
+
369
+ private sendFrames(frames: Uint8Array[]): void {
370
+ if (this.transport.state !== 'open') return
371
+ for (const frame of frames) this.transport.send(frame)
372
+ }
373
+
374
+ private nextMsgId(): number {
375
+ this.msgId = (this.msgId + 1) >>> 0
376
+ if (this.msgId === 0) this.msgId = 1
377
+ return this.msgId
378
+ }
379
+
380
+ private clearPendingTimer(entry: PendingCall): void {
381
+ if (entry.timer !== undefined) {
382
+ clearTimeout(entry.timer)
383
+ entry.timer = undefined
384
+ }
385
+ }
386
+
387
+ private lookupDedupe(requestId: string): DedupeEntry | undefined {
388
+ const entry = this.dedupe.get(requestId)
389
+ if (!entry) return undefined
390
+ if (Date.now() - entry.at > this.dedupeTtlMs) {
391
+ this.dedupe.delete(requestId)
392
+ return undefined
393
+ }
394
+ return entry
395
+ }
396
+
397
+ private rememberDedupe(requestId: string, result: WireResult): void {
398
+ this.dedupe.set(requestId, { result, at: Date.now() })
399
+ while (this.dedupe.size > this.dedupeSize) {
400
+ const oldest = this.dedupe.keys().next().value
401
+ if (oldest === undefined) break
402
+ this.dedupe.delete(oldest)
403
+ }
404
+ }
405
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 错误模型(见 docs/phone-control-bridge.md §3.4)。
3
+ *
4
+ * 只有 `retryable === true` 才允许 UI 给「重试」按钮。
5
+ * `E_UNSUPPORTED` 是**版本兼容的关键**:老手机调新方法 → 明确报错 → UI 隐藏该功能,
6
+ * **永远不要靠版本号 if-else 分支**。
7
+ */
8
+
9
+ export type ErrorCode =
10
+ | 'E_BAD_REQUEST' // 参数非法(不可重试)
11
+ | 'E_NOT_FOUND' // 会话/消息不存在(不可重试)
12
+ | 'E_DENIED' // ACL 拒绝 / 未授权(不可重试)
13
+ | 'E_UNSUPPORTED' // 方法不被对端支持(能力协商用,不可重试)
14
+ | 'E_BUSY' // 会话正在工作(可重试)
15
+ | 'E_CONFLICT' // 目标已被处理 / 状态已变(不可重试)
16
+ | 'E_CONFIRM_REQUIRED' // 高风险操作缺二次确认(不可重试)
17
+ | 'E_TIMEOUT' // 超时(可重试)
18
+ | 'E_INTERNAL' // 对端内部错误(不可重试)
19
+ | 'E_TRANSPORT' // 本地:链路不可用(可重试)
20
+ | 'E_REPLACED' // 被顶号:同一台电脑已被另一台手机接管(不可重试,且**禁止自动重连**)
21
+
22
+ /** 线上(wire)错误形态 —— 可序列化为 JSON。 */
23
+ export interface WireError {
24
+ code: ErrorCode
25
+ message: string
26
+ retryable: boolean
27
+ data?: unknown
28
+ }
29
+
30
+ const RETRYABLE: Record<ErrorCode, boolean> = {
31
+ E_BAD_REQUEST: false,
32
+ E_NOT_FOUND: false,
33
+ E_DENIED: false,
34
+ E_UNSUPPORTED: false,
35
+ E_BUSY: true,
36
+ E_CONFLICT: false,
37
+ E_CONFIRM_REQUIRED: false,
38
+ E_TIMEOUT: true,
39
+ E_INTERNAL: false,
40
+ E_TRANSPORT: true,
41
+ E_REPLACED: false,
42
+ }
43
+
44
+ export interface BridgeErrorOptions {
45
+ retryable?: boolean
46
+ data?: unknown
47
+ cause?: unknown
48
+ }
49
+
50
+ export class BridgeError extends Error {
51
+ readonly code: ErrorCode
52
+ readonly retryable: boolean
53
+ readonly data?: unknown
54
+
55
+ constructor(code: ErrorCode, message: string, options?: BridgeErrorOptions) {
56
+ super(message)
57
+ this.name = 'BridgeError'
58
+ this.code = code
59
+ this.retryable = options?.retryable ?? RETRYABLE[code] ?? false
60
+ this.data = options?.data
61
+ if (options?.cause !== undefined) {
62
+ // 避免依赖 ES2022 的 ErrorOptions,直接挂 cause 字段
63
+ ;(this as { cause?: unknown }).cause = options.cause
64
+ }
65
+ }
66
+
67
+ toWire(): WireError {
68
+ return {
69
+ code: this.code,
70
+ message: this.message,
71
+ retryable: this.retryable,
72
+ ...(this.data !== undefined ? { data: this.data } : {}),
73
+ }
74
+ }
75
+
76
+ static fromWire(w: WireError): BridgeError {
77
+ return new BridgeError(w.code, w.message, { retryable: w.retryable, data: w.data })
78
+ }
79
+
80
+ static is(value: unknown): value is BridgeError {
81
+ return value instanceof BridgeError
82
+ }
83
+ }
84
+
85
+ /** 把任意抛出物归一为 BridgeError(非 BridgeError 一律按 E_INTERNAL 处理)。 */
86
+ export function toBridgeError(err: unknown): BridgeError {
87
+ if (err instanceof BridgeError) return err
88
+ const message = err instanceof Error ? err.message : String(err)
89
+ return new BridgeError('E_INTERNAL', message, { cause: err })
90
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * 帧编解码(见 docs/phone-control-bridge.md §3.1/§3.2)。
3
+ *
4
+ * 12 字节二进制头 + UTF-8 JSON 载荷。**现在就上二进制头**的理由:
5
+ * 分片是必然会需要的(长消息、大列表),而分片必须靠「帧头 + 归组 id」;
6
+ * 若一期先发裸 JSON,等需要分片时要改所有帧格式 → 返工。一期 `chunkCount` 恒为 1 即可,零额外成本。
7
+ *
8
+ * ```
9
+ * 偏移 字段 类型 说明
10
+ * 0 version u8 协议主版本(当前 =1)
11
+ * 1 kind u8 1=CALL 2=RESULT 3=EVENT 4=CTRL
12
+ * 2 chunkIndex u16 LE 分片序号,从 0 开始
13
+ * 4 chunkCount u16 LE 本次逻辑消息的分片总数(无分片 =1)
14
+ * 6 flags u16 LE 保留(bit0 预留=压缩)
15
+ * 8 msgId u32 LE 发送方单调递增,**仅用于分片归组**(回指靠载荷里的 requestId)
16
+ * 12.. payload UTF-8 JSON
17
+ * ```
18
+ */
19
+ import { BridgeError } from './errors'
20
+
21
+ export const PROTOCOL_VERSION = 1
22
+ export const HEADER_SIZE = 12
23
+ /** 超过该阈值的载荷即分片(见 §3.2)。 */
24
+ export const DEFAULT_MAX_FRAME_PAYLOAD = 12 * 1024
25
+
26
+ export const FrameKind = {
27
+ CALL: 1,
28
+ RESULT: 2,
29
+ EVENT: 3,
30
+ CTRL: 4,
31
+ } as const
32
+ export type FrameKind = (typeof FrameKind)[keyof typeof FrameKind]
33
+
34
+ const MAX_CHUNK_COUNT = 0xffff
35
+
36
+ const textEncoder = new TextEncoder()
37
+ const textDecoder = new TextDecoder()
38
+
39
+ export interface FrameHeader {
40
+ version: number
41
+ kind: FrameKind
42
+ chunkIndex: number
43
+ chunkCount: number
44
+ flags: number
45
+ msgId: number
46
+ }
47
+
48
+ /** 一个已完整还原的逻辑消息。 */
49
+ export interface DecodedMessage<T = unknown> {
50
+ header: FrameHeader
51
+ payload: T
52
+ }
53
+
54
+ export function encodeHeader(header: FrameHeader): Uint8Array {
55
+ const buf = new Uint8Array(HEADER_SIZE)
56
+ const dv = new DataView(buf.buffer)
57
+ dv.setUint8(0, header.version)
58
+ dv.setUint8(1, header.kind)
59
+ dv.setUint16(2, header.chunkIndex, true)
60
+ dv.setUint16(4, header.chunkCount, true)
61
+ dv.setUint16(6, header.flags, true)
62
+ dv.setUint32(8, header.msgId, true)
63
+ return buf
64
+ }
65
+
66
+ export function decodeHeader(bytes: Uint8Array, offset = 0): FrameHeader {
67
+ if (bytes.length - offset < HEADER_SIZE) {
68
+ throw new BridgeError('E_BAD_REQUEST', `frame too short: ${bytes.length - offset} < ${HEADER_SIZE}`)
69
+ }
70
+ const dv = new DataView(bytes.buffer, bytes.byteOffset + offset, HEADER_SIZE)
71
+ return {
72
+ version: dv.getUint8(0),
73
+ kind: dv.getUint8(1) as FrameKind,
74
+ chunkIndex: dv.getUint16(2, true),
75
+ chunkCount: dv.getUint16(4, true),
76
+ flags: dv.getUint16(6, true),
77
+ msgId: dv.getUint32(8, true),
78
+ }
79
+ }
80
+
81
+ /** 读取帧体(不含头)。 */
82
+ export function frameBody(frame: Uint8Array): Uint8Array {
83
+ return frame.subarray(HEADER_SIZE)
84
+ }
85
+
86
+ /**
87
+ * 把一条逻辑消息编码为 1..N 个帧。载荷 > `maxFramePayload` 时自动分片。
88
+ * 返回的数组顺序即分片顺序(chunkIndex 0..n-1)。
89
+ */
90
+ export function encodeFrames(
91
+ kind: FrameKind,
92
+ msgId: number,
93
+ payload: unknown,
94
+ maxFramePayload: number = DEFAULT_MAX_FRAME_PAYLOAD,
95
+ ): Uint8Array[] {
96
+ const json = textEncoder.encode(payload === undefined ? '' : JSON.stringify(payload))
97
+ const chunkCount = Math.max(1, Math.ceil(json.length / maxFramePayload))
98
+ if (chunkCount > MAX_CHUNK_COUNT) {
99
+ throw new BridgeError('E_BAD_REQUEST', `payload too large: needs ${chunkCount} chunks (> ${MAX_CHUNK_COUNT})`)
100
+ }
101
+ const frames: Uint8Array[] = []
102
+ for (let index = 0; index < chunkCount; index++) {
103
+ const start = index * maxFramePayload
104
+ const slice = json.subarray(start, start + maxFramePayload)
105
+ const frame = new Uint8Array(HEADER_SIZE + slice.length)
106
+ frame.set(encodeHeader({ version: PROTOCOL_VERSION, kind, chunkIndex: index, chunkCount, flags: 0, msgId }), 0)
107
+ frame.set(slice, HEADER_SIZE)
108
+ frames.push(frame)
109
+ }
110
+ return frames
111
+ }
112
+
113
+ interface PartialMessage {
114
+ header: FrameHeader
115
+ chunks: Array<Uint8Array | undefined>
116
+ received: number
117
+ }
118
+
119
+ /**
120
+ * 分片归组器 —— 按 `msgId` 收集分片,**完整才交付**(不完整则整体丢弃,不做部分应用)。
121
+ *
122
+ * 天然容忍:分片乱序、多条消息交错(不同 msgId 各自独立累积)。
123
+ */
124
+ export class Reassembler {
125
+ private readonly partials = new Map<number, PartialMessage>()
126
+
127
+ constructor(private readonly maxPartials = 64) {}
128
+
129
+ /** 喂入一帧;返回已完整还原的消息,否则 null。 */
130
+ accept(frame: Uint8Array): DecodedMessage | null {
131
+ const header = decodeHeader(frame)
132
+ const body = frameBody(frame)
133
+
134
+ if (header.chunkCount <= 1) {
135
+ return { header, payload: this.decodePayload(body) }
136
+ }
137
+
138
+ let partial = this.partials.get(header.msgId)
139
+ if (!partial) {
140
+ if (this.partials.size >= this.maxPartials) {
141
+ this.evictOldest()
142
+ }
143
+ partial = { header, chunks: new Array<Uint8Array | undefined>(header.chunkCount), received: 0 }
144
+ this.partials.set(header.msgId, partial)
145
+ }
146
+
147
+ if (header.chunkIndex >= partial.chunks.length) {
148
+ this.partials.delete(header.msgId)
149
+ throw new BridgeError('E_BAD_REQUEST', `chunkIndex ${header.chunkIndex} out of range (${partial.chunks.length})`)
150
+ }
151
+
152
+ if (partial.chunks[header.chunkIndex] === undefined) {
153
+ partial.chunks[header.chunkIndex] = body
154
+ partial.received++
155
+ }
156
+
157
+ if (partial.received === partial.chunks.length) {
158
+ this.partials.delete(header.msgId)
159
+ return { header: partial.header, payload: this.decodePayload(concatChunks(partial.chunks)) }
160
+ }
161
+ return null
162
+ }
163
+
164
+ /** 正在等待后续分片的逻辑消息数(诊断用)。 */
165
+ get pendingCount(): number {
166
+ return this.partials.size
167
+ }
168
+
169
+ reset(): void {
170
+ this.partials.clear()
171
+ }
172
+
173
+ private decodePayload(body: Uint8Array): unknown {
174
+ if (body.length === 0) return undefined
175
+ return JSON.parse(textDecoder.decode(body))
176
+ }
177
+
178
+ private evictOldest(): void {
179
+ const oldest = this.partials.keys().next().value
180
+ if (oldest !== undefined) this.partials.delete(oldest)
181
+ }
182
+ }
183
+
184
+ function concatChunks(chunks: Array<Uint8Array | undefined>): Uint8Array {
185
+ let total = 0
186
+ for (const c of chunks) total += c?.length ?? 0
187
+ const merged = new Uint8Array(total)
188
+ let offset = 0
189
+ for (const c of chunks) {
190
+ if (c) {
191
+ merged.set(c, offset)
192
+ offset += c.length
193
+ }
194
+ }
195
+ return merged
196
+ }