virlen-remote 0.5.0 → 0.7.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 (61) hide show
  1. package/CHANGELOG.md +373 -169
  2. package/README.md +389 -201
  3. package/dist/index.d.ts +10 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +10 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/protocol/agents.d.ts +14 -0
  8. package/dist/protocol/agents.d.ts.map +1 -0
  9. package/dist/protocol/agents.js +14 -0
  10. package/dist/protocol/agents.js.map +1 -0
  11. package/dist/protocol/api.d.ts +370 -1
  12. package/dist/protocol/api.d.ts.map +1 -1
  13. package/dist/protocol/api.js.map +1 -1
  14. package/dist/protocol/compress.d.ts +55 -0
  15. package/dist/protocol/compress.d.ts.map +1 -0
  16. package/dist/protocol/compress.js +60 -0
  17. package/dist/protocol/compress.js.map +1 -0
  18. package/dist/protocol/files.d.ts +261 -0
  19. package/dist/protocol/files.d.ts.map +1 -0
  20. package/dist/protocol/files.js +506 -0
  21. package/dist/protocol/files.js.map +1 -0
  22. package/dist/protocol/host.d.ts +24 -1
  23. package/dist/protocol/host.d.ts.map +1 -1
  24. package/dist/protocol/host.js +9 -1
  25. package/dist/protocol/host.js.map +1 -1
  26. package/dist/protocol/message-files.d.ts +79 -0
  27. package/dist/protocol/message-files.d.ts.map +1 -0
  28. package/dist/protocol/message-files.js +118 -0
  29. package/dist/protocol/message-files.js.map +1 -0
  30. package/dist/protocol/tool-args.d.ts +95 -0
  31. package/dist/protocol/tool-args.d.ts.map +1 -0
  32. package/dist/protocol/tool-args.js +439 -0
  33. package/dist/protocol/tool-args.js.map +1 -0
  34. package/dist/testing/mock-host.d.ts +44 -1
  35. package/dist/testing/mock-host.d.ts.map +1 -1
  36. package/dist/testing/mock-host.js +439 -10
  37. package/dist/testing/mock-host.js.map +1 -1
  38. package/dist/transport/rtc.d.ts +17 -0
  39. package/dist/transport/rtc.d.ts.map +1 -1
  40. package/dist/transport/rtc.js +49 -12
  41. package/dist/transport/rtc.js.map +1 -1
  42. package/dist/transport/signaling.d.ts +12 -1
  43. package/dist/transport/signaling.d.ts.map +1 -1
  44. package/dist/transport/signaling.js +17 -3
  45. package/dist/transport/signaling.js.map +1 -1
  46. package/package.json +69 -69
  47. package/src/index.ts +270 -184
  48. package/src/protocol/agents.ts +13 -0
  49. package/src/protocol/api.ts +974 -615
  50. package/src/protocol/compress.ts +65 -0
  51. package/src/protocol/files.ts +544 -0
  52. package/src/protocol/host.ts +205 -165
  53. package/src/protocol/message-actions.ts +60 -60
  54. package/src/protocol/message-files.ts +161 -0
  55. package/src/protocol/pairing.ts +254 -254
  56. package/src/protocol/tool-args.ts +478 -0
  57. package/src/testing/mock-host.ts +1285 -740
  58. package/src/transport/link-kind.ts +222 -222
  59. package/src/transport/rtc.ts +381 -344
  60. package/src/transport/signaling.ts +331 -317
  61. package/src/transport/transfer-tier.ts +64 -64
@@ -1,317 +1,331 @@
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
- }
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
+ /**
307
+ * 便利封装:`电脑 key → 在线?`(手机端列表用)。
308
+ *
309
+ * ⚠️ **问不到的电脑**(信令服务不可达 / 老服务没有 `/status` / 应答里没有这一间)
310
+ * **不进 Map**,消费方读到 `undefined` 就显示「状态未知」—— 把「不知道」变成 `false`
311
+ * 是一句假话:名单上每一台都会变成「电脑不在线」,而它们可能都好端端在网上
312
+ *(真机现场:电脑端「等待手机连接…」、手机端一行行全是「电脑不在线」,实际那个房间在不在
313
+ * 只取决于这次查询成不成功)。判「在线」与判「离线」都只能看服务端的明确答复。
314
+ *
315
+ * 返回类型仍是 `Map<string, boolean>`:缺键的语义就是「没问到」,而 `Map.get` 本来就返回
316
+ * `boolean | undefined` —— 消费方(手机端登录页的 `DeviceRow`)已经在渲染「状态未知」了。
317
+ */
318
+ export async function fetchHostOnlineMap(
319
+ baseUrl: string,
320
+ hostKeys: string[],
321
+ fetchImpl?: typeof fetch,
322
+ ): Promise<Map<string, boolean>> {
323
+ const statuses = await fetchRoomStatus({ baseUrl, rooms: hostKeys.map(roomFor), ...(fetchImpl ? { fetchImpl } : {}) })
324
+ const byRoom = new Map(statuses.map((s) => [s.room, s.hostOnline]))
325
+ const out = new Map<string, boolean>()
326
+ for (const key of hostKeys) {
327
+ const online = byRoom.get(roomFor(key))
328
+ if (typeof online === 'boolean') out.set(key, online)
329
+ }
330
+ return out
331
+ }