@steve02081504/fount-p2p 0.0.46 → 0.0.49

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.
@@ -12,11 +12,65 @@ import {
12
12
  onBufferedAmountLow,
13
13
  } from '../channel_mux.mjs'
14
14
  import { asLinkHandle, createLinkPipe } from '../pipe.mjs'
15
+ import { iceLocalHostnameLadder } from '../../node/signaling_config.mjs'
15
16
  import { loadNodeRtcPolyfill, waitForChannelState } from '../rtc/index.mjs'
16
17
  import { extractDtlsFingerprint } from '../sdp_fingerprint.mjs'
17
18
 
18
19
  import { LINK_LEVEL_WEBRTC } from './levels.mjs'
19
20
 
21
+ /** 收到候选后多久无新候选就视为 gathering 收齐(polyfill 不推 complete 时的兜底)。 */
22
+ export const ICE_CANDIDATE_SETTLE_MS = 300
23
+ /** gathering 状态轮询间隔。 */
24
+ const ICE_GATHERING_POLL_MS = 50
25
+ /** 一个候选都没收到时,等这么久就放行让 DTLS/数据通道自行判成败。 */
26
+ export const ICE_GATHERING_STALL_MS = ms('3s')
27
+
28
+ /**
29
+ * 无 trickle ICE 时等本地候选收齐。
30
+ *
31
+ * 不把 `iceGatheringState === 'complete'` 当作唯一完成条件,也不靠比较 SDP 字符串:服务端 polyfill
32
+ * (node-datachannel)把候选通过 `icecandidate` 事件派发,`localDescription.sdp` 里并不含候选行,
33
+ * 而 gathering 状态在「全部 relay 超时」等情况下会长期停在 'gathering'(fount-p2p#37 次要观察)。
34
+ * 所以完成判据是:状态 complete、收到过候选且一段时间内再无新候选、或一个候选都没收到时直接放行。
35
+ * 最后一种走 [ICE_GATHERING_STALL_MS],让 DTLS/数据通道自己判成败,而不是在握手超时后才硬失败。
36
+ * @param {{ iceGatheringState: () => string, candidateCount?: () => number, handshakeTimeoutMs: number, settleMs?: number, stallMs?: number, onStall?: (elapsedMs: number) => void }} options 观测、超时与窗口配置
37
+ * @returns {Promise<'complete' | 'stable' | 'stalled'>} 结束原因
38
+ * @throws {Error} 超过 handshakeTimeoutMs 仍未收齐
39
+ */
40
+ export async function collectIceGathering(options) {
41
+ const { iceGatheringState, candidateCount, handshakeTimeoutMs, onStall } = options
42
+ const settleMs = options.settleMs ?? ICE_CANDIDATE_SETTLE_MS
43
+ const stallMs = options.stallMs ?? ICE_GATHERING_STALL_MS
44
+ const startedAt = Date.now()
45
+ const deadline = startedAt + handshakeTimeoutMs
46
+ const failIfOverdue = () => {
47
+ if (Date.now() >= deadline)
48
+ throw new Error(`p2p: ice gathering incomplete after ${handshakeTimeoutMs}ms`)
49
+ }
50
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
51
+ while (true) {
52
+ if (iceGatheringState() === 'complete') return 'complete'
53
+ await sleep(ICE_GATHERING_POLL_MS)
54
+ if (iceGatheringState() === 'complete') return 'complete'
55
+ const candidates = candidateCount?.() ?? 0
56
+ // 一个候选都没收到,再等也不会变:放行让 DTLS/数据通道自己判成败。
57
+ if (!candidates) {
58
+ const elapsedMs = Date.now() - startedAt
59
+ if (elapsedMs >= stallMs) {
60
+ onStall?.(elapsedMs)
61
+ return 'stalled'
62
+ }
63
+ failIfOverdue()
64
+ continue
65
+ }
66
+ // 有候选了:等「再无新候选」的窗口过去;窗口内又来候选就重新计时。
67
+ await sleep(settleMs)
68
+ if (iceGatheringState() === 'complete') return 'complete'
69
+ if ((candidateCount?.() ?? 0) === candidates) return 'stable'
70
+ failIfOverdue()
71
+ }
72
+ }
73
+
20
74
  /**
21
75
  * @param {unknown} error 原始错误
22
76
  * @returns {string} 短 reason
@@ -52,6 +106,11 @@ export async function canUseWebRtcLink() {
52
106
 
53
107
  /**
54
108
  * 建立 WebRTC link(双 DataChannel + discovery 信令)。
109
+ *
110
+ * ICE 本地主机名策略按观测结果逐级升级(见 [iceLocalHostnameLadder]):一级 gathering 后候选集为空
111
+ * 就换更宽松的一级重建 peer connection 重发 offer(**全新一次建链尝试**,不是重新协商已有连接,
112
+ * 所以 DTLS 指纹绑定语义不变)。接受方按 offer 里的 `rung` 在同级重建并回 answer,自身不升级,
113
+ * 故收敛有界。
55
114
  * @param {object} options link 配置
56
115
  * @param {string | null} [options.nodeHash] 期望的对端 nodeHash
57
116
  * @param {boolean} options.initiator 是否为连接发起方
@@ -59,19 +118,25 @@ export async function canUseWebRtcLink() {
59
118
  * @param {RTCConfiguration['iceServers']} [options.iceServers] ICE 服务器列表
60
119
  * @param {number} [options.heartbeatMs] 心跳间隔
61
120
  * @param {number} [options.idleTimeoutMs] 无入站流量超时
62
- * @param {number} [options.handshakeTimeoutMs] 握手超时
121
+ * @param {number} [options.handshakeTimeoutMs] 握手超时(同时是整个升级阶梯的总预算)
63
122
  * @param {import('../rtc/polyfill.mjs').LoadedRtcPolyfill} [options.rtc] RTC 构造器
123
+ * @param {typeof import('../rtc/polyfill.mjs').loadNodeRtcPolyfill} [options.loadRtc] RTC 加载函数(测试注入)
124
+ * @param {typeof createLinkPipe} [options.createPipe] pipe 工厂(测试注入)
125
+ * @param {number} [options.iceCandidateSettleMs] 候选收齐窗口覆盖(测试注入)
126
+ * @param {number} [options.iceGatheringStallMs] gathering 停滞窗口覆盖(测试注入)
64
127
  * @param {{ nodeHash?: string, nodePubKey?: string, secretKey?: Uint8Array, nonce?: string } | null} [options.localIdentity] 本地握手身份
65
128
  * @returns {Promise<import('./index.mjs').LinkHandle>} link 句柄
66
129
  */
