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.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol/answer.d.ts +48 -0
- package/dist/protocol/answer.d.ts.map +1 -0
- package/dist/protocol/answer.js +65 -0
- package/dist/protocol/answer.js.map +1 -0
- package/dist/protocol/api.d.ts +576 -0
- package/dist/protocol/api.d.ts.map +1 -0
- package/dist/protocol/api.js +38 -0
- package/dist/protocol/api.js.map +1 -0
- package/dist/protocol/endpoint.d.ts +76 -0
- package/dist/protocol/endpoint.d.ts.map +1 -0
- package/dist/protocol/endpoint.js +307 -0
- package/dist/protocol/endpoint.js.map +1 -0
- package/dist/protocol/errors.d.ts +32 -0
- package/dist/protocol/errors.d.ts.map +1 -0
- package/dist/protocol/errors.js +56 -0
- package/dist/protocol/errors.js.map +1 -0
- package/dist/protocol/frame.d.ts +51 -0
- package/dist/protocol/frame.d.ts.map +1 -0
- package/dist/protocol/frame.js +154 -0
- package/dist/protocol/frame.js.map +1 -0
- package/dist/protocol/hello.d.ts +73 -0
- package/dist/protocol/hello.d.ts.map +1 -0
- package/dist/protocol/hello.js +37 -0
- package/dist/protocol/hello.js.map +1 -0
- package/dist/protocol/host.d.ts +116 -0
- package/dist/protocol/host.d.ts.map +1 -0
- package/dist/protocol/host.js +52 -0
- package/dist/protocol/host.js.map +1 -0
- package/dist/protocol/identity.d.ts +107 -0
- package/dist/protocol/identity.d.ts.map +1 -0
- package/dist/protocol/identity.js +143 -0
- package/dist/protocol/identity.js.map +1 -0
- package/dist/protocol/ids.d.ts +2 -0
- package/dist/protocol/ids.d.ts.map +1 -0
- package/dist/protocol/ids.js +20 -0
- package/dist/protocol/ids.js.map +1 -0
- package/dist/protocol/pairing.d.ts +43 -0
- package/dist/protocol/pairing.d.ts.map +1 -0
- package/dist/protocol/pairing.js +78 -0
- package/dist/protocol/pairing.js.map +1 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/mock-host.d.ts +66 -0
- package/dist/testing/mock-host.d.ts.map +1 -0
- package/dist/testing/mock-host.js +573 -0
- package/dist/testing/mock-host.js.map +1 -0
- package/dist/transport/broadcast.d.ts +26 -0
- package/dist/transport/broadcast.d.ts.map +1 -0
- package/dist/transport/broadcast.js +71 -0
- package/dist/transport/broadcast.js.map +1 -0
- package/dist/transport/ice.d.ts +130 -0
- package/dist/transport/ice.d.ts.map +1 -0
- package/dist/transport/ice.js +338 -0
- package/dist/transport/ice.js.map +1 -0
- package/dist/transport/memory.d.ts +52 -0
- package/dist/transport/memory.d.ts.map +1 -0
- package/dist/transport/memory.js +135 -0
- package/dist/transport/memory.js.map +1 -0
- package/dist/transport/rtc.d.ts +76 -0
- package/dist/transport/rtc.d.ts.map +1 -0
- package/dist/transport/rtc.js +310 -0
- package/dist/transport/rtc.js.map +1 -0
- package/dist/transport/signaling.d.ts +101 -0
- package/dist/transport/signaling.d.ts.map +1 -0
- package/dist/transport/signaling.js +235 -0
- package/dist/transport/signaling.js.map +1 -0
- package/dist/transport/types.d.ts +41 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/types.js +11 -0
- package/dist/transport/types.js.map +1 -0
- package/package.json +69 -0
- package/src/index.ts +155 -0
- package/src/protocol/answer.ts +89 -0
- package/src/protocol/api.ts +517 -0
- package/src/protocol/endpoint.ts +405 -0
- package/src/protocol/errors.ts +90 -0
- package/src/protocol/frame.ts +196 -0
- package/src/protocol/hello.ts +112 -0
- package/src/protocol/host.ts +150 -0
- package/src/protocol/identity.ts +184 -0
- package/src/protocol/ids.ts +20 -0
- package/src/protocol/pairing.ts +95 -0
- package/src/testing/index.ts +5 -0
- package/src/testing/mock-host.ts +691 -0
- package/src/transport/broadcast.ts +81 -0
- package/src/transport/ice.ts +402 -0
- package/src/transport/memory.ts +164 -0
- package/src/transport/rtc.ts +344 -0
- package/src/transport/signaling.ts +317 -0
- 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
|
+
}
|