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,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BroadcastChannel transport —— 同源跨上下文(两个 tab)直连。
|
|
3
|
+
*
|
|
4
|
+
* 用途:M2 的**浏览器联调**。宿主 harness 与手机页放在同一源的两个 tab,
|
|
5
|
+
* 用同名 `BroadcastChannel` 直连 —— **不依赖 WebRTC、不依赖信令服务器**(docs/phone-control-bridge.md §9-M2)。
|
|
6
|
+
*
|
|
7
|
+
* 约束:**仅同源**。生产不可用(生产走 RTC transport,M3)。
|
|
8
|
+
* 同一上下文内创建两个同名实例也能互通(BroadcastChannel 不会回声给发送者自身)。
|
|
9
|
+
*/
|
|
10
|
+
import type { Transport, TransportState } from './types'
|
|
11
|
+
|
|
12
|
+
export class BroadcastTransport implements Transport {
|
|
13
|
+
private readonly channel: BroadcastChannel
|
|
14
|
+
private _state: TransportState = 'open'
|
|
15
|
+
private readonly messageListeners = new Set<(bytes: Uint8Array) => void>()
|
|
16
|
+
private readonly stateListeners = new Set<(state: TransportState) => void>()
|
|
17
|
+
|
|
18
|
+
constructor(channelName: string) {
|
|
19
|
+
this.channel = new BroadcastChannel(channelName)
|
|
20
|
+
this.channel.onmessage = (event: MessageEvent) => {
|
|
21
|
+
if (this._state !== 'open') return
|
|
22
|
+
const bytes = toBytes(event.data)
|
|
23
|
+
if (!bytes) return
|
|
24
|
+
// 异步投递,模拟真实链路的异步边界(与 memory transport 一致)
|
|
25
|
+
queueMicrotask(() => {
|
|
26
|
+
if (this._state !== 'open') return
|
|
27
|
+
for (const listener of [...this.messageListeners]) listener(bytes)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get state(): TransportState {
|
|
33
|
+
return this._state
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get bufferedAmount(): number {
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
send(bytes: Uint8Array): void {
|
|
41
|
+
if (this._state !== 'open') return
|
|
42
|
+
// 结构化克隆:Uint8Array 可跨 tab 直接传递(不复制成普通数组,避免体积膨胀)
|
|
43
|
+
this.channel.postMessage(bytes)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
close(): void {
|
|
47
|
+
if (this._state === 'closed') return
|
|
48
|
+
this._state = 'closed'
|
|
49
|
+
try {
|
|
50
|
+
this.channel.close()
|
|
51
|
+
} catch {
|
|
52
|
+
/* 忽略 */
|
|
53
|
+
}
|
|
54
|
+
for (const listener of [...this.stateListeners]) listener(this._state)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
onMessage(listener: (bytes: Uint8Array) => void): () => void {
|
|
58
|
+
this.messageListeners.add(listener)
|
|
59
|
+
return () => {
|
|
60
|
+
this.messageListeners.delete(listener)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
onStateChange(listener: (state: TransportState) => void): () => void {
|
|
65
|
+
this.stateListeners.add(listener)
|
|
66
|
+
return () => {
|
|
67
|
+
this.stateListeners.delete(listener)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toBytes(data: unknown): Uint8Array | null {
|
|
73
|
+
if (data instanceof Uint8Array) return data
|
|
74
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 建立一对同名 channel 的 transport(同上下文互通用)。 */
|
|
79
|
+
export function createBroadcastPair(channelName: string): [BroadcastTransport, BroadcastTransport] {
|
|
80
|
+
return [new BroadcastTransport(channelName), new BroadcastTransport(channelName)]
|
|
81
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICE 配置 —— **取用策略**(M7,§31)与两端同一份实现。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么要有这个模块
|
|
5
|
+
*
|
|
6
|
+
* 在此之前,TURN 的 `username` / `credential` 是**硬编码在两端源码里**的(`DEFAULT_ICE`)。
|
|
7
|
+
* 两个端都要开源,这就等于把中继带宽白送出去 —— 所以规则改成:
|
|
8
|
+
*
|
|
9
|
+
* 1. **客户端源码里不再有任何 ICE 凭证**,默认值一律向**信令服务**要(`GET <信令基址>/ice`);
|
|
10
|
+
* 2. 用户可自填 ICE(自建 coturn / 公共 STUN / 企业内网),**自定义优先**;
|
|
11
|
+
* 3. 服务端下发的配置在本地缓存一段时间 —— 信令偶发抖动时不至于退化成「无 STUN 直连」。
|
|
12
|
+
*
|
|
13
|
+
* ## 优先级(`resolveIceServers`)
|
|
14
|
+
*
|
|
15
|
+
* ```
|
|
16
|
+
* 自定义(localStorage['virlen.rtc.ice'],非空且合法)
|
|
17
|
+
* > 服务端下发的本地缓存(TTL 内)
|
|
18
|
+
* > 服务端下发(本次现取)
|
|
19
|
+
* > 过期缓存(拿不到就是拿不到,用旧的也比没有强)
|
|
20
|
+
* > 空(仅本机候选,局域网可用、跨网多半不可用)
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* ## 为什么放在共享包而不是各端各写一份
|
|
24
|
+
*
|
|
25
|
+
* 与 `pairing.ts` / `identity.ts` 同一个理由(§30.2):两端口径分叉只会在真机上暴露。
|
|
26
|
+
* 「哪来的 ICE、还剩几个、是不是降级了」在两端必须**用同一套判定与同一套文案**作答。
|
|
27
|
+
*
|
|
28
|
+
* 零运行时依赖;不直接碰 `localStorage`(用注入的 `IceStoragePort`),因此 Node 侧可测。
|
|
29
|
+
*/
|
|
30
|
+
import { BridgeError } from '../protocol/errors'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ICE 服务器条目(`RTCIceServer` 的结构子集)。
|
|
34
|
+
*
|
|
35
|
+
* 刻意**不直接用 DOM 的 `RTCIceServer`**:这个类型要跨端(含 Node 侧的用例)使用,
|
|
36
|
+
* 引 DOM 类型会把「本包需要 DOM lib」这条隐含约束扩散到消费方。结构上完全兼容,
|
|
37
|
+
* 传给 `new RTCPeerConnection({ iceServers })` 无需转换。
|
|
38
|
+
*/
|
|
39
|
+
export interface IceServerInit {
|
|
40
|
+
urls: string | string[]
|
|
41
|
+
username?: string
|
|
42
|
+
credential?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 服务端应答的版本号(载荷形状变了就 +1,客户端据此决定认不认)。 */
|
|
46
|
+
export const ICE_CONFIG_VERSION = 1
|
|
47
|
+
|
|
48
|
+
/** 相对信令基址的路径:`GET <base>/ice`。 */
|
|
49
|
+
export const ICE_API_PATH = 'ice'
|
|
50
|
+
|
|
51
|
+
/** 用户自定义 ICE 的存放键(与既有 `virlen.rtc.ice` 保持兼容,老用户的自定义配置不丢)。 */
|
|
52
|
+
export const ICE_CUSTOM_STORAGE_KEY = 'virlen.rtc.ice'
|
|
53
|
+
|
|
54
|
+
/** 服务端下发配置的缓存键。 */
|
|
55
|
+
export const ICE_REMOTE_STORAGE_KEY = 'virlen.rtc.ice.remote'
|
|
56
|
+
|
|
57
|
+
/** 下发配置的本地缓存时长(6 小时)—— 够短,跟着服务端改配置走;够长,不每次连接都请求。 */
|
|
58
|
+
export const ICE_CACHE_TTL_MS = 6 * 60 * 60 * 1000
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 取默认值的超时(3 秒)。
|
|
62
|
+
*
|
|
63
|
+
* 刻意**短**:它是连接前的**前置**步骤,卡住就是「用户点连接后干等」。
|
|
64
|
+
* 超时即降级(缓存 / 空),而不是把整个连接流程拖住。
|
|
65
|
+
*/
|
|
66
|
+
export const ICE_FETCH_TIMEOUT_MS = 3000
|
|
67
|
+
|
|
68
|
+
/** 存储端口(两端都传 `localStorage`;用例传假实现)。 */
|
|
69
|
+
export interface IceStoragePort {
|
|
70
|
+
getItem(key: string): string | null
|
|
71
|
+
setItem(key: string, value: string): void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 本次实际用的 ICE 从哪来。 */
|
|
75
|
+
export type IceSource = 'custom' | 'remote' | 'cache' | 'stale-cache' | 'none'
|
|
76
|
+
|
|
77
|
+
export interface ResolvedIceServers {
|
|
78
|
+
/** 最终要交给 `RTCPeerConnection` 的列表(可能为空 = 只用本机候选)。 */
|
|
79
|
+
servers: IceServerInit[]
|
|
80
|
+
source: IceSource
|
|
81
|
+
/** 给用户看的一句话(设置页直接渲染)。 */
|
|
82
|
+
detail: string
|
|
83
|
+
/** 降级 / 异常时的补充说明(没有则 `undefined`)。 */
|
|
84
|
+
warning?: string
|
|
85
|
+
/** 自定义配置写了但解析失败时的原因(UI 用来标红,不阻断连接)。 */
|
|
86
|
+
customError?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ------------------------------ 解析 / 清洗 ------------------------------ */
|
|
90
|
+
|
|
91
|
+
function isNonEmptyString(v: unknown): v is string {
|
|
92
|
+
return typeof v === 'string' && v.trim().length > 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 把任意输入清洗成合法的 ICE 列表。
|
|
97
|
+
*
|
|
98
|
+
* 宽容但**不猜**:`urls` 必须是字符串或字符串数组(至少一项非空),
|
|
99
|
+
* `username` / `credential` 只在是字符串时保留 —— 其余一律丢弃。
|
|
100
|
+
* 与其让一个坏条目把 `new RTCPeerConnection()` 整个搞崩(浏览器直接抛错),
|
|
101
|
+
* 不如在这里筛掉:ICE 少一个服务器只是连通性变差,抛错则是功能完全不可用。
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ **保留 `urls` 的书写形状**(数组就还是数组):清洗结果会回填到设置页文本框
|
|
104
|
+
* (`readCustomIceText`),把 `['a']` 折叠成 `'a'` 会让用户改过的配置莫名其妙变形。
|
|
105
|
+
*/
|
|
106
|
+
export function sanitizeIceServers(input: unknown): IceServerInit[] {
|
|
107
|
+
if (!Array.isArray(input)) return []
|
|
108
|
+
const out: IceServerInit[] = []
|
|
109
|
+
for (const raw of input) {
|
|
110
|
+
if (!raw || typeof raw !== 'object') continue
|
|
111
|
+
const item = raw as Partial<IceServerInit>
|
|
112
|
+
const urls: string | string[] | null = Array.isArray(item.urls)
|
|
113
|
+
? item.urls.filter(isNonEmptyString).map((u) => u.trim())
|
|
114
|
+
: isNonEmptyString(item.urls)
|
|
115
|
+
? item.urls.trim()
|
|
116
|
+
: null
|
|
117
|
+
if (urls === null || (Array.isArray(urls) && urls.length === 0)) continue
|
|
118
|
+
const entry: IceServerInit = { urls }
|
|
119
|
+
if (isNonEmptyString(item.username)) entry.username = item.username
|
|
120
|
+
if (isNonEmptyString(item.credential)) entry.credential = item.credential
|
|
121
|
+
out.push(entry)
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type ParseIceResult = { ok: true; servers: IceServerInit[] } | { ok: false; error: string }
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 解析用户手填的 ICE JSON(设置页文本框)。
|
|
130
|
+
*
|
|
131
|
+
* 空串 = 「用服务端默认」,不算错误(UI 也据此判断「已自定义 / 未自定义」)。
|
|
132
|
+
*/
|
|
133
|
+
export function parseIceText(text: string): ParseIceResult {
|
|
134
|
+
const trimmed = (text ?? '').trim()
|
|
135
|
+
if (!trimmed) return { ok: false, error: '未填写' }
|
|
136
|
+
let parsed: unknown
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(trimmed)
|
|
139
|
+
} catch {
|
|
140
|
+
return { ok: false, error: '不是合法 JSON' }
|
|
141
|
+
}
|
|
142
|
+
if (!Array.isArray(parsed)) return { ok: false, error: '顶层必须是数组,例如 [{"urls":"stun:…"}]' }
|
|
143
|
+
const servers = sanitizeIceServers(parsed)
|
|
144
|
+
if (servers.length === 0) {
|
|
145
|
+
return { ok: false, error: '没有可用的条目(每项至少要有非空的 urls)' }
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, servers }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 回填文本框:把当前生效的列表格式化成可编辑的 JSON(用户改之前先看得见现状)。 */
|
|
151
|
+
export function formatIceServers(servers: IceServerInit[]): string {
|
|
152
|
+
return JSON.stringify(servers, null, 2)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ------------------------------ 文案 ------------------------------ */
|
|
156
|
+
|
|
157
|
+
/** 来源 → 用户可读文案(两端共用,避免「服务端下发」与「服务器配置」两种说法打架)。 */
|
|
158
|
+
export function describeIceSource(source: IceSource, count: number): string {
|
|
159
|
+
switch (source) {
|
|
160
|
+
case 'custom':
|
|
161
|
+
return `自定义 ICE(${count} 个服务器)`
|
|
162
|
+
case 'remote':
|
|
163
|
+
return `服务端下发(${count} 个服务器)`
|
|
164
|
+
case 'cache':
|
|
165
|
+
return `服务端下发 · 本地缓存(${count} 个服务器)`
|
|
166
|
+
case 'stale-cache':
|
|
167
|
+
return `服务端下发 · 过期缓存(${count} 个服务器)`
|
|
168
|
+
default:
|
|
169
|
+
return '未配置 ICE(仅本机候选,跨网可能连不上)'
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* ------------------------------ 取服务端默认值 ------------------------------ */
|
|
174
|
+
|
|
175
|
+
export interface FetchIceOptions {
|
|
176
|
+
/** 信令基址,如 `https://virlen.cn/api/rtc/`(末尾斜杠可有可无)。 */
|
|
177
|
+
baseUrl: string
|
|
178
|
+
fetchImpl?: typeof fetch
|
|
179
|
+
/** 默认 `ICE_FETCH_TIMEOUT_MS`。 */
|
|
180
|
+
timeoutMs?: number
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeBase(baseUrl: string): string {
|
|
184
|
+
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 向信令服务要默认 ICE。
|
|
189
|
+
*
|
|
190
|
+
* @returns 服务端给出的列表;**取不到返回 `null`**(与「服务端明确说没有」区分开)。
|
|
191
|
+
*
|
|
192
|
+
* 为什么区分:`[]` 是「服务端回答了:没配」→ 不该继续拿旧缓存;
|
|
193
|
+
* `null` 是「没问到」→ 旧缓存反而是当前最好的答案。
|
|
194
|
+
*/
|
|
195
|
+
export async function fetchIceServers(options: FetchIceOptions): Promise<IceServerInit[] | null> {
|
|
196
|
+
const fetchImpl = options.fetchImpl ?? (typeof fetch === 'function' ? fetch : null)
|
|
197
|
+
if (!fetchImpl) return null
|
|
198
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
199
|
+
const timer = controller
|
|
200
|
+
? setTimeout(() => controller.abort(), options.timeoutMs ?? ICE_FETCH_TIMEOUT_MS)
|
|
201
|
+
: null
|
|
202
|
+
try {
|
|
203
|
+
const res = await fetchImpl(normalizeBase(options.baseUrl) + ICE_API_PATH, {
|
|
204
|
+
method: 'GET',
|
|
205
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
206
|
+
})
|
|
207
|
+
if (!res.ok) return null
|
|
208
|
+
const body = (await res.json()) as unknown
|
|
209
|
+
// 宽容两种形状:`{ iceServers: [...] }`(现行)与裸数组(更早/更简的部署)
|
|
210
|
+
const list = Array.isArray(body)
|
|
211
|
+
? body
|
|
212
|
+
: ((body as { iceServers?: unknown } | null)?.iceServers ?? null)
|
|
213
|
+
if (list === null) return null
|
|
214
|
+
return sanitizeIceServers(list)
|
|
215
|
+
} catch {
|
|
216
|
+
// 网络不通 / 超时 / 非 JSON:都归为「没问到」
|
|
217
|
+
return null
|
|
218
|
+
} finally {
|
|
219
|
+
if (timer) clearTimeout(timer)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* ------------------------------ 解析(对外主入口) ------------------------------ */
|
|
224
|
+
|
|
225
|
+
export interface ResolveIceOptions {
|
|
226
|
+
/** 信令基址;缺省则跳过「现取」这一步(只用自定义 / 缓存)。 */
|
|
227
|
+
baseUrl?: string
|
|
228
|
+
/** 用户自定义的 JSON 文本;空 / 非法则走下一优先级。 */
|
|
229
|
+
customText?: string | null
|
|
230
|
+
storage?: IceStoragePort | null
|
|
231
|
+
fetchImpl?: typeof fetch
|
|
232
|
+
/** 注入「现在」(用例用)。 */
|
|
233
|
+
now?: number
|
|
234
|
+
cacheTtlMs?: number
|
|
235
|
+
timeoutMs?: number
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function readCache(
|
|
239
|
+
storage: IceStoragePort | null | undefined,
|
|
240
|
+
now: number,
|
|
241
|
+
ttlMs: number,
|
|
242
|
+
): { servers: IceServerInit[]; fresh: boolean } | null {
|
|
243
|
+
if (!storage) return null
|
|
244
|
+
try {
|
|
245
|
+
const raw = storage.getItem(ICE_REMOTE_STORAGE_KEY)
|
|
246
|
+
if (!raw) return null
|
|
247
|
+
const parsed = JSON.parse(raw) as { at?: unknown; servers?: unknown }
|
|
248
|
+
const servers = sanitizeIceServers(parsed?.servers)
|
|
249
|
+
if (servers.length === 0) return null
|
|
250
|
+
const at = typeof parsed?.at === 'number' ? parsed.at : 0
|
|
251
|
+
return { servers, fresh: now - at <= ttlMs }
|
|
252
|
+
} catch {
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function writeCache(storage: IceStoragePort | null | undefined, now: number, servers: IceServerInit[]): void {
|
|
258
|
+
if (!storage) return
|
|
259
|
+
try {
|
|
260
|
+
storage.setItem(ICE_REMOTE_STORAGE_KEY, JSON.stringify({ at: now, servers }))
|
|
261
|
+
} catch {
|
|
262
|
+
/* 存储不可用(隐私模式 / 配额满):缓存丢了下一次再取,不影响本次连接 */
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 解析出**本次连接实际要用**的 ICE 列表(两端唯一的入口)。
|
|
268
|
+
*
|
|
269
|
+
* 永不抛错:任何一步失败都降级到下一优先级 ——「拿不到 TURN 配置」不该让用户连不上电脑,
|
|
270
|
+
* 只该让跨网连通率变差(并由 `detail` / `warning` 如实告诉用户)。
|
|
271
|
+
*/
|
|
272
|
+
export async function resolveIceServers(options: ResolveIceOptions = {}): Promise<ResolvedIceServers> {
|
|
273
|
+
const now = options.now ?? Date.now()
|
|
274
|
+
|
|
275
|
+
// ① 自定义优先:用户填了就以用户的为准(自建 coturn / 内网 STUN 都靠它)
|
|
276
|
+
const customText = options.customText ?? null
|
|
277
|
+
let customError: string | undefined
|
|
278
|
+
if (customText && customText.trim()) {
|
|
279
|
+
const parsed = parseIceText(customText)
|
|
280
|
+
/*
|
|
281
|
+
* ⚠️ 必须写 `parsed.ok === false` 而不是 `if (!parsed.ok)`:
|
|
282
|
+
* 消费方 `virlen-app/tsconfig.json` 里是 `strictNullChecks: false`,此时 TS 对
|
|
283
|
+
* **布尔判别字段的真值判断不收敛联合类型**(详见 docs/phone-control-bridge.md 的踩坑记录)。
|
|
284
|
+
*/
|
|
285
|
+
if (parsed.ok === false) {
|
|
286
|
+
// 非法配置:**不阻断**,降级到服务端默认,同时把原因带出去让 UI 标红
|
|
287
|
+
customError = parsed.error
|
|
288
|
+
} else {
|
|
289
|
+
return {
|
|
290
|
+
servers: parsed.servers,
|
|
291
|
+
source: 'custom',
|
|
292
|
+
detail: describeIceSource('custom', parsed.servers.length),
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ② 服务端下发的本地缓存(TTL 内)
|
|
298
|
+
const cached = readCache(options.storage, now, options.cacheTtlMs ?? ICE_CACHE_TTL_MS)
|
|
299
|
+
if (cached?.fresh) {
|
|
300
|
+
return {
|
|
301
|
+
servers: cached.servers,
|
|
302
|
+
source: 'cache',
|
|
303
|
+
detail: describeIceSource('cache', cached.servers.length),
|
|
304
|
+
...(customError ? { customError } : {}),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ③ 现取
|
|
309
|
+
if (options.baseUrl) {
|
|
310
|
+
const fetched = await fetchIceServers({
|
|
311
|
+
baseUrl: options.baseUrl,
|
|
312
|
+
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
313
|
+
...(options.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {}),
|
|
314
|
+
})
|
|
315
|
+
if (fetched && fetched.length > 0) {
|
|
316
|
+
writeCache(options.storage, now, fetched)
|
|
317
|
+
return {
|
|
318
|
+
servers: fetched,
|
|
319
|
+
source: 'remote',
|
|
320
|
+
detail: describeIceSource('remote', fetched.length),
|
|
321
|
+
...(customError ? { customError } : {}),
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (fetched && fetched.length === 0) {
|
|
325
|
+
// 服务端明确回答「没配」:此时再拿旧缓存是自欺欺人(配置已被撤下)
|
|
326
|
+
return {
|
|
327
|
+
servers: [],
|
|
328
|
+
source: 'none',
|
|
329
|
+
detail: describeIceSource('none', 0),
|
|
330
|
+
warning: '服务端未下发 ICE 配置',
|
|
331
|
+
...(customError ? { customError } : {}),
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ④ 取不到:过期缓存也比没有强(TURN 凭证通常变化不大)
|
|
337
|
+
if (cached) {
|
|
338
|
+
return {
|
|
339
|
+
servers: cached.servers,
|
|
340
|
+
source: 'stale-cache',
|
|
341
|
+
detail: describeIceSource('stale-cache', cached.servers.length),
|
|
342
|
+
warning: '信令服务暂不可达,先用上次缓存',
|
|
343
|
+
...(customError ? { customError } : {}),
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
servers: [],
|
|
349
|
+
source: 'none',
|
|
350
|
+
detail: describeIceSource('none', 0),
|
|
351
|
+
warning: options.baseUrl ? '信令服务暂不可达' : '未配置信令服务',
|
|
352
|
+
...(customError ? { customError } : {}),
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 读取「用户自定义」文本框的初始内容(设置页打开时回填)。
|
|
358
|
+
*
|
|
359
|
+
* 坏数据(非 JSON / 非数组)按「没填」处理:让用户看到一个空框,而不是一段乱码。
|
|
360
|
+
*/
|
|
361
|
+
export function readCustomIceText(storage: IceStoragePort | null | undefined): string {
|
|
362
|
+
if (!storage) return ''
|
|
363
|
+
try {
|
|
364
|
+
const raw = storage.getItem(ICE_CUSTOM_STORAGE_KEY)
|
|
365
|
+
if (!raw || !raw.trim()) return ''
|
|
366
|
+
const parsed = JSON.parse(raw) as unknown
|
|
367
|
+
return formatIceServers(sanitizeIceServers(parsed))
|
|
368
|
+
} catch {
|
|
369
|
+
return ''
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 写入 / 清除用户自定义 ICE。
|
|
375
|
+
*
|
|
376
|
+
* 空文本 = 清除(回到服务端默认)。**先校验再落盘**:让错误在保存那一刻暴露,
|
|
377
|
+
* 而不是等到某次真机连接失败才发现自己少写了一个引号。
|
|
378
|
+
*/
|
|
379
|
+
export function writeCustomIceText(
|
|
380
|
+
storage: IceStoragePort | null | undefined,
|
|
381
|
+
text: string,
|
|
382
|
+
): { ok: true } | { ok: false; error: string } {
|
|
383
|
+
const trimmed = (text ?? '').trim()
|
|
384
|
+
if (!trimmed) {
|
|
385
|
+
try {
|
|
386
|
+
storage?.setItem(ICE_CUSTOM_STORAGE_KEY, '')
|
|
387
|
+
} catch {
|
|
388
|
+
/* 忽略 */
|
|
389
|
+
}
|
|
390
|
+
return { ok: true }
|
|
391
|
+
}
|
|
392
|
+
const parsed = parseIceText(trimmed)
|
|
393
|
+
// 同 `resolveIceServers`:`=== false` 而非 `!parsed.ok`(消费方关了 strictNullChecks)
|
|
394
|
+
if (parsed.ok === false) return { ok: false, error: parsed.error }
|
|
395
|
+
if (!storage) return { ok: false, error: '当前环境不支持本地存储' }
|
|
396
|
+
try {
|
|
397
|
+
storage.setItem(ICE_CUSTOM_STORAGE_KEY, formatIceServers(parsed.servers))
|
|
398
|
+
} catch (err) {
|
|
399
|
+
return { ok: false, error: new BridgeError('E_INTERNAL', String(err)).message }
|
|
400
|
+
}
|
|
401
|
+
return { ok: true }
|
|
402
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 内存 transport —— 两个端点直连(测试 / M2 联调)。
|
|
3
|
+
*
|
|
4
|
+
* 用途(docs/phone-control-bridge.md §7-⑯):
|
|
5
|
+
* 1. 让协议层全部逻辑(分片/超时/幂等/乱序/重连重放)在 vitest 里可覆盖 —— **WebRTC 无法在 CI 里跑**;
|
|
6
|
+
* 2. M2 用它做「电脑侧 bridge + 手机侧 UI」的端到端联调,**不依赖 WebRTC**;
|
|
7
|
+
* 3. 也是将来「把 RTC 挪到 Rust」时,上层代码的退路。
|
|
8
|
+
*/
|
|
9
|
+
import type { Transport, TransportState } from './types'
|
|
10
|
+
|
|
11
|
+
export interface MemoryTransportMetrics {
|
|
12
|
+
sent: number
|
|
13
|
+
received: number
|
|
14
|
+
dropped: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MemoryTransport implements Transport {
|
|
18
|
+
private _state: TransportState = 'connecting'
|
|
19
|
+
|
|
20
|
+
/** 对端;由 `createMemoryPair()` 建立双向链接。 */
|
|
21
|
+
peer: MemoryTransport | null = null
|
|
22
|
+
|
|
23
|
+
private readonly messageListeners = new Set<(bytes: Uint8Array) => void>()
|
|
24
|
+
private readonly stateListeners = new Set<(state: TransportState) => void>()
|
|
25
|
+
private readonly metrics: MemoryTransportMetrics = { sent: 0, received: 0, dropped: 0 }
|
|
26
|
+
|
|
27
|
+
/** 随机丢包率 0..1(测试用)。 */
|
|
28
|
+
lossRate = 0
|
|
29
|
+
/** 确定性丢包:>0 时下一次 send 丢弃该帧并自减。 */
|
|
30
|
+
private dropNextOutgoing = 0
|
|
31
|
+
/** 确定性丢包:>0 时下一次投递丢弃该帧并自减。 */
|
|
32
|
+
private dropNextIncoming = 0
|
|
33
|
+
|
|
34
|
+
get state(): TransportState {
|
|
35
|
+
return this._state
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get bufferedAmount(): number {
|
|
39
|
+
return 0
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 累计统计(测试断言用)。 */
|
|
43
|
+
get stats(): MemoryTransportMetrics {
|
|
44
|
+
return { ...this.metrics }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
send(bytes: Uint8Array): void {
|
|
48
|
+
this.metrics.sent++
|
|
49
|
+
if (this._state !== 'open' || !this.peer) {
|
|
50
|
+
this.metrics.dropped++
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (this.dropNextOutgoing > 0) {
|
|
54
|
+
this.dropNextOutgoing--
|
|
55
|
+
this.metrics.dropped++
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if (this.lossRate > 0 && Math.random() < this.lossRate) {
|
|
59
|
+
this.metrics.dropped++
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
this.peer.deliver(bytes)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
close(): void {
|
|
66
|
+
this.setState('closed')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
onMessage(listener: (bytes: Uint8Array) => void): () => void {
|
|
70
|
+
this.messageListeners.add(listener)
|
|
71
|
+
return () => {
|
|
72
|
+
this.messageListeners.delete(listener)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
onStateChange(listener: (state: TransportState) => void): () => void {
|
|
77
|
+
this.stateListeners.add(listener)
|
|
78
|
+
return () => {
|
|
79
|
+
this.stateListeners.delete(listener)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ───────────────────────── 测试辅助 ─────────────────────────
|
|
84
|
+
|
|
85
|
+
open(): void {
|
|
86
|
+
this.setState('open')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 模拟链路断开(双向,原子:先写两端状态再通知)。 */
|
|
90
|
+
disconnect(): void {
|
|
91
|
+
this.transition('closed')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 模拟链路恢复(双向,原子)。 */
|
|
95
|
+
reconnect(): void {
|
|
96
|
+
this.transition('open')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 让接下来 n 次 send 静默丢弃(验证幂等/重放)。 */
|
|
100
|
+
dropOutgoing(n: number): void {
|
|
101
|
+
this.dropNextOutgoing = n
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 让接下来 n 次投递静默丢弃(验证超时/重放)。 */
|
|
105
|
+
dropIncoming(n: number): void {
|
|
106
|
+
this.dropNextIncoming = n
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private deliver(bytes: Uint8Array): void {
|
|
110
|
+
if (this._state !== 'open') {
|
|
111
|
+
this.metrics.dropped++
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (this.dropNextIncoming > 0) {
|
|
115
|
+
this.dropNextIncoming--
|
|
116
|
+
this.metrics.dropped++
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
// 异步投递:模拟真实链路的异步边界;queueMicrotask 保证先进先出(顺序性)
|
|
120
|
+
queueMicrotask(() => {
|
|
121
|
+
if (this._state !== 'open') return
|
|
122
|
+
this.metrics.received++
|
|
123
|
+
for (const listener of this.messageListeners) listener(bytes)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private transition(state: TransportState): void {
|
|
128
|
+
const changed: MemoryTransport[] = []
|
|
129
|
+
if (this._state !== state) {
|
|
130
|
+
this._state = state
|
|
131
|
+
changed.push(this)
|
|
132
|
+
}
|
|
133
|
+
const peer = this.peer
|
|
134
|
+
if (peer && peer._state !== state) {
|
|
135
|
+
peer._state = state
|
|
136
|
+
changed.push(peer)
|
|
137
|
+
}
|
|
138
|
+
// 关键:**先把两端状态写完,再逐个通知**。
|
|
139
|
+
// 否则「重放」的一侧会在通知回调里立即发送,而对端此刻仍是 closed
|
|
140
|
+
// → 帧被 deliver() 静默丢弃,重放失效(真实链路恢复时同样会踩到)。
|
|
141
|
+
for (const t of changed) t.notifyState()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private notifyState(): void {
|
|
145
|
+
for (const listener of [...this.stateListeners]) listener(this._state)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private setState(state: TransportState): void {
|
|
149
|
+
if (this._state === state) return
|
|
150
|
+
this._state = state
|
|
151
|
+
this.notifyState()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 建立一对互连的 transport(双方初始即 open)。 */
|
|
156
|
+
export function createMemoryPair(): [MemoryTransport, MemoryTransport] {
|
|
157
|
+
const a = new MemoryTransport()
|
|
158
|
+
const b = new MemoryTransport()
|
|
159
|
+
a.peer = b
|
|
160
|
+
b.peer = a
|
|
161
|
+
a.open()
|
|
162
|
+
b.open()
|
|
163
|
+
return [a, b]
|
|
164
|
+
}
|