@steve02081504/fount-p2p 0.0.20 → 0.0.22

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/README.md CHANGED
@@ -107,8 +107,9 @@ Maintainer notes for agents / contributors: [AGENTS.md](./AGENTS.md). Sim harnes
107
107
 
108
108
  ## Optional dependencies
109
109
 
110
- - `@stoprocent/noble` / `@stoprocent/bleno` — Bluetooth (optionalDependencies). Hardware probe is subprocess-only; see [docs/runtime.md](./docs/runtime.md).
111
- - `node-datachannel` — WebRTC DataChannels (dependency).
110
+ - `@stoprocent/noble` / `@stoprocent/bleno` — Bluetooth (optionalDependencies). Hardware probe is in-process; see [docs/runtime.md](./docs/runtime.md).
111
+ - `node-datachannel` — native WebRTC DataChannels (optionalDependencies). Preferred when the addon loads.
112
+ - `node-rtc-connection` — pure-JS WebRTC DataChannel fallback (dependency; used on Termux/Android or when native is missing).
112
113
  - `ws` — Nostr discovery / signaling / last-resort link WebSockets (dependency).
113
114
 
114
115
  Group chunk remote storage (S3, etc.) is implemented by the shell as `GroupStoragePlugin` and injected; see `node/storage_plugins.mjs` for the local reference implementation.