67
130
  export async function createWebRtcLink(options) {
68
131
  const handshakeTimeoutMs = Number(options.handshakeTimeoutMs) || ms('10s')
69
132
  const channelOpenTimeoutMs = Math.max(handshakeTimeoutMs, ms('30s'))
70
- const rtc = options.rtc ?? await loadNodeRtcPolyfill()
71
- const trickleIceOff = getSignalingRuntimeConfig().channels.webrtc?.trickleIceOff === true
72
- const peerConnection = new rtc.RTCPeerConnection(options.iceServers?.length ? { iceServers: options.iceServers } : undefined)
133
+ const loadRtc = options.loadRtc ?? loadNodeRtcPolyfill
134
+ const icePolicy = getSignalingRuntimeConfig().channels.webrtc?.iceLocalHostnamePolicy
135
+ const rungs = iceLocalHostnameLadder(icePolicy)
73
136
  const remoteSignalQueue = []
74
137
  const seenRemoteSignals = createLruMap(1024)
138
+ /** @type {InstanceType<import('../rtc/polyfill.mjs').LoadedRtcPolyfill['RTCPeerConnection']> & { ondatachannel?: unknown, onconnectionstatechange?: unknown, candidateCountHandler?: unknown }} */
139
+ let peerConnection = null
75
140
  let remoteDescriptionSet = false
76
141
  let controlChannel = null
77
142
  let bulkChannel = null
@@ -80,6 +145,9 @@ export async function createWebRtcLink(options) {
80
145
  let controlLowEvents = 0
81
146
  let bulkLowEvents = 0
82
147
  let reconnectCount = 0
148
+ let rungIndex = 0
149
+ /** 当前一级收到的 ICE 候选数(polyfill 不把候选写进 SDP,故单独计数)。 */
150
+ let localCandidateCount = 0
83
151
 
84
152
  /**
85
153
  * @param {unknown} message 信令载荷
@@ -89,7 +157,8 @@ export async function createWebRtcLink(options) {
89
157
  await Promise.resolve(options.signal.send(message))
90
158
  }
91
159
 
92
- const pipe = createLinkPipe({
160
+ const createPipe = options.createPipe ?? createLinkPipe
161
+ const pipe = createPipe({
93
162
  providerId: 'webrtc',
94
163
  level: LINK_LEVEL_WEBRTC,
95
164
  initiator: !!options.initiator,
@@ -98,10 +167,10 @@ export async function createWebRtcLink(options) {
98
167
  heartbeatMs: options.heartbeatMs,
99
168
  idleTimeoutMs: options.idleTimeoutMs,
100
169
  handshakeTimeoutMs,
101
- /** @returns {string} 本端 DTLS fingerprint */
102
- getLocalBinding: () => extractDtlsFingerprint(peerConnection.localDescription?.sdp || ''),
170
+ /** @returns {string} 本端 DTLS fingerprint(取当前一级的 peer connection) */
171
+ getLocalBinding: () => extractDtlsFingerprint(peerConnection?.localDescription?.sdp || ''),
103
172
  /** @returns {string} 对端 DTLS fingerprint */
104
- getRemoteBinding: () => extractDtlsFingerprint(peerConnection.remoteDescription?.sdp || ''),
173
+ getRemoteBinding: () => extractDtlsFingerprint(peerConnection?.remoteDescription?.sdp || ''),
105
174
  /**
106
175
  * @param {string} text control JSON
107
176
  * @returns {void}
@@ -127,15 +196,17 @@ export async function createWebRtcLink(options) {
127
196
  sendQueues?.clear()
128
197
  try { controlChannel?.close() } catch { /* ignore */ }
129
198
  try { bulkChannel?.close() } catch { /* ignore */ }
130
- try { await peerConnection.close() } catch { /* ignore */ }
199
+ try { await peerConnection?.close() } catch { /* ignore */ }
131
200
  },
