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,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RTC transport —— 真实链路(WebRTC DataChannel)。
|
|
3
|
+
*
|
|
4
|
+
* 只依赖两个注入点,因此**可单测**(不需要真实 WebRTC):
|
|
5
|
+
* - `signaling`:信令通道(`SignalingChannel`,M3 用 SSE 实现;测试用环回实现)
|
|
6
|
+
* - `createPeerConnection`:`RTCPeerConnection` 工厂(测试用假实现)
|
|
7
|
+
*
|
|
8
|
+
* 角色(见 §8 拍板):**电脑(host) 发起 offer**,手机(guest) 应答。
|
|
9
|
+
* 电脑常驻房间,手机加入后由电脑发 offer —— 手机可能在后台切换,桌面是稳定进程。
|
|
10
|
+
*
|
|
11
|
+
* 状态映射:`connected → open`;`failed/closed → closed`;`disconnected → connecting`(等重连)。
|
|
12
|
+
*/
|
|
13
|
+
import type { Transport, TransportState } from './types'
|
|
14
|
+
import type { KickedInfo, SignalingChannel, SignalingRole } from './signaling'
|
|
15
|
+
import type { IceServerInit } from './ice'
|
|
16
|
+
import { BridgeError, toBridgeError } from '../protocol/errors'
|
|
17
|
+
|
|
18
|
+
export interface RtcTransportOptions {
|
|
19
|
+
role: SignalingRole
|
|
20
|
+
signaling: SignalingChannel
|
|
21
|
+
/**
|
|
22
|
+
* ICE 服务器列表。**不再有内置默认值**(M7,§31):两端都由
|
|
23
|
+
* `resolveIceServers()` 决定(自定义 > 服务端下发 > 缓存 > 空)。
|
|
24
|
+
*/
|
|
25
|
+
iceServers?: IceServerInit[]
|
|
26
|
+
/** 数据通道 label(对两端须一致)。 */
|
|
27
|
+
channelLabel?: string
|
|
28
|
+
/** 注入 `RTCPeerConnection` 工厂(测试用;默认取全局构造器)。 */
|
|
29
|
+
createPeerConnection?: (config: RTCConfiguration) => RTCPeerConnection
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type SignalData =
|
|
33
|
+
| { kind: 'offer'; sdp: RTCSessionDescriptionInit }
|
|
34
|
+
| { kind: 'answer'; sdp: RTCSessionDescriptionInit }
|
|
35
|
+
| { kind: 'candidate'; cand: RTCIceCandidateInit | null }
|
|
36
|
+
|
|
37
|
+
export class RtcTransport implements Transport {
|
|
38
|
+
private _state: TransportState = 'connecting'
|
|
39
|
+
private readonly messageListeners = new Set<(bytes: Uint8Array) => void>()
|
|
40
|
+
private readonly stateListeners = new Set<(state: TransportState) => void>()
|
|
41
|
+
|
|
42
|
+
private pc: RTCPeerConnection | null = null
|
|
43
|
+
private dc: RTCDataChannel | null = null
|
|
44
|
+
private pendingCandidates: RTCIceCandidateInit[] = []
|
|
45
|
+
private started = false
|
|
46
|
+
/** 被顶号(致命):置位后一切重建/重连动作都无意义。 */
|
|
47
|
+
private replaced = false
|
|
48
|
+
|
|
49
|
+
private readonly readyPromise: Promise<void>
|
|
50
|
+
private readyResolve: (() => void) | null = null
|
|
51
|
+
private readyReject: ((error: Error) => void) | null = null
|
|
52
|
+
|
|
53
|
+
constructor(private readonly options: RtcTransportOptions) {
|
|
54
|
+
this.readyPromise = new Promise<void>((resolve, reject) => {
|
|
55
|
+
this.readyResolve = resolve
|
|
56
|
+
this.readyReject = reject
|
|
57
|
+
})
|
|
58
|
+
const sig = options.signaling
|
|
59
|
+
sig.onPeer = (peerId) => this.onPeer(peerId)
|
|
60
|
+
sig.onData = (data) => {
|
|
61
|
+
void this.handleRemote(data)
|
|
62
|
+
}
|
|
63
|
+
sig.onError = (err) => this.fail(err)
|
|
64
|
+
sig.onKicked = (info) => this.onKicked(info)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get state(): TransportState {
|
|
68
|
+
return this._state
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get bufferedAmount(): number {
|
|
72
|
+
return this.dc?.bufferedAmount ?? 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 链路真正可用的 Promise(DataChannel open 时 resolve);失败/关闭时 reject。 */
|
|
76
|
+
whenReady(): Promise<void> {
|
|
77
|
+
return this.readyPromise
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 加入信令房间并开始协商。使用方创建后调用一次(不需要 await,靠状态/`whenReady` 驱动)。 */
|
|
81
|
+
async start(): Promise<void> {
|
|
82
|
+
if (this.started) return
|
|
83
|
+
this.started = true
|
|
84
|
+
try {
|
|
85
|
+
await this.options.signaling.join()
|
|
86
|
+
} catch (err) {
|
|
87
|
+
this.fail(toBridgeError(err))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
send(bytes: Uint8Array): void {
|
|
92
|
+
const dc = this.dc
|
|
93
|
+
if (this._state !== 'open' || !dc || dc.readyState !== 'open') return
|
|
94
|
+
// 注:DOM 定义的 `send` 要求 `ArrayBufferView<ArrayBuffer>`,而 `Uint8Array` 泛型是
|
|
95
|
+
// `ArrayBufferLike`(可能含 SharedArrayBuffer)。实际数据永远是普通 buffer,安全 cast。
|
|
96
|
+
dc.send(bytes as unknown as ArrayBufferView<ArrayBuffer>)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
close(): void {
|
|
100
|
+
if (this._state === 'closed') return
|
|
101
|
+
this.teardownPeer()
|
|
102
|
+
try {
|
|
103
|
+
this.options.signaling.close()
|
|
104
|
+
} catch {
|
|
105
|
+
/* 忽略 */
|
|
106
|
+
}
|
|
107
|
+
this.setState('closed')
|
|
108
|
+
this.readyReject?.(new Error('transport closed'))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
onMessage(listener: (bytes: Uint8Array) => void): () => void {
|
|
112
|
+
this.messageListeners.add(listener)
|
|
113
|
+
return () => {
|
|
114
|
+
this.messageListeners.delete(listener)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
onStateChange(listener: (state: TransportState) => void): () => void {
|
|
119
|
+
this.stateListeners.add(listener)
|
|
120
|
+
return () => {
|
|
121
|
+
this.stateListeners.delete(listener)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ───────────────────────── 内部:信令 → 协商 ─────────────────────────
|
|
126
|
+
|
|
127
|
+
private onPeer(peerId: string | null): void {
|
|
128
|
+
// 被顶号后信令已停用:此时任何对端变化都不该再重建 PC(重建出来也是死链路)
|
|
129
|
+
if (this.replaced) return
|
|
130
|
+
if (peerId === null) {
|
|
131
|
+
// 对端离开:清掉旧连接,回到等待态(手机重连后会重新触发)
|
|
132
|
+
this.teardownPeer()
|
|
133
|
+
this.setState('connecting')
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
// 只有 host 主动发起:guest 必须等 offer 到达才建 PC(否则会建两次 PC、上下文串味)
|
|
137
|
+
if (this.options.role === 'host') {
|
|
138
|
+
this.ensurePC()
|
|
139
|
+
void this.startOffer()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 被服务端顶号(同一台电脑已有别的手机接入)。
|
|
145
|
+
*
|
|
146
|
+
* 顺序是刻意的:**先把错误告知上层,再改状态**。上层(手机端 `connectionStore`)在
|
|
147
|
+
* 看到 `closed` 时会安排自动重连 —— 而重连会把刚接入的那台手机再顶掉(乒乓)。
|
|
148
|
+
* 所以「被顶号」这个事实必须先于状态变化到达。
|
|
149
|
+
*/
|
|
150
|
+
private onKicked(info: KickedInfo): void {
|
|
151
|
+
if (this.replaced) return
|
|
152
|
+
this.replaced = true
|
|
153
|
+
this.fail(
|
|
154
|
+
new BridgeError('E_REPLACED', '该电脑已被另一台手机接管连接', {
|
|
155
|
+
data: { reason: info.reason },
|
|
156
|
+
}),
|
|
157
|
+
)
|
|
158
|
+
this.teardownPeer()
|
|
159
|
+
try {
|
|
160
|
+
this.options.signaling.close()
|
|
161
|
+
} catch {
|
|
162
|
+
/* 忽略 */
|
|
163
|
+
}
|
|
164
|
+
// 状态置 closed 收尾(`_state` 为 closed 时 `close()` 也不会重复处理)
|
|
165
|
+
this.setState('closed')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private ensurePC(): RTCPeerConnection {
|
|
169
|
+
if (this.pc) return this.pc
|
|
170
|
+
const factory = this.options.createPeerConnection ?? defaultFactory()
|
|
171
|
+
const pc = factory({ iceServers: this.options.iceServers ?? [] })
|
|
172
|
+
this.pc = pc
|
|
173
|
+
|
|
174
|
+
pc.onicecandidate = (e) => {
|
|
175
|
+
if (!e.candidate) return
|
|
176
|
+
void this.options.signaling.send({ kind: 'candidate', cand: candidateToJson(e.candidate) })
|
|
177
|
+
}
|
|
178
|
+
pc.onconnectionstatechange = () => this.onConnectionState(pc.connectionState)
|
|
179
|
+
pc.ondatachannel = (e) => this.setupDataChannel(e.channel)
|
|
180
|
+
return pc
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async startOffer(): Promise<void> {
|
|
184
|
+
const pc = this.pc
|
|
185
|
+
if (!pc) return
|
|
186
|
+
if (!this.dc) {
|
|
187
|
+
this.setupDataChannel(pc.createDataChannel(this.options.channelLabel ?? 'virlen'))
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const offer = await pc.createOffer()
|
|
191
|
+
await pc.setLocalDescription(offer)
|
|
192
|
+
await this.options.signaling.send({ kind: 'offer', sdp: pc.localDescription ?? offer })
|
|
193
|
+
} catch (err) {
|
|
194
|
+
this.fail(toBridgeError(err))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async handleRemote(data: unknown): Promise<void> {
|
|
199
|
+
const msg = data as SignalData
|
|
200
|
+
if (!msg || typeof msg !== 'object' || typeof msg.kind !== 'string') return
|
|
201
|
+
try {
|
|
202
|
+
if (msg.kind === 'offer') {
|
|
203
|
+
// 每个 offer 视作新会话:清旧上下文,避免新旧 ICE 串味(同 rtc.js)
|
|
204
|
+
if (this.pc) this.teardownPeer()
|
|
205
|
+
const pc = this.ensurePC()
|
|
206
|
+
await pc.setRemoteDescription(msg.sdp)
|
|
207
|
+
await this.flushCandidates()
|
|
208
|
+
const answer = await pc.createAnswer()
|
|
209
|
+
await pc.setLocalDescription(answer)
|
|
210
|
+
await this.options.signaling.send({ kind: 'answer', sdp: pc.localDescription ?? answer })
|
|
211
|
+
} else if (msg.kind === 'answer') {
|
|
212
|
+
const pc = this.pc
|
|
213
|
+
if (!pc) return
|
|
214
|
+
if (pc.signalingState === 'have-local-offer') {
|
|
215
|
+
await pc.setRemoteDescription(msg.sdp)
|
|
216
|
+
await this.flushCandidates()
|
|
217
|
+
}
|
|
218
|
+
} else if (msg.kind === 'candidate') {
|
|
219
|
+
const pc = this.pc
|
|
220
|
+
if (!pc || !msg.cand) return
|
|
221
|
+
if (pc.remoteDescription && pc.remoteDescription.type) {
|
|
222
|
+
await pc.addIceCandidate(msg.cand).catch(() => {})
|
|
223
|
+
} else {
|
|
224
|
+
this.pendingCandidates.push(msg.cand)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
this.fail(toBridgeError(err))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async flushCandidates(): Promise<void> {
|
|
233
|
+
const list = this.pendingCandidates
|
|
234
|
+
this.pendingCandidates = []
|
|
235
|
+
const pc = this.pc
|
|
236
|
+
if (!pc) return
|
|
237
|
+
for (const cand of list) {
|
|
238
|
+
await pc.addIceCandidate(cand).catch(() => {})
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private setupDataChannel(channel: RTCDataChannel): void {
|
|
243
|
+
this.dc = channel
|
|
244
|
+
try {
|
|
245
|
+
channel.binaryType = 'arraybuffer'
|
|
246
|
+
} catch {
|
|
247
|
+
/* 某些实现只读 */
|
|
248
|
+
}
|
|
249
|
+
channel.onopen = () => {
|
|
250
|
+
this.setState('open')
|
|
251
|
+
this.readyResolve?.()
|
|
252
|
+
}
|
|
253
|
+
channel.onclose = () => {
|
|
254
|
+
// 通道关闭:等待对端重连(不永久 closed)
|
|
255
|
+
this.setState('connecting')
|
|
256
|
+
}
|
|
257
|
+
channel.onmessage = (e: MessageEvent) => {
|
|
258
|
+
const bytes = toBytes(e.data)
|
|
259
|
+
if (!bytes) return
|
|
260
|
+
for (const listener of [...this.messageListeners]) listener(bytes)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private onConnectionState(connState: RTCPeerConnectionState): void {
|
|
265
|
+
switch (connState) {
|
|
266
|
+
case 'connected':
|
|
267
|
+
// ⚠️ **不在这里置 open**:`connectionState` 可能**早于** DataChannel 的 `open` 触发。
|
|
268
|
+
// 尤其 guest(手机)侧:dc 是 `ondatachannel` **异步交付**的,此刻 `this.dc` 可能还是 null
|
|
269
|
+
// → 上层 `whenReady` 已 resolve、`transport.state==='open'`,但 `send()` 因 dc 未就绪而
|
|
270
|
+
// **静默丢弃**(2026-09 真机踩过:手机“连接超时”,电脑端日志 open→4s→connecting)。
|
|
271
|
+
// 统一以 `dc.onopen` 为准 —— 回归用例在 rtc-transport.test.ts。
|
|
272
|
+
break
|
|
273
|
+
case 'failed':
|
|
274
|
+
case 'closed':
|
|
275
|
+
this.setState('closed')
|
|
276
|
+
this.readyReject?.(new Error(`peer connection ${connState}`))
|
|
277
|
+
break
|
|
278
|
+
case 'disconnected':
|
|
279
|
+
this.setState('connecting')
|
|
280
|
+
break
|
|
281
|
+
default:
|
|
282
|
+
break
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private teardownPeer(): void {
|
|
287
|
+
this.pendingCandidates = []
|
|
288
|
+
try {
|
|
289
|
+
this.dc?.close()
|
|
290
|
+
} catch {
|
|
291
|
+
/* 忽略 */
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
this.pc?.close()
|
|
295
|
+
} catch {
|
|
296
|
+
/* 忽略 */
|
|
297
|
+
}
|
|
298
|
+
this.dc = null
|
|
299
|
+
this.pc = null
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private fail(error: Error): void {
|
|
303
|
+
this.readyReject?.(error)
|
|
304
|
+
this.onErrorListeners?.(error)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private onErrorListeners: ((error: Error) => void) | null = null
|
|
308
|
+
|
|
309
|
+
/** 订阅「链路级错误」(区别于状态变化);返回取消订阅函数。 */
|
|
310
|
+
onError(listener: (error: Error) => void): () => void {
|
|
311
|
+
this.onErrorListeners = listener
|
|
312
|
+
return () => {
|
|
313
|
+
if (this.onErrorListeners === listener) this.onErrorListeners = null
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private setState(state: TransportState): void {
|
|
318
|
+
if (this._state === state) return
|
|
319
|
+
this._state = state
|
|
320
|
+
for (const listener of [...this.stateListeners]) listener(state)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function defaultFactory(): (config: RTCConfiguration) => RTCPeerConnection {
|
|
325
|
+
return (config: RTCConfiguration) => {
|
|
326
|
+
const Ctor = (globalThis as { RTCPeerConnection?: new (c: RTCConfiguration) => RTCPeerConnection })
|
|
327
|
+
.RTCPeerConnection
|
|
328
|
+
if (!Ctor) {
|
|
329
|
+
throw new Error('当前环境没有 RTCPeerConnection,未注入 createPeerConnection')
|
|
330
|
+
}
|
|
331
|
+
return new Ctor(config)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function candidateToJson(candidate: RTCIceCandidate): RTCIceCandidateInit {
|
|
336
|
+
const withJson = candidate as RTCIceCandidate & { toJSON?: () => RTCIceCandidateInit }
|
|
337
|
+
return typeof withJson.toJSON === 'function' ? withJson.toJSON() : (candidate as unknown as RTCIceCandidateInit)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function toBytes(data: unknown): Uint8Array | null {
|
|
341
|
+
if (data instanceof Uint8Array) return data
|
|
342
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
|
343
|
+
return null
|
|
344
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 信令通道抽象 + SSE 实现(对接 virlen-api `/api/rtc`)。
|
|
3
|
+
*
|
|
4
|
+
* 分层理由(docs/phone-control-bridge.md §6):**RtcTransport 只依赖本接口**,不认识 HTTP/SSE。
|
|
5
|
+
* 于是:
|
|
6
|
+
* - 单测可注入「环回信令」验证 offer/answer 编排(不碰网络);
|
|
7
|
+
* - 将来信令换成 WebSocket / 或挪到 Rust,**只换一个实现**。
|
|
8
|
+
*
|
|
9
|
+
* 协议(virlen-api `/api/rtc`,M6 设备房间语义,见 §30.3):
|
|
10
|
+
* POST join { room, role, deviceKey?, clientName? }
|
|
11
|
+
* → { id, room, role, ip, hostOnline, peers }
|
|
12
|
+
* GET events ?room&id
|
|
13
|
+
* → SSE:{ type: 'peers' | 'peer-joined' | 'peer-left' | 'signal' | 'kicked' }
|
|
14
|
+
* POST signal { room, from, to, data }
|
|
15
|
+
* POST status { rooms: string[] } → { rooms: RoomStatus[] }(批量在线查询,不占房间)
|
|
16
|
+
*
|
|
17
|
+
* 角色语义(**M6 起服务器强制**):
|
|
18
|
+
* - `room` 由**电脑设备 key** 派生(`roomFor`),不再由客户端自定;
|
|
19
|
+
* - 同一房间**最多一个 host、一个 guest**;新 guest 加入会**顶掉**旧 guest(旧端收 `kicked`);
|
|
20
|
+
* - 同 key 的 host 重入 = 接管(电脑重启后旧连接多是死连接)。
|
|
21
|
+
*/
|
|
22
|
+
import { BridgeError } from '../protocol/errors'
|
|
23
|
+
import { roomFor, type RoomStatus } from '../protocol/identity'
|
|
24
|
+
|
|
25
|
+
export type SignalingRole = 'host' | 'guest'
|
|
26
|
+
|
|
27
|
+
/** 被顶号的通知内容(服务器 `kicked` 事件)。 */
|
|
28
|
+
export interface KickedInfo {
|
|
29
|
+
/** `replaced` = 同一台电脑已有新的手机接入(本端被顶掉);`takeover` = 电脑端重入接管。 */
|
|
30
|
+
reason: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `EventSource` 的最小形状(注入测试替身用)。 */
|
|
34
|
+
export interface EventSourceLike {
|
|
35
|
+
onopen: ((event: unknown) => void) | null
|
|
36
|
+
onerror: ((event: unknown) => void) | null
|
|
37
|
+
onmessage: ((event: { data: string }) => void) | null
|
|
38
|
+
close(): void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SignalingChannel {
|
|
42
|
+
readonly selfId: string
|
|
43
|
+
readonly peerId: string | null
|
|
44
|
+
onPeer: ((peerId: string | null) => void) | null
|
|
45
|
+
onData: ((data: unknown) => void) | null
|
|
46
|
+
onError: ((error: Error) => void) | null
|
|
47
|
+
/**
|
|
48
|
+
* 本端被服务端顶号(**不是**普通掉线)。
|
|
49
|
+
*
|
|
50
|
+
* 为什么必须独立成回调而不是兼做 `onError`:上层要做出**完全相反的动作** ——
|
|
51
|
+
* 普通掉线自动重连,被顶号**绝不能自动重连**(否则两台手机会互相顶来顶去)。
|
|
52
|
+
*/
|
|
53
|
+
onKicked: ((info: KickedInfo) => void) | null
|
|
54
|
+
/** 加入房间;返回本端 id。 */
|
|
55
|
+
join(): Promise<string>
|
|
56
|
+
/** 把一条信令发给当前对端(无对端时 no-op)。 */
|
|
57
|
+
send(data: unknown): Promise<void>
|
|
58
|
+
close(): void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SseSignalingOptions {
|
|
62
|
+
/** 信令基址,如 `https://virlen.cn/api/rtc/`(末尾斜杠可有可无)。 */
|
|
63
|
+
baseUrl: string
|
|
64
|
+
room: string
|
|
65
|
+
role: SignalingRole
|
|
66
|
+
/** 本端设备 key(电脑 `dk-…` / 手机 `mk-…`);服务器用于日志与 host 房主校验。 */
|
|
67
|
+
deviceKey?: string
|
|
68
|
+
/** 本端显示名(服务器日志可读性)。 */
|
|
69
|
+
clientName?: string
|
|
70
|
+
/**
|
|
71
|
+
* 加入时要求房间内已有 host(手机端用)。
|
|
72
|
+
* 无 host 时 `join()` 直接抛错 —— 比「先占住 guest 位再等 15 秒超时」诚实得多。
|
|
73
|
+
*/
|
|
74
|
+
requireHostOnline?: boolean
|
|
75
|
+
/** 注入(测试用);默认取全局 `fetch`。 */
|
|
76
|
+
fetchImpl?: typeof fetch
|
|
77
|
+
/** 注入(测试用);默认 `(url) => new EventSource(url)`。 */
|
|
78
|
+
eventSourceFactory?: (url: string) => EventSourceLike
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface JoinResponse {
|
|
82
|
+
id: string
|
|
83
|
+
room: string
|
|
84
|
+
role: string
|
|
85
|
+
ip?: string
|
|
86
|
+
peers?: Array<{ id: string; role: string }>
|
|
87
|
+
/** 加入时房间里是否已有 host(M6)。 */
|
|
88
|
+
hostOnline?: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type ServerEvent =
|
|
92
|
+
| { type: 'peers'; peers: Array<{ id: string; role: string }> }
|
|
93
|
+
| { type: 'peer-joined'; peer: { id: string; role: string } }
|
|
94
|
+
| { type: 'peer-left'; id: string }
|
|
95
|
+
| { type: 'signal'; from: string; data: unknown }
|
|
96
|
+
| { type: 'kicked'; reason?: string }
|
|
97
|
+
|
|
98
|
+
function normalizeBase(baseUrl: string): string {
|
|
99
|
+
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class SseSignalingClient implements SignalingChannel {
|
|
103
|
+
selfId = ''
|
|
104
|
+
peerId: string | null = null
|
|
105
|
+
onPeer: ((peerId: string | null) => void) | null = null
|
|
106
|
+
onData: ((data: unknown) => void) | null = null
|
|
107
|
+
onError: ((error: Error) => void) | null = null
|
|
108
|
+
onKicked: ((info: KickedInfo) => void) | null = null
|
|
109
|
+
|
|
110
|
+
private readonly base: string
|
|
111
|
+
private es: EventSourceLike | null = null
|
|
112
|
+
private closed = false
|
|
113
|
+
/** 被顶号后置位:此后一切信令发送都无意义(房间已把我们移除)。 */
|
|
114
|
+
private kicked = false
|
|
115
|
+
|
|
116
|
+
constructor(private readonly options: SseSignalingOptions) {
|
|
117
|
+
this.base = normalizeBase(options.baseUrl)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async join(): Promise<string> {
|
|
121
|
+
const res = await this.fetch()(this.base + 'join', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'content-type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
room: this.options.room,
|
|
126
|
+
role: this.options.role,
|
|
127
|
+
...(this.options.deviceKey ? { deviceKey: this.options.deviceKey } : {}),
|
|
128
|
+
...(this.options.clientName ? { clientName: this.options.clientName } : {}),
|
|
129
|
+
}),
|
|
130
|
+
})
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
const detail = await readErrorDetail(res)
|
|
133
|
+
throw new BridgeError('E_TRANSPORT', `信令 join 失败:HTTP ${res.status}${detail}`)
|
|
134
|
+
}
|
|
135
|
+
const info = (await res.json()) as JoinResponse
|
|
136
|
+
this.selfId = info.id
|
|
137
|
+
// 手机端:电脑不在线就别占着 guest 位 —— 直接失败,让上层给「电脑不在线」
|
|
138
|
+
if (this.options.role === 'guest' && this.options.requireHostOnline && info.hostOnline === false) {
|
|
139
|
+
throw new BridgeError('E_TRANSPORT', '电脑不在线(信令房间内没有 host)', {
|
|
140
|
+
data: { reason: 'host-offline' },
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
this.openEvents()
|
|
144
|
+
// 房间里可能已有对端(本端后加入)→ 立刻通知,避免只依赖 peer-joined
|
|
145
|
+
const existing = (info.peers ?? []).find((p) => p.id !== this.selfId)
|
|
146
|
+
if (existing) this.setPeer(existing.id)
|
|
147
|
+
return this.selfId
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async send(data: unknown): Promise<void> {
|
|
151
|
+
if (!this.peerId || this.kicked || this.closed) return
|
|
152
|
+
await this.fetch()(this.base + 'signal', {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: { 'content-type': 'application/json' },
|
|
155
|
+
body: JSON.stringify({
|
|
156
|
+
room: this.options.room,
|
|
157
|
+
from: this.selfId,
|
|
158
|
+
to: this.peerId,
|
|
159
|
+
data,
|
|
160
|
+
}),
|
|
161
|
+
}).catch(() => {
|
|
162
|
+
/* 信令转发失败:ICE 会重试/另择候选,不回滚 */
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
close(): void {
|
|
167
|
+
if (this.closed) return
|
|
168
|
+
this.closed = true
|
|
169
|
+
try {
|
|
170
|
+
this.es?.close()
|
|
171
|
+
} catch {
|
|
172
|
+
/* 忽略 */
|
|
173
|
+
}
|
|
174
|
+
this.es = null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private fetch(): typeof fetch {
|
|
178
|
+
if (this.options.fetchImpl) return this.options.fetchImpl
|
|
179
|
+
if (typeof fetch === 'function') return fetch
|
|
180
|
+
throw new BridgeError('E_INTERNAL', '当前环境没有 fetch,未注入 fetchImpl')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private openEvents(): void {
|
|
184
|
+
const factory = this.options.eventSourceFactory ?? defaultEventSourceFactory()
|
|
185
|
+
const url = `${this.base}events?room=${encodeURIComponent(this.options.room)}&id=${encodeURIComponent(this.selfId)}`
|
|
186
|
+
const es = factory(url)
|
|
187
|
+
es.onopen = () => {
|
|
188
|
+
/* 连接就绪:无操作(首个事件即 peers) */
|
|
189
|
+
}
|
|
190
|
+
es.onerror = () => {
|
|
191
|
+
if (!this.closed) this.onError?.(new Error('信令事件流异常'))
|
|
192
|
+
}
|
|
193
|
+
es.onmessage = (ev) => this.route(ev.data)
|
|
194
|
+
this.es = es
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private route(raw: string): void {
|
|
198
|
+
let msg: ServerEvent
|
|
199
|
+
try {
|
|
200
|
+
msg = JSON.parse(raw) as ServerEvent
|
|
201
|
+
} catch {
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
switch (msg.type) {
|
|
205
|
+
case 'peers': {
|
|
206
|
+
const other = msg.peers.find((p) => p.id !== this.selfId)
|
|
207
|
+
if (other) this.setPeer(other.id)
|
|
208
|
+
break
|
|
209
|
+
}
|
|
210
|
+
case 'peer-joined':
|
|
211
|
+
this.setPeer(msg.peer.id)
|
|
212
|
+
break
|
|
213
|
+
case 'peer-left':
|
|
214
|
+
this.setPeer(null)
|
|
215
|
+
break
|
|
216
|
+
case 'kicked':
|
|
217
|
+
this.handleKicked(msg.reason ?? 'replaced')
|
|
218
|
+
break
|
|
219
|
+
case 'signal':
|
|
220
|
+
this.onData?.(msg.data)
|
|
221
|
+
break
|
|
222
|
+
default:
|
|
223
|
+
break
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 被顶号:先通知上层(它要据此关掉自动重连),再关掉事件流。
|
|
229
|
+
*
|
|
230
|
+
* 顺序很重要:`onKicked` 里上层会去 `transport.close()`,而 reconnect 的判定
|
|
231
|
+
* 就在那条路径上 —— 先把「被顶号」这个事实告知,再关流,才不会出现「先判定为重连、后知道是顶号」。
|
|
232
|
+
*/
|
|
233
|
+
private handleKicked(reason: string): void {
|
|
234
|
+
if (this.kicked) return
|
|
235
|
+
this.kicked = true
|
|
236
|
+
this.peerId = null
|
|
237
|
+
try {
|
|
238
|
+
this.onKicked?.({ reason })
|
|
239
|
+
} finally {
|
|
240
|
+
this.close()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private setPeer(peerId: string | null): void {
|
|
245
|
+
if (this.peerId === peerId) return
|
|
246
|
+
this.peerId = peerId
|
|
247
|
+
this.onPeer?.(peerId)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function defaultEventSourceFactory(): (url: string) => EventSourceLike {
|
|
252
|
+
return (url: string) => {
|
|
253
|
+
const ES = (globalThis as unknown as { EventSource?: new (url: string) => EventSourceLike }).EventSource
|
|
254
|
+
if (!ES) {
|
|
255
|
+
throw new BridgeError('E_INTERNAL', '当前环境没有 EventSource,未注入 eventSourceFactory')
|
|
256
|
+
}
|
|
257
|
+
return new ES(url)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 从失败的 join 应答里取一句可读的原因(服务器给 `{ error }`)。 */
|
|
262
|
+
async function readErrorDetail(res: Response): Promise<string> {
|
|
263
|
+
try {
|
|
264
|
+
const body = (await res.json()) as { error?: unknown }
|
|
265
|
+
if (typeof body?.error === 'string' && body.error) return `(${body.error})`
|
|
266
|
+
} catch {
|
|
267
|
+
/* 非 JSON 应答:忽略 */
|
|
268
|
+
}
|
|
269
|
+
return ''
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 批量查房间在线状态(**不加入房间**,不占 guest 位)。 */
|
|
273
|
+
export interface FetchRoomStatusOptions {
|
|
274
|
+
/** 信令基址,如 `https://virlen.cn/api/rtc/`。 */
|
|
275
|
+
baseUrl: string
|
|
276
|
+
/** 要查的房间号(由 `roomFor(电脑 key)` 派生)。 */
|
|
277
|
+
rooms: string[]
|
|
278
|
+
fetchImpl?: typeof fetch
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 查询一批房间的在线状态。
|
|
283
|
+
*
|
|
284
|
+
* 失败时**返回空数组而不抛错**:手机端列表的「在线状态」只是提示,
|
|
285
|
+
* 信令服务不可用/版本过旧(无 `/status`)时应当退化为「未知」,而不是把整个列表打挂。
|
|
286
|
+
*/
|
|
287
|
+
export async function fetchRoomStatus(options: FetchRoomStatusOptions): Promise<RoomStatus[]> {
|
|
288
|
+
const rooms = options.rooms.filter((r) => typeof r === 'string' && r)
|
|
289
|
+
if (rooms.length === 0) return []
|
|
290
|
+
const fetchImpl = options.fetchImpl ?? (typeof fetch === 'function' ? fetch : null)
|
|
291
|
+
if (!fetchImpl) return []
|
|
292
|
+
try {
|
|
293
|
+
const res = await fetchImpl(normalizeBase(options.baseUrl) + 'status', {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
headers: { 'content-type': 'application/json' },
|
|
296
|
+
body: JSON.stringify({ rooms }),
|
|
297
|
+
})
|
|
298
|
+
if (!res.ok) return []
|
|
299
|
+
const body = (await res.json()) as { rooms?: RoomStatus[] }
|
|
300
|
+
return Array.isArray(body?.rooms) ? body.rooms : []
|
|
301
|
+
} catch {
|
|
302
|
+
return []
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 便利封装:`电脑 key → 在线?`(手机端列表用)。 */
|
|
307
|
+
export async function fetchHostOnlineMap(
|
|
308
|
+
baseUrl: string,
|
|
309
|
+
hostKeys: string[],
|
|
310
|
+
fetchImpl?: typeof fetch,
|
|
311
|
+
): Promise<Map<string, boolean>> {
|
|
312
|
+
const statuses = await fetchRoomStatus({ baseUrl, rooms: hostKeys.map(roomFor), ...(fetchImpl ? { fetchImpl } : {}) })
|
|
313
|
+
const byRoom = new Map(statuses.map((s) => [s.room, s.hostOnline]))
|
|
314
|
+
const out = new Map<string, boolean>()
|
|
315
|
+
for (const key of hostKeys) out.set(key, byRoom.get(roomFor(key)) ?? false)
|
|
316
|
+
return out
|
|
317
|
+
}
|