@@ -11,7 +11,7 @@ import {
11
11
  import { isHex64, normalizeHex64 } from './hexIds.mjs'
12
12
 
13
13
  /**
14
- *
14
+ * entityHash 编解码与校验(re-export)。
15
15
  */
16
16
  export {
17
17
  encodeEntityHash,
package/dag/index.mjs CHANGED
@@ -252,10 +252,10 @@ export function eventsToMetas(events) {
252
252
  }
253
253
 
254
254
  /**
255
- *
255
+ * DAG 行内容/签名 canonicalize(re-export)。
256
256
  */
257
257
  export { canonicalizeRowContent, canonicalizeSignedRow } from './canonicalize_row.mjs'
258
258
  /**
259
- *
259
+ * 剥离 DAG 事件本地扩展字段(re-export)。
260
260
  */
261
261
  export { stripDagEventLocalExtensions } from './strip_extensions.mjs'
package/dag/storage.mjs CHANGED
@@ -6,9 +6,12 @@ import { createInterface } from 'node:readline'
6
6
  import { Readable } from 'node:stream'
7
7
  import { pipeline } from 'node:stream/promises'
8
8
 
9
- import { atomicTemporaryPath, finalizeAtomicRename } from '../utils/atomic_fs.mjs'
10
9
  import { withAsyncMutex } from '../utils/async_mutex.mjs'
10
+ import { atomicTemporaryPath, finalizeAtomicRename } from '../utils/atomic_fs.mjs'
11
11
 
12
+ /**
13
+ * 原子重命名 finalize(re-export)。
14
+ */
12
15
  export { finalizeAtomicRename } from '../utils/atomic_fs.mjs'
13
16
 
14
17
  /** 流式重写 JSONL 时分块写入的行数上限 */
@@ -20,7 +20,7 @@ export function registerChunkFetchWait(key, expectedHash, timeoutMs, options = {
20
20
  return {
21
21
  done: Promise.resolve(null),
22
22
  /**
23
- *
23
+ * 槽位已满时的空操作取消。
24
24
  */
25
25
  cancel: () => { },
26
26
  }
@@ -8,7 +8,7 @@ import { hashFromPubKeyHex } from '../core/entity_id.mjs'
8
8
  import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
9
9
 
10
10
  /**
11
- *
11
+ * entity 密钥撤销签名的域分隔前缀。
12
12
  */
13
13
  export const ENTITY_KEY_REVOKE_DOMAIN = 'fount-entity-key-revoke'
14
14
 
@@ -26,7 +26,7 @@ export function registerManifestFetchWait(key, expectedKey, timeoutMs) {
26
26
  return {
27
27
  done: Promise.resolve(null),
28
28
  /**
29
- *
29
+ * 槽位已满时的空操作取消。
30
30
  */
31
31
  cancel: () => { },
32
32
  }
@@ -57,7 +57,7 @@ export function registerManifestFetchWait(key, expectedKey, timeoutMs) {
57
57
  return {
58
58
  done,
59
59
  /**
60
- *
60
+ * 取消等待并以 null 结算。
61
61
  */
62
62
  cancel: () => {
63
63
  clearTimeout(timer)
@@ -76,7 +76,7 @@ function readStreamChunk(stream) {
76
76
  if (stream.readableEnded) return Promise.resolve(null)
77
77
  return new Promise((resolve, reject) => {
78
78
  /**
79
- *
79
+ * readable 时读取下一块密文。
80
80
  */
81
81
  const onReadable = () => {
82
82
  cleanup()
@@ -84,7 +84,7 @@ function readStreamChunk(stream) {
84
84
  resolve(next ? Buffer.from(next) : Buffer.alloc(0))
85
85
  }
86
86
  /**
87
- *
87
+ * 流结束时 resolve null。
88
88
  */
89
89
  const onEnd = () => {
90
90
  cleanup()
@@ -98,7 +98,7 @@ function readStreamChunk(stream) {
98
98
  reject(error)
99
99
  }
100
100
  /**
101
- *
101
+ * 注销流事件监听。
102
102
  */
103
103
  const cleanup = () => {
104
104
  stream.off('readable', onReadable)
@@ -130,7 +130,7 @@ export function createManifestPlaintextStream(manifest, partStreams, contentKey)
130
130
 
131
131
  return new Readable({
132
132
  /**
133
- *
133
+ * 按 manifest part 顺序解密密文块并推送明文。
134
134
  */
135
135
  async read() {
136
136
  if (finished) return
@@ -99,7 +99,7 @@ export function attachTrustGraphFedChunkResponder(username, room, fedOut, guardG
99
99
  if (!String(data.requestId || '')) return
100
100
  await handleFedChunkGetIngress(username, data, peerId, (resp, pid) => {
101
101
  /**
102
- *
102
+ * 发送 fed_chunk_data 响应(可经 fedOut 限速队列)。
103
103
  */
104
104
  const send = () => {
105
105
  try { sendChunkData(resp, pid) }
@@ -123,7 +123,7 @@ export function attachTrustGraphFedChunkResponder(username, room, fedOut, guardG
123
123
  if (!String(data.requestId || '')) return
124
124
  await handleFedManifestGetIngress(username, data, peerId, (resp, pid) => {
125
125
  /**
126
- *
126
+ * 发送 fed_manifest_data 响应(可经 fedOut 限速队列)。
127
127
  */
128
128
  const send = () => {
129
129
  try { sendManifestData(resp, pid) }
@@ -26,8 +26,8 @@ const manifestInflight = createInflightTable({
26
26
 
27
27
  /**
28
28
  * 拉取公开 manifest;默认不写盘。`cache: true` 或 `cachePublicManifest` 才缓存。
29
- * 本地已有 publicSig 时仍会 fanout 再校验,按 publishedAt 择新;超时则回退本地。
30
- * key in-flight 去重;调用方外层超时不 abort,后台继续填缓存。
29
+ * 本地已有 publicSig 时立即返回,并后台 fanout;`cache: true` 时按 publishedAt 择新写盘。
30
+ * miss 仍等 fanout。同 key in-flight 去重;调用方外层超时不 abort,后台继续填缓存。
31
31
  * @param {{ username: string, ownerEntityHash: string, logicalPath: string, cache?: boolean, timeoutMs?: number }} context - 拉取上下文
32
32
  * @returns {Promise<import('./manifest.mjs').FileManifest | null>} 验签后的 manifest,失败为 null
33
33
  */
@@ -42,6 +42,7 @@ export async function fetchPublicManifest(context) {
42
42
  : DEFAULT_MANIFEST_FETCH_TIMEOUT_MS
43
43
  const expectedKey = manifestFetchExpectedKey(ownerEntityHash, logicalPath)
44
44
  const inflightKey = `${username}\0${expectedKey}`
45
+ const wantCache = context.cache === true
45
46
 
46
47
  // 先挂 in-flight(同步),再读本地 — 避免并发调用在 await 间隙各自 start
47
48
  const localPromise = loadFileManifest(ownerEntityHash, logicalPath)
@@ -67,15 +68,21 @@ export async function fetchPublicManifest(context) {
67
68
  const hasLocalPublic = local?.transferKeyDescriptor?.type === 'public' && !!local?.meta?.publicSig
68
69
  if (!shared) return hasLocalPublic ? local : null
69
70
 
70
- const result = await shared
71
-
72
- if (result && (!hasLocalPublic || shouldPreferIncomingPublicManifest(local, result))) {
73
- if (context.cache === true)
74
- await cachePublicManifest(ownerEntityHash, logicalPath, result)
75
- return result
71
+ if (hasLocalPublic) {
72
+ // SWR:立刻回本地;后台 fanout 择新写盘(需 cache: true)
73
+ if (wantCache) {
74
+ void shared.then(async result => {
75
+ if (result && shouldPreferIncomingPublicManifest(local, result))
76
+ await cachePublicManifest(ownerEntityHash, logicalPath, result)
77
+ })
78
+ }
79
+ return local
76
80
  }
77
- if (hasLocalPublic) return local
78
- return null
81
+
82
+ const result = await shared
83
+ if (!result) return null
84
+ if (wantCache) await cachePublicManifest(ownerEntityHash, logicalPath, result)
85
+ return result
79
86
  }
80
87
 
81
88
  /**
@@ -75,35 +75,23 @@ export function pickChannel(action, byteLength) {
75
75
  }
76
76
 
77
77
  /**
78
- * 读取 RTC 数据通道当前 bufferedAmount,失败时返回 0
78
+ * 读取 RTC 数据通道当前 bufferedAmount。
79
79
  * @param {RTCDataChannel} channel RTC 数据通道
80
80
  * @returns {number} 缓冲区待发送字节数
81
81
  */
82
82
  export function readBufferedAmount(channel) {
83
- try {
84
- return Number.isFinite(channel?.bufferedAmount) ? Number(channel.bufferedAmount) : 0
85
- }
86
- catch {
87
- return 0
88
- }
83
+ return channel.bufferedAmount
89
84
  }
90
85
 
91
86
  /**
92
87
  * 配置数据通道的 bufferedAmountLowThreshold。
93
88
  * @param {RTCDataChannel} channel RTC 数据通道
94
89
  * @param {number} [thresholdBytes=CHANNEL_LOW_THRESHOLD_BYTES] 低水位阈值(字节)
95
- * @returns {number | null} 实际生效的阈值,不支持时返回 null
90
+ * @returns {number} 实际生效的阈值
96
91
  */
97
92
  export function configureBufferedAmountLowThreshold(channel, thresholdBytes = CHANNEL_LOW_THRESHOLD_BYTES) {
98
- try {
99
- channel.bufferedAmountLowThreshold = thresholdBytes
100
- return Number.isFinite(channel.bufferedAmountLowThreshold)
101
- ? Number(channel.bufferedAmountLowThreshold)
102
- : null
103
- }
104
- catch {
105
- return null
106
- }
93
+ channel.bufferedAmountLowThreshold = thresholdBytes
94
+ return channel.bufferedAmountLowThreshold
107
95
  }
108
96
 
109
97
  /**
@@ -113,18 +101,9 @@ export function configureBufferedAmountLowThreshold(channel, thresholdBytes = CH
113
101
  * @returns {() => void} 取消订阅函数
114
102
  */
115
103
  export function onBufferedAmountLow(channel, callback) {
116
- /**
117
- * bufferedamountlow 事件处理函数。
118
- * @returns {void}
119
- */
120
- const handler = () => callback()
121
- channel.addEventListener?.('bufferedamountlow', handler)
122
- channel.onbufferedamountlow = handler
123
- if (channel.bufferedAmountLow?.subscribe)
124
- channel.bufferedAmountLow.subscribe(handler)
104
+ channel.onbufferedamountlow = callback
125
105
  return () => {
126
- channel.removeEventListener?.('bufferedamountlow', handler)
127
- if (channel.onbufferedamountlow === handler) channel.onbufferedamountlow = null
106
+ if (channel.onbufferedamountlow === callback) channel.onbufferedamountlow = null
128
107
  }
129
108
  }
130
109
 
@@ -163,12 +142,12 @@ export function createChannelSendQueues(options) {
163
142
  const channel = getChannel(channelName)
164
143
  if (channel?.readyState !== 'open') return
165
144
  const queue = queues[channelName]
166
- let i = 0
167
- while (i < queue.length) {
145
+ let sent = 0
146
+ while (sent < queue.length) {
168
147
  if (readBufferedAmount(channel) > highWatermarkBytes) break
169
- channel.send(queue[i++].bytes)
148
+ channel.send(queue[sent++].bytes)
170
149
  }
171
- if (i > 0) queue.splice(0, i)
150
+ if (sent > 0) queue.splice(0, sent)
172
151
  }
173
152
 
174
153
  return {
@@ -1,3 +1,4 @@
1
+ import { toBytes } from '../../core/bytes_codec.mjs'
1
2
  import { getSignalingRuntimeConfig } from '../../node/instance.mjs'
2
3
  import { nodeDebug } from '../../node/log.mjs'
3
4
  import { ms } from '../../utils/duration.mjs'
@@ -11,20 +12,12 @@ import {
11
12
  onBufferedAmountLow,
12
13
  } from '../channel_mux.mjs'
13
14
  import { asLinkHandle, createLinkPipe } from '../pipe.mjs'
14
- import {
15
- attachChannelMessageListener,
16
- attachDataChannelListener,
17
- attachIceCandidateListener,
18
- loadNodeRtcPolyfill,
19
- dataToBytes,
20
- waitForChannelState,
21
- } from '../rtc.mjs'
15
+ import { loadNodeRtcPolyfill, waitForChannelState } from '../rtc/index.mjs'
22
16
  import { extractDtlsFingerprint } from '../sdp_fingerprint.mjs'
23
17
 
24
18
  import { LINK_LEVEL_WEBRTC } from './levels.mjs'
25
19
 
26
20
  /**
27
- * 将错误对象格式化为短字符串。
28
21
  * @param {unknown} error 原始错误
29
22
  * @returns {string} 短 reason
30
23
  */
@@ -35,27 +28,21 @@ function formatErrorReason(error) {
35
28
  let cachedAvailable = null
36
29
 
37
30
  /**
38
- * @returns {boolean} 是否跑在 Deno
39
- */
40
- function isDenoRuntime() {
41
- return typeof globalThis.Deno !== 'undefined'
42
- }
43
-
44
- /**
45
- * 探测 WebRTC(node-datachannel)是否可用。
31
+ * 探测 WebRTC(node-datachannel 或纯 JS fallback)是否可用。
46
32
  * @returns {Promise<boolean>} 可用为 true
47
33
  */
48
34
  export async function canUseWebRtcLink() {
49
35
  if (cachedAvailable !== null) return cachedAvailable
50
36
  try {
51
- await loadNodeRtcPolyfill()
37
+ const rtc = await loadNodeRtcPolyfill()
52
38
  cachedAvailable = true
39
+ nodeDebug('p2p:webrtc backend', { backend: rtc.backend })
53
40
  }
54
41
  catch (error) {
55
42
  cachedAvailable = false
56
43
  const reason = formatErrorReason(error)
57
44
  nodeDebug('p2p:webrtc unavailable', {
58
- err: isDenoRuntime()
45
+ err: typeof globalThis.Deno !== 'undefined'
59
46
  ? `${reason} | deno: see docs/runtime.md (node-datachannel scripts only)`
60
47
  : reason,
61
48
  })
@@ -73,15 +60,16 @@ export async function canUseWebRtcLink() {
73
60
  * @param {number} [options.heartbeatMs] 心跳间隔
74
61
  * @param {number} [options.idleTimeoutMs] 无入站流量超时
75
62
  * @param {number} [options.handshakeTimeoutMs] 握手超时
76
- * @param {{ RTCPeerConnection: typeof RTCPeerConnection } | null} [options.rtc] RTC 构造器
63
+ * @param {import('../rtc/polyfill.mjs').LoadedRtcPolyfill} [options.rtc] RTC 构造器
77
64
  * @param {{ nodeHash?: string, nodePubKey?: string, secretKey?: Uint8Array, nonce?: string } | null} [options.localIdentity] 本地握手身份
78
65
  * @returns {Promise<import('./index.mjs').LinkHandle>} link 句柄
79
66
  */
80
67
  export async function createWebRtcLink(options) {
81
68
  const handshakeTimeoutMs = Number(options.handshakeTimeoutMs) || ms('10s')
82
69
  const channelOpenTimeoutMs = Math.max(handshakeTimeoutMs, ms('30s'))
83
- const trickleIceOff = getSignalingRuntimeConfig().trickleIceOff === true
84
70
  const rtc = options.rtc ?? await loadNodeRtcPolyfill()
71
+ // 纯 JS 后端只做 trickle(SDP 不含 candidate);强制开启 trickle。
72
+ const trickleIceOff = !rtc.forcesTrickleIce && getSignalingRuntimeConfig().trickleIceOff === true
85
73
  const peerConnection = new rtc.RTCPeerConnection(options.iceServers?.length ? { iceServers: options.iceServers } : undefined)
86
74
  const remoteSignalQueue = []
87
75
  const seenRemoteSignals = createLruMap(1024)
@@ -187,14 +175,6 @@ export async function createWebRtcLink(options) {
187
175
  await flushQueuedIceCandidates()
188
176
  }
189
177
 
190
- /**
191
- * @param {unknown} error addIceCandidate 错误
192
- * @returns {boolean} 是否应暂存后重试
193
- */
194
- function shouldRetryQueuedIce(error) {
195
- return /without ice transport/i.test(String(error?.message ?? error ?? ''))
196
- }
197
-
198
178
  /**
199
179
  * @returns {Promise<void>}
200
180
  */
@@ -216,7 +196,7 @@ export async function createWebRtcLink(options) {
216
196
  await peerConnection.addIceCandidate(candidate)
217
197
  }
218
198
  catch (error) {
219
- if (shouldRetryQueuedIce(error)) {
199
+ if (/without ice transport/i.test(String(error?.message ?? error))) {
220
200
  remoteSignalQueue.unshift(candidate)
221
201
  return
222
202
  }
@@ -261,7 +241,7 @@ export async function createWebRtcLink(options) {
261
241
  await peerConnection.addIceCandidate(message.candidate)
262
242
  }
263
243
  catch (error) {
264
- if (shouldRetryQueuedIce(error)) {
244
+ if (/without ice transport/i.test(String(error?.message ?? error))) {
265
245
  remoteSignalQueue.push(message.candidate)
266
246
  return
267
247
  }
@@ -272,19 +252,23 @@ export async function createWebRtcLink(options) {
272
252
 
273
253
  /**
274
254
  * @param {RTCDataChannel} channel RTC 数据通道
275
- * @returns {Promise<void>}
255
+ * @returns {void}
276
256
  */
277
- async function attachChannel(channel) {
257
+ function attachChannel(channel) {
278
258
  if (channel.label === CHANNEL_CONTROL) controlChannel = channel
279
259
  else if (channel.label === CHANNEL_BULK) bulkChannel = channel
280
260
  else return
281
- attachChannelMessageListener(channel, data => {
261
+ /**
262
+ * @param {MessageEvent} event 入站消息事件
263
+ * @returns {void}
264
+ */
265
+ channel.onmessage = event => {
282
266
  try {
283
- if (typeof data === 'string') pipe.handleInbound(data)
284
- else pipe.handleInbound(dataToBytes(data))
267
+ const data = event.data
268
+ pipe.handleInbound(typeof data === 'string' ? data : toBytes(data, { allowString: true }))
285
269
  }
286
270
  catch { /* drop */ }
287
- })
271
+ }
288
272
  attachBackpressurePump(channel)
289
273
  }
290
274
 
@@ -312,20 +296,29 @@ export async function createWebRtcLink(options) {
312
296
  void handleRemoteSignal(message).catch(error => pipe.close(`signal-error:${formatErrorReason(error)}`))
313
297
  }) ?? null
314
298
 
315
- attachIceCandidateListener(peerConnection, event => {
299
+ /**
300
+ * @param {RTCPeerConnectionIceEvent} event ICE candidate 事件
301
+ * @returns {void}
302
+ */
303
+ peerConnection.onicecandidate = event => {
316
304
  if (trickleIceOff || !event.candidate) return
317
305
  void sendSignal({
318
306
  type: 'ice',
319
307
  candidate: typeof event.candidate.toJSON === 'function' ? event.candidate.toJSON() : event.candidate,
320
308
  }).catch(error => pipe.close(`signal-send-failed:${formatErrorReason(error)}`))
321
- })
322
- attachDataChannelListener(peerConnection, event => {
323
- void attachChannel(event.channel)
324
- .then(() => maybeStartPostOpenFlow())
325
- .catch(error => pipe.close(`channel-attach-failed:${error?.message ?? error}`))
326
- })
309
+ }
310
+ /**
311
+ * @param {RTCDataChannelEvent} event 远端 data channel 事件
312
+ * @returns {void}
313
+ */
314
+ peerConnection.ondatachannel = event => {
315
+ attachChannel(event.channel)
316
+ void maybeStartPostOpenFlow().catch(error => pipe.close(`channel-attach-failed:${formatErrorReason(error)}`))
317
+ }
327
318
 
328
- /** 连接失败/关闭时关掉 pipe。 */
319
+ /**
320
+ * 连接失败/断开时递增重连计数并关闭 pipe。
321
+ */
329
322
  peerConnection.onconnectionstatechange = () => {
330
323
  if (['failed', 'closed', 'disconnected'].includes(peerConnection.connectionState)) {
331
324
  reconnectCount++
@@ -334,8 +327,8 @@ export async function createWebRtcLink(options) {
334
327
  }
335
328
 
336
329
  if (options.initiator) {
337
- await attachChannel(peerConnection.createDataChannel(CHANNEL_CONTROL))
338
- await attachChannel(peerConnection.createDataChannel(CHANNEL_BULK))
330
+ attachChannel(peerConnection.createDataChannel(CHANNEL_CONTROL))
331
+ attachChannel(peerConnection.createDataChannel(CHANNEL_BULK))
339
332
  const offer = await peerConnection.createOffer()
340
333
  await peerConnection.setLocalDescription(offer)
341
334
  await waitForIceGatheringComplete()
@@ -345,7 +338,7 @@ export async function createWebRtcLink(options) {
345
338
  })
346
339
  }
347
340
 
348
- void maybeStartPostOpenFlow().catch(error => pipe.close(`open-flow-failed:${error?.message ?? error}`))
341
+ void maybeStartPostOpenFlow().catch(error => pipe.close(`open-flow-failed:${formatErrorReason(error)}`))
349
342
 
350
343
  return asLinkHandle(pipe, {
351
344
  /**
@@ -353,7 +346,7 @@ export async function createWebRtcLink(options) {
353
346
  * @param {'control' | 'bulk'} name 通道名
354
347
  * @returns {RTCDataChannel | null} 测试用通道
355
348
  */
356
- _channelForTest(name) {
349
+ channelForTest(name) {
357
350
  return name === CHANNEL_CONTROL ? controlChannel : bulkChannel
358
351
  },
359
352
  })
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 等待 data channel 进入 open 或 close 状态,超时则 reject。
3
+ * 同一 channel 可并发等待:新订阅链式调用已有 onopen/onclose,cleanup 只摘掉自身。
4
+ * @param {RTCDataChannel} channel RTC 数据通道
5
+ * @param {'open' | 'close'} eventName 目标状态事件名
6
+ * @param {number} timeoutMs 超时毫秒数
7
+ * @returns {Promise<void>}
8
+ */
9
+ export function waitForChannelState(channel, eventName, timeoutMs) {
10
+ return new Promise((resolve, reject) => {
11
+ if (eventName === 'open' && channel.readyState === 'open') return resolve()
12
+ if (eventName === 'close' && channel.readyState === 'closed') return resolve()
13
+ const eventHandlerProperty = eventName === 'open' ? 'onopen' : 'onclose'
14
+ const previousHandler = channel[eventHandlerProperty]
15
+ let active = true
16
+ const timer = setTimeout(() => {
17
+ cleanup()
18
+ reject(new Error(`p2p: data channel ${eventName} timeout after ${timeoutMs}ms`))
19
+ }, timeoutMs)
20
+ /**
21
+ * 清除超时并恢复 data channel 事件 handler。
22
+ */
23
+ const cleanup = () => {
24
+ if (!active) return
25
+ active = false
26
+ clearTimeout(timer)
27
+ if (channel[eventHandlerProperty] === chained) channel[eventHandlerProperty] = previousHandler
28
+ }
29
+ /**
30
+ * @param {...any} eventArguments 原 handler 参数
31
+ * @returns {void}
32
+ */
33
+ const chained = (...eventArguments) => {
34
+ previousHandler?.(...eventArguments)
35
+ if (!active) return
36
+ cleanup()
37
+ resolve()
38
+ }
39
+ channel[eventHandlerProperty] = chained
40
+ })
41
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * 服务端 WebRTC polyfill 在 Windows 等环境常产出 `.local` host candidate,
3
+ * 远端无法解析。按 iceLocalHostnamePolicy 改写为 loopback 或丢弃。
4
+ */
5
+
6
+ /** @typedef {'none' | 'rewrite-loopback' | 'drop'} IceLocalHostnamePolicy */
7
+
8
+ /**
9
+ * @param {string | null | undefined} candidateSdp ICE candidate SDP 行
10
+ * @param {IceLocalHostnamePolicy} policy 处理策略
11
+ * @returns {string | null} 处理后的 SDP;drop 策略下不可用时返回 null
12
+ */
13
+ export function applyIceLocalHostnamePolicy(candidateSdp, policy) {
14
+ const sdp = String(candidateSdp || '').trim()
15
+ if (!sdp || !/\.local/i.test(sdp)) return sdp || null
16
+ if (!/\btyp host\b/i.test(sdp)) return sdp
17
+ if (policy === 'drop') return null
18
+ if (policy === 'rewrite-loopback') {
19
+ const rewritten = sdp.replace(/(\s)[\w-]+\.local(\s|$)/gi, '$1127.0.0.1$2')
20
+ return rewritten === sdp ? null : rewritten
21
+ }
22
+ return sdp
23
+ }
24
+
25
+ /**
26
+ * @param {RTCIceCandidate | { candidate?: string } | null | undefined} candidate ICE candidate
27
+ * @param {typeof RTCIceCandidate} RTCIceCandidateCtor 构造函数
28
+ * @param {IceLocalHostnamePolicy} policy 处理策略
29
+ * @returns {RTCIceCandidate | { candidate?: string } | null | undefined} 过滤/改写后的 candidate
30
+ */
31
+ export function filterIceLocalHostnameCandidate(candidate, RTCIceCandidateCtor, policy) {
32
+ if (!candidate || policy === 'none') return candidate
33
+ const raw = candidate.candidate ?? candidate.toJSON?.()?.candidate ?? ''
34
+ const rewritten = applyIceLocalHostnamePolicy(raw, policy)
35
+ if (!rewritten) return null
36
+ if (rewritten === raw) return candidate
37
+ const init = typeof candidate.toJSON === 'function'
38
+ ? { ...candidate.toJSON(), candidate: rewritten }
39
+ : { candidate: rewritten, sdpMid: candidate.sdpMid, sdpMLineIndex: candidate.sdpMLineIndex }
40
+ return new RTCIceCandidateCtor(init)
41
+ }
42
+
43
+ /**
44
+ * @param {typeof RTCPeerConnection} BaseRTC 原始 polyfill 类
45
+ * @param {typeof RTCIceCandidate} RTCIceCandidate ICE candidate 构造函数
46
+ * @param {IceLocalHostnamePolicy} [policy='drop'] 策略
47
+ * @returns {typeof RTCPeerConnection} 包装后的 RTCPeerConnection 类(none 时原样返回)
48
+ */
49
+ export function wrapRtcPeerConnectionForIceLocalHostname(BaseRTC, RTCIceCandidate, policy = 'drop') {
50
+ if (policy === 'none') return BaseRTC
51
+
52
+ const baseRoutesIce = typeof BaseRTC.prototype.prepareIceCandidateEvent === 'function'
53
+
54
+ return class IceLocalHostnameFilteredRTCPeerConnection extends BaseRTC {
55
+ /** @type {((event: RTCPeerConnectionIceEvent) => void) | null} */
56
+ #userIceHandler = null
57
+ /** @type {Set<(event: unknown) => void>} */
58
+ #iceListeners = new Set()
59
+ /** 去重:同一次 native 派发可能既走 attribute 又走 listener */
60
+ #lastIceEvent = null
61
+
62
+ /**
63
+ * drop:不派发;rewrite:仅派发替换 candidate 后的事件。
64
+ * @param {RTCPeerConnectionIceEvent | { candidate?: unknown }} event 原始 ICE 事件
65
+ * @returns {RTCPeerConnectionIceEvent | { candidate?: unknown } | null} 规范化后的事件;drop 时为 null
66
+ */
67
+ prepareIceCandidateEvent(event) {
68
+ if (!event?.candidate) return event
69
+ const filtered = filterIceLocalHostnameCandidate(event.candidate, RTCIceCandidate, policy)
70
+ if (!filtered) return null
71
+ return filtered === event.candidate ? event : { candidate: filtered }
72
+ }
73
+
74
+ /**
75
+ * @param {RTCConfiguration} [config] RTC 配置
76
+ */
77
+ constructor(config) {
78
+ super(config)
79
+ if (baseRoutesIce) return
80
+
81
+ // native EventTarget:在派发前规范化,自管 listener,不依赖 stopImmediatePropagation。
82
+ Object.defineProperty(this, 'onicecandidate', {
83
+ configurable: true,
84
+ enumerable: true,
85
+ /**
86
+ * @returns {((event: RTCPeerConnectionIceEvent) => void) | null} 用户 ICE handler
87
+ */
88
+ get: () => this.#userIceHandler,
89
+ /**
90
+ * @param {((event: RTCPeerConnectionIceEvent) => void) | null} handler 用户 ICE handler
91
+ * @returns {void}
92
+ */
93
+ set: handler => { this.#userIceHandler = handler },
94
+ })
95
+ super.addEventListener('icecandidate', event => this.#deliverIce(event))
96
+ }
97
+
98
+ /**
99
+ * @param {RTCPeerConnectionIceEvent | { candidate?: unknown }} event 原始 ICE 事件
100
+ * @returns {void}
101
+ */
102
+ #deliverIce = event => {
103
+ if (this.#lastIceEvent === event) return
104
+ this.#lastIceEvent = event
105
+ const normalized = this.prepareIceCandidateEvent(event)
106
+ if (normalized == null) return
107
+ this.#userIceHandler?.(normalized)
108
+ for (const listener of this.#iceListeners) listener(normalized)
109
+ }
110
+
111
+ /**
112
+ * @param {string} type 事件名
113
+ * @param {(event: unknown) => void} listener 回调
114
+ * @param {boolean | AddEventListenerOptions} [options] 监听选项
115
+ * @returns {void}
116
+ */
117
+ addEventListener(type, listener, options) {
118
+ if (!baseRoutesIce && type === 'icecandidate') {
119
+ this.#iceListeners.add(listener)
120
+ return
121
+ }
122
+ return super.addEventListener(type, listener, options)
123
+ }
124
+
125
+ /**
126
+ * @param {string} type 事件名
127
+ * @param {(event: unknown) => void} listener 回调
128
+ * @param {boolean | EventListenerOptions} [options] 监听选项
129
+ * @returns {void}
130
+ */
131
+ removeEventListener(type, listener, options) {
132
+ if (!baseRoutesIce && type === 'icecandidate') {
133
+ this.#iceListeners.delete(listener)
134
+ return
135
+ }
136
+ return super.removeEventListener(type, listener, options)
137
+ }
138
+ }
139
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 等待 data channel open/close(re-export)。
3
+ */
4
+ export { waitForChannelState } from './channel.mjs'
5
+ /**
6
+ * ICE candidate 本地主机名策略(re-export)。
7
+ */
8
+ export {
9
+ applyIceLocalHostnamePolicy,
10
+ filterIceLocalHostnameCandidate,
11
+ wrapRtcPeerConnectionForIceLocalHostname,
12
+ } from './ice_local_hostname.mjs'
13
+ /**
14
+ * Node WebRTC polyfill 加载(re-export)。
15
+ */
16
+ export { clearNodeRtcPolyfillCache, loadNodeRtcPolyfill } from './polyfill.mjs'