132
201
  /**
133
202
  * @returns {object} WebRTC 附加 stats
134
203
  */
135
204
  extraStats() {
136
205
  return {
137
- connectionState: peerConnection.connectionState,
138
- iceConnectionState: peerConnection.iceConnectionState,
206
+ connectionState: peerConnection?.connectionState,
207
+ iceConnectionState: peerConnection?.iceConnectionState,
208
+ iceLocalHostnamePolicy: rungs[rungIndex],
209
+ iceRung: rungIndex,
139
210
  reconnectCount,
140
211
  pending: sendQueues?.pending() ?? { control: 0, bulk: 0 },
141
212
  controlBufferedAmount: controlChannel?.bufferedAmount ?? 0,
@@ -175,17 +246,126 @@ export async function createWebRtcLink(options) {
175
246
  }
176
247
 
177
248
  /**
249
+ * 取第 index 级策略对应的 RTC 构造器(策略在包装期烘入,故每级都要重新取)。
250
+ * @param {number} index 阶梯下标
251
+ * @returns {Promise<import('../rtc/polyfill.mjs').LoadedRtcPolyfill>} RTC 构造器
252
+ */
253
+ function rtcForRung(index) {
254
+ if (options.rtc) return options.rtc
255
+ return loadRtc({ policy: rungs[Math.min(index, rungs.length - 1)] })
256
+ }
257
+
258
+ /**
259
+ * 用第 index 级策略重建 peer connection(换级即换 pc:一次全新建链尝试)。
260
+ * @param {number} index 阶梯下标
261
+ * @returns {Promise<void>}
262
+ */
263
+ async function buildPeerConnection(index) {
264
+ const previous = peerConnection
265
+ rungIndex = Math.min(index, rungs.length - 1)
266
+ const rtc = await rtcForRung(rungIndex)
267
+ peerConnection = new rtc.RTCPeerConnection(options.iceServers?.length ? { iceServers: options.iceServers } : undefined)
268
+ remoteDescriptionSet = false
269
+ remoteSignalQueue.length = 0
270
+ localCandidateCount = 0
271
+ attachPeerConnection(peerConnection)
272
+ if (previous) {
273
+ // 先摘回调再关:旧连接 close() 会经 connectionstatechange 触发 pipe.close,而
274
+ // closeTransport() 关的是 peerConnection 这个变量(此刻已指向新连接),于是刚建好的一级被自己关掉(fount-p2p#39)。
275
+ detachPeerConnection(previous)
276
+ try { await previous.close() } catch { /* ignore */ }
277
+ }
278
+ nodeDebug('p2p:webrtc ice rung', {
279
+ rung: rungIndex,
280
+ policy: rungs[rungIndex],
281
+ rungs: rungs.length,
282
+ })
283
+ }
284
+
285
+ /**
286
+ * 确保当前已有一级 peer connection:接受方在收到第一份信令前也要有实例才能 setRemoteDescription。
178
287
  * @returns {Promise<void>}
179
288
  */
289
+ async function ensurePeerConnection() {
290
+ if (!peerConnection) await buildPeerConnection(rungIndex)
291
+ }
292
+
293
+ /**
294
+ * 给 peer connection 挂上事件与回调(换级重建后需重新挂)。
295
+ *
296
+ * 回调一律用传入的这个连接,不读 `peerConnection` 闭包变量:换级后旧连接仍可能派发事件,
297
+ * 读变量就会把事件算到新连接头上。
298
+ * @param {RTCPeerConnection} connection peer connection
299
+ * @returns {void}
300
+ */
301
+ function attachPeerConnection(connection) {
302
+ // 候选随 description 一次性带出(对端要先有 remoteDescription 才吃候选),故这里只计数、不外发。
303
+ // 事件 API 由后端提供(W3C 是 addEventListener);缺失时退化为 onicecandidate,摘的时候看 candidateCountHandler 在不在。
304
+ const countCandidate = event => {
305
+ if (event?.candidate) localCandidateCount++
306
+ }
307
+ if (connection.addEventListener) {
308
+ connection.candidateCountHandler = countCandidate
309
+ connection.addEventListener('icecandidate', countCandidate)
310
+ }
311
+ else {
312
+ const previousHandler = connection.onicecandidate
313
+ connection.candidateCountHandler = event => {
314
+ countCandidate(event)
315
+ previousHandler?.(event)
316
+ }
317
+ connection.onicecandidate = connection.candidateCountHandler
318
+ }
319
+ connection.ondatachannel = event => {
320
+ attachChannel(event.channel)
321
+ void maybeStartPostOpenFlow().catch(error => pipe.close(`channel-attach-failed:${formatErrorReason(error)}`))
322
+ }
323
+ connection.onconnectionstatechange = () => {
324
+ if (['failed', 'closed', 'disconnected'].includes(connection.connectionState)) {
325
+ reconnectCount++
326
+ void pipe.close(`connection-${connection.connectionState}`)
327
+ }
328
+ }
329
+ }
330
+
331
+ /**
332
+ * 摘掉某一级 peer connection 上挂的事件与回调(重建前必须调用,见 [buildPeerConnection])。
333
+ * @param {RTCPeerConnection} connection 待摘的连接
334
+ * @returns {void}
335
+ */
336
+ function detachPeerConnection(connection) {
337
+ if (connection.candidateCountHandler) connection.removeEventListener?.('icecandidate', connection.candidateCountHandler)
338
+ else connection.onicecandidate = null
339
+ delete connection.candidateCountHandler
340
+ connection.ondatachannel = null
341
+ connection.onconnectionstatechange = null
342
+ }
343
+
344
+ /**
345
+ * 等本地候选收齐(停滞判定见 [collectIceGathering]),并回报本次是否产出了候选。
346
+ * @returns {Promise<boolean>} 是否收到过候选
347
+ */
180
348
  async function waitForIceGatheringComplete() {
181
- if (!trickleIceOff || peerConnection.iceGatheringState === 'complete') return
182
- const deadline = Date.now() + handshakeTimeoutMs
183
- while (peerConnection.iceGatheringState !== 'complete' && Date.now() < deadline)
184
- await new Promise(resolve => setTimeout(resolve, 50))
185
- if (peerConnection.iceGatheringState !== 'complete') {
349
+ try {
350
+ await collectIceGathering({
351
+ iceGatheringState: () => peerConnection.iceGatheringState,
352
+ candidateCount: () => localCandidateCount,
353
+ handshakeTimeoutMs,
354
+ settleMs: options.iceCandidateSettleMs,
355
+ stallMs: options.iceGatheringStallMs,
356
+ onStall: elapsedMs => nodeDebug('p2p:webrtc ice gathering stalled without candidates', {
357
+ elapsedMs,
358
+ rung: rungIndex,
359
+ policy: rungs[rungIndex],
360
+ iceServers: options.iceServers?.length || 0,
361
+ }),
362
+ })
363
+ }
364
+ catch (error) {
186
365
  await pipe.close('ice-gathering-timeout')
187
- throw new Error(`p2p: ice gathering incomplete after ${handshakeTimeoutMs}ms`)
366
+ throw error
188
367
  }
368
+ return localCandidateCount > 0
189
369
  }
190
370
 
191
371
  /**
@@ -214,21 +394,35 @@ export async function createWebRtcLink(options) {
214
394
  */
215
395
  async function handleRemoteSignal(message) {
216
396
  if (!message?.type) return
397
+ // 信令与建链是并发的:任何入站信令都要先确保当前这一级已有可用 pc。
398
+ await ensurePeerConnection()
217
399
  const signalKey = message.type === 'ice' && message.candidate
218
400
  ? `ice:${message.candidate.candidate ?? ''}:${message.candidate.sdpMid ?? ''}:${message.candidate.sdpMLineIndex ?? ''}`
219
401
  : JSON.stringify(message)
220
402
  if (seenRemoteSignals.has(signalKey)) return
221
403
  seenRemoteSignals.touch(signalKey, true)
222
404
  if (message.type === 'description' && message.description) {
405
+ // 接受方按 offer 的级号重建,保证自己的候选过滤策略与发起方同一级;自身不升级,
406
+ // 所以阶梯收敛有界(由发起方的观测结果驱动)。
407
+ const remoteRung = Number.isInteger(message.rung) ? Math.max(0, message.rung) : 0
408
+ if (message.description.type === 'offer' && !options.initiator && remoteRung !== rungIndex)
409
+ await buildPeerConnection(remoteRung)
223
410
  if (message.description.type === 'answer' && peerConnection.signalingState === 'stable') return
224
411
  await applyRemoteDescription(message.description)
225
412
  if (message.description.type === 'offer') {
226
413
  const answer = await peerConnection.createAnswer()
227
414
  await peerConnection.setLocalDescription(answer)
228
415
  await flushQueuedIceCandidates()
229
- await waitForIceGatheringComplete()
416
+ const hasCandidates = await waitForIceGatheringComplete()
417
+ nodeDebug('p2p:webrtc answer gathered', {
418
+ rung: rungIndex,
419
+ policy: rungs[rungIndex],
420
+ candidates: localCandidateCount,
421
+ hasCandidates,
422
+ })
230
423
  await sendSignal({
231
424
  type: 'description',
425
+ rung: rungIndex,
232
426
  description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? answer,
233
427
  })
234
428
  await pipe.maybeSendAuth()
@@ -299,46 +493,57 @@ export async function createWebRtcLink(options) {
299
493
  void handleRemoteSignal(message).catch(error => pipe.close(`signal-error:${formatErrorReason(error)}`))
300
494
  }) ?? null
301
495
 
302
- /**
303
- * @param {RTCPeerConnectionIceEvent} event ICE candidate 事件
304
- * @returns {void}
305
- */
306
- peerConnection.onicecandidate = event => {
307
- if (trickleIceOff || !event.candidate) return
308
- void sendSignal({
309
- type: 'ice',
310
- candidate: event.candidate.toJSON?.() || event.candidate,
311
- }).catch(error => pipe.close(`signal-send-failed:${formatErrorReason(error)}`))
312
- }
313
- /**
314
- * @param {RTCDataChannelEvent} event 远端 data channel 事件
315
- * @returns {void}
316
- */
317
- peerConnection.ondatachannel = event => {
318
- attachChannel(event.channel)
319
- void maybeStartPostOpenFlow().catch(error => pipe.close(`channel-attach-failed:${formatErrorReason(error)}`))
320
- }
321
-
322
- /**
323
- * 连接失败/断开时递增重连计数并关闭 pipe。
324
- */
325
- peerConnection.onconnectionstatechange = () => {
326
- if (['failed', 'closed', 'disconnected'].includes(peerConnection.connectionState)) {
327
- reconnectCount++
328
- void pipe.close(`connection-${peerConnection.connectionState}`)
329
- }
330
- }
496
+ // 信令随时可能到,故先为当前一级(第 0 级)建好 pc;发起方阶梯的首次迭代直接用它,不重建。
497
+ await ensurePeerConnection()
331
498
 
332
499
  if (options.initiator) {
333
- attachChannel(peerConnection.createDataChannel(CHANNEL_CONTROL))
334
- attachChannel(peerConnection.createDataChannel(CHANNEL_BULK))
335
- const offer = await peerConnection.createOffer()
336
- await peerConnection.setLocalDescription(offer)
337
- await waitForIceGatheringComplete()
338
- await sendSignal({
339
- type: 'description',
340
- description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? offer,
341
- })
500
+ // ICE 阶梯:一级 gathering 后候选集为空就升到更宽松的一级,用全新 peer connection 重发 offer。
501
+ // 总预算沿用 handshakeTimeoutMs,故整条阶梯(含最后一级的数据通道等待)仍有界。
502
+ const ladderDeadline = Date.now() + handshakeTimeoutMs
503
+ let sentOffer = false
504
+ for (let index = 0; index < rungs.length; index++) {
505
+ // 同一级不重建:换 pc 会牵动并发信令与旧连接的关闭,只在真的换级时才需要(fount-p2p#39)。
506
+ if (rungIndex !== index) await buildPeerConnection(index)
507
+ attachChannel(peerConnection.createDataChannel(CHANNEL_CONTROL))
508
+ attachChannel(peerConnection.createDataChannel(CHANNEL_BULK))
509
+ const offer = await peerConnection.createOffer()
510
+ await peerConnection.setLocalDescription(offer)
511
+ const hasCandidates = await waitForIceGatheringComplete()
512
+ nodeDebug('p2p:webrtc offer gathered', {
513
+ rung: index,
514
+ policy: rungs[index],
515
+ candidates: localCandidateCount,
516
+ })
517
+ // 候选集为空说明这一级策略把本机候选全滤掉了(例如只产出 mDNS 候选),换更宽松的一级重建。
518
+ // 只在预算内、且还有更宽松的一级时升级;`rewrite-loopback` 起点不会走到这里(阶梯只有它自己)。
519
+ const escalatable = index + 1 < rungs.length
520
+ if (!hasCandidates && escalatable && Date.now() < ladderDeadline) {
521
+ nodeDebug('p2p:webrtc ice rung escalated', {
522
+ from: rungs[index],
523
+ to: rungs[index + 1],
524
+ rung: index + 1,
525
+ })
526
+ continue
527
+ }
528
+ await sendSignal({
529
+ type: 'description',
530
+ rung: index,
531
+ description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? offer,
532
+ })
533
+ sentOffer = true
534
+ if (!hasCandidates)
535
+ nodeDebug('p2p:webrtc offer has no usable ice candidates', {
536
+ rung: index,
537
+ policy: rungs[index],
538
+ escalatable,
539
+ iceServers: options.iceServers?.length || 0,
540
+ })
541
+ break
542
+ }
543
+ if (!sentOffer) {
544
+ await pipe.close('ice-candidates-empty')
545
+ throw new Error('p2p: no usable ice candidates after the local hostname ladder')
546
+ }
342
547
  }
343
548
 
344
549
  void maybeStartPostOpenFlow().catch(error => pipe.close(`open-flow-failed:${formatErrorReason(error)}`))
@@ -5,6 +5,8 @@ import { nodeDebug } from '../../node/log.mjs'
5
5
 
6
6
  import { wrapRtcPeerConnectionForIceLocalHostname } from './ice_local_hostname.mjs'
7
7
 
8
+ /** @typedef {import('./ice_local_hostname.mjs').IceLocalHostnamePolicy} IceLocalHostnamePolicy */
9
+
8
10
  /** @type {boolean} */
9
11
  let exitCleanupHooked = false
10
12
 
@@ -87,7 +89,7 @@ function defaultRtcBackends() {
87
89
  }
88
90
 
89
91
  /**
90
- * @param {{ backends?: RtcBackend[] }} options 后端列表
92
+ * @param {{ backends?: RtcBackend[], policy?: IceLocalHostnamePolicy }} options 后端列表与策略覆盖
91
93
  * @returns {Promise<LoadedRtcPolyfill>} 首个可用后端的 polyfill
92
94
  */
93
95
  async function loadNodeRtcPolyfillUncached(options) {
@@ -99,7 +101,7 @@ async function loadNodeRtcPolyfillUncached(options) {
99
101
  for (const backend of backends)
100
102
  try {
101
103
  const mod = await backend.load()
102
- const iceLocalHostnamePolicy = getSignalingRuntimeConfig().channels.webrtc?.iceLocalHostnamePolicy
104
+ const iceLocalHostnamePolicy = options.policy ?? getSignalingRuntimeConfig().channels.webrtc?.iceLocalHostnamePolicy
103
105
  return {
104
106
  RTCPeerConnection: wrapRtcPeerConnectionForIceLocalHostname(
105
107
  mod.RTCPeerConnection,
@@ -122,13 +124,14 @@ async function loadNodeRtcPolyfillUncached(options) {
122
124
  }
123
125
 
124
126
  /**
125
- * 加载 RTC polyfill(node-datachannel 优先,失败则 node-rtc-connection),并按配置包装 RTCPeerConnection。
126
- * 默认后端路径会缓存首次成功结果;注入 backends 时不走缓存。
127
- * @param {{ backends?: RtcBackend[] }} [options] 可注入后端列表(测试用)
127
+ * 加载 RTC polyfill(node-datachannel 优先,失败则 node-rtc-connection),并按 ICE 本地主机名策略包装
128
+ * RTCPeerConnection。策略是构造期烘进包装类的,所以 ICE 阶梯每换一级都要带 `policy` 重新取一次。
129
+ * 默认后端路径会缓存首次成功结果(仅在不带 policy 覆盖时);注入 backends / policy 时不走缓存。
130
+ * @param {{ backends?: RtcBackend[], policy?: IceLocalHostnamePolicy }} [options] 可注入后端列表与策略覆盖(测试/阶梯用)
128
131
  * @returns {Promise<LoadedRtcPolyfill>} RTC 构造器
129
132
  */
130
133
  export async function loadNodeRtcPolyfill(options = {}) {
131
- if (options.backends?.length)
134
+ if (options.backends?.length || options.policy)
132
135
  return loadNodeRtcPolyfillUncached(options)
133
136
  const epoch = getRtcPolyfillCacheEpoch()
134
137
  if (!cachedDefaultPolyfill || cachedDefaultPolyfillEpoch !== epoch) {
@@ -1,4 +1,10 @@
1
- import process from 'node:process'
1
+ /**
2
+ * 信令运行时配置。
3
+ *
4
+ * webrtc 通道不再有 `trickleIceOff`:本仓库的 ICE 候选是靠 description 一次性带出去的
5
+ * (对端要先有 remoteDescription 才吃候选),所以「收齐后一次性发送」是信令层的固定语义,
6
+ * 不是可配置项。`iceLocalHostnamePolicy` 是 ICE 阶梯的起点(见 [iceLocalHostnameLadder])。
7
+ */
2
8
 
3
9
  /** @typedef {'none' | 'rewrite-loopback' | 'drop'} IceLocalHostnamePolicy */
4
10
 
@@ -13,20 +19,36 @@ import process from 'node:process'
13
19
  */
14
20
 
15
21
  /**
16
- * webrtc 通道默认配置。
17
- * @returns {{ iceLocalHostnamePolicy: IceLocalHostnamePolicy, trickleIceOff: boolean }} webrtc 通道默认配置
22
+ * ICE 本地主机名策略的升级阶梯(宽松度递增)。
23
+ *
24
+ * `.local`(mDNS)候选对端通常解析不了,所以从 `drop` 起;若丢弃后候选集为空,说明这台主机只产出了
25
+ * mDNS 候选,就升到 `none` 让对端自己处理(对端支持 mDNS 时就能用上)。判据是运行时观测到的候选集,
26
+ * 不是平台——任何后端、任何主机都走同一条流程。
27
+ *
28
+ * `rewrite-loopback` 不在阶梯里:它把候选改写成 `127.0.0.1`,只对同机有意义,发给跨机对端是坏候选。
18
29
  */
19
- function defaultWebRtcConfig() {
20
- const iceLocalHostnamePolicy = process.platform === 'win32' ? 'drop' : 'none'
21
- return { iceLocalHostnamePolicy, trickleIceOff: iceLocalHostnamePolicy !== 'none' }
22
- }
30
+ export const ICE_LOCAL_HOSTNAME_LADDER = Object.freeze(['drop', 'none'])
23
31
 
24
32
  /** 各介质的默认 channel 配置;对象值是启用并覆盖默认。 */
25
33
  const DEFAULT_CHANNEL_CONFIG = {
26
34
  nostr: true,
27
35
  lan: true,
28
36
  bt: true,
29
- webrtc: defaultWebRtcConfig(),
37
+ webrtc: { iceLocalHostnamePolicy: ICE_LOCAL_HOSTNAME_LADDER[0] },
38
+ }
39
+
40
+ /**
41
+ * 从起点策略推导要依次尝试的策略(宽松度递增),用于把「候选集为空」当成升级信号。
42
+ * `rewrite-loopback` 作为显式起点时只试它自己:它是调试/同机用途,不应自动放宽成对外候选。
43
+ * @param {IceLocalHostnamePolicy | undefined | null} [from] 起点策略;未配置/非阶梯项时用阶梯首项
44
+ * @returns {IceLocalHostnamePolicy[]} 依次尝试的策略(至少一项)
45
+ */
46
+ export function iceLocalHostnameLadder(from) {
47
+ if (from === 'rewrite-loopback') return ['rewrite-loopback']
48
+ if (!ICE_LOCAL_HOSTNAME_LADDER.includes(/** @type {IceLocalHostnamePolicy} */ (from)))
49
+ return [...ICE_LOCAL_HOSTNAME_LADDER]
50
+ const startIndex = ICE_LOCAL_HOSTNAME_LADDER.indexOf(/** @type {IceLocalHostnamePolicy} */ (from))
51
+ return ICE_LOCAL_HOSTNAME_LADDER.slice(startIndex)
30
52
  }
31
53
 
32
54
  /**