@steve02081504/fount-p2p 0.0.9 → 0.0.11

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.
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto'
2
2
 
3
3
  import { schnorr } from '@noble/curves/secp256k1.js'
4
+ import WebSocket from 'ws'
4
5
 
5
6
  import { sha256Hex } from '../crypto/crypto.mjs'
6
7
 
@@ -11,26 +12,69 @@ export const DEFAULT_RELAY_URLS = [
11
12
  'wss://relay.nostr.band',
12
13
  ]
13
14
 
15
+ /** 单中继 WebSocket 首连超时(短超时 + 并行,避免串行 10s×N)。 */
16
+ export const NOSTR_CONNECT_TIMEOUT_MS = 2_000
17
+ /** 先 close,超时未 CLOSED 再 terminate(给对端优雅关闭的窗口)。 */
18
+ export const NOSTR_CLOSE_GRACE_MS = 1_000
19
+
14
20
  /** Nostr advert 事件 kind。 */
15
21
  export const NOSTR_ADVERT_KIND = 27235
16
22
  /** Nostr signal 事件 kind。 */
17
23
  export const NOSTR_SIGNAL_KIND = 27236
18
24
 
19
25
  /**
20
- * 合并默认与用户配置的中继 URL(去重)。
21
- * @param {string[] | undefined | null} userRelayUrls 用户自定义中继列表
22
- * @returns {string[]} 合并后的中继 URL 列表
26
+ * 关掉 WebSocket:已连上则先 close,grace 内未 CLOSED 再 terminate。
27
+ * 连接中直接 terminate(无握手可优雅收尾)。
28
+ * @param {import('ws').WebSocket} ws 连接
29
+ * @returns {void}
23
30
  */
24
- export function mergeSignalingRelayUrls(userRelayUrls) {
31
+ function dropWebSocket(ws) {
32
+ if (ws.readyState === WebSocket.CLOSED) return
33
+ if (ws.readyState === WebSocket.CONNECTING) {
34
+ try { ws.terminate() } catch { /* ignore */ }
35
+ return
36
+ }
37
+ const timer = setTimeout(() => {
38
+ if (ws.readyState !== WebSocket.CLOSED)
39
+ try { ws.terminate() } catch { /* ignore */ }
40
+ }, NOSTR_CLOSE_GRACE_MS)
41
+ // Grace is polite to the peer; must not pin the process after shutdown.
42
+ timer.unref()
43
+ ws.once('close', () => clearTimeout(timer))
44
+ try {
45
+ ws.close()
46
+ }
47
+ catch {
48
+ clearTimeout(timer)
49
+ try { ws.terminate() } catch { /* ignore */ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * 去重并清洗中继 URL 列表。
55
+ * @param {string[] | undefined | null} urls 原始列表
56
+ * @returns {string[]} 清洗后的列表
57
+ */
58
+ function dedupeRelayUrls(urls) {
25
59
  const seen = new Set()
26
60
  /** @type {string[]} */
27
- const merged = []
28
- for (const url of [...DEFAULT_RELAY_URLS, ...Array.isArray(userRelayUrls) ? userRelayUrls : []]) {
61
+ const out = []
62
+ for (const url of Array.isArray(urls) ? urls : []) {
29
63
  const trimmed = String(url || '').trim()
30
64
  if (!trimmed || seen.has(trimmed)) continue
31
65
  seen.add(trimmed)
32
- merged.push(trimmed)
66
+ out.push(trimmed)
33
67
  }
68
+ return out
69
+ }
70
+
71
+ /**
72
+ * 合并默认与用户配置的中继 URL(去重)。
73
+ * @param {string[] | undefined | null} userRelayUrls 用户自定义中继列表
74
+ * @returns {string[]} 合并后的中继 URL 列表
75
+ */
76
+ export function mergeSignalingRelayUrls(userRelayUrls) {
77
+ const merged = dedupeRelayUrls([...DEFAULT_RELAY_URLS, ...Array.isArray(userRelayUrls) ? userRelayUrls : []])
34
78
  return merged.length ? merged : [...DEFAULT_RELAY_URLS]
35
79
  }
36
80
 
@@ -92,40 +136,67 @@ async function signNostrEvent(kind, tags, content, secretKey) {
92
136
  }
93
137
 
94
138
  /**
95
- * 连接 Nostr 中继 WebSocket
139
+ * 连接 Nostr 中继 WebSocket(`ws` 包,支持 terminate)。
96
140
  * @param {string} relayUrl 中继 URL
97
- * @returns {Promise<WebSocket>} 已打开的 WebSocket
141
+ * @param {number} [timeoutMs] 超时毫秒
142
+ * @param {AbortSignal} [signal] 取消信号
143
+ * @returns {Promise<import('ws').WebSocket>} 已打开的 WebSocket
98
144
  */
99
- function connectRelay(relayUrl) {
145
+ function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
100
146
  return new Promise((resolve, reject) => {
147
+ if (signal?.aborted) {
148
+ reject(new Error('nostr: aborted'))
149
+ return
150
+ }
101
151
  const ws = new WebSocket(relayUrl)
102
- const timer = setTimeout(() => {
103
- try { ws.close() } catch { /* ignore */ }
104
- reject(new Error(`nostr: connect timeout for ${relayUrl}`))
105
- }, 10_000)
106
- ws.addEventListener('open', () => {
152
+ let settled = false
153
+ /**
154
+ * @param {Error} error 失败原因
155
+ * @returns {void}
156
+ */
157
+ const fail = error => {
158
+ if (settled) return
159
+ settled = true
107
160
  clearTimeout(timer)
108
- resolve(ws)
109
- }, { once: true })
110
- ws.addEventListener('error', () => {
161
+ signal?.removeEventListener('abort', onAbort)
162
+ dropWebSocket(ws)
163
+ reject(error)
164
+ }
165
+ /**
166
+ * @returns {void}
167
+ */
168
+ const onAbort = () => fail(new Error('nostr: aborted'))
169
+ const timer = setTimeout(() => fail(new Error(`nostr: connect timeout for ${relayUrl}`)), timeoutMs)
170
+ timer.unref()
171
+ signal?.addEventListener('abort', onAbort, { once: true })
172
+ ws.once('open', () => {
173
+ if (settled) return
174
+ settled = true
111
175
  clearTimeout(timer)
112
- reject(new Error(`nostr: websocket error for ${relayUrl}`))
113
- }, { once: true })
176
+ signal?.removeEventListener('abort', onAbort)
177
+ resolve(ws)
178
+ })
179
+ ws.once('error', () => {
180
+ fail(new Error(`nostr: websocket error for ${relayUrl}`))
181
+ })
114
182
  })
115
183
  }
116
184
 
117
185
  /**
118
- * 向多个中继发布 Nostr 事件。
186
+ * 向多个中继并行发布 Nostr 事件(任一成功即可)。
119
187
  * @param {string[]} relayUrls 中继 URL 列表
120
188
  * @param {object} event 待发布事件
189
+ * @param {AbortSignal} [signal] 取消信号
121
190
  * @returns {Promise<void>}
122
191
  */
123
- async function publishEvent(relayUrls, event) {
192
+ async function publishEvent(relayUrls, event, signal) {
124
193
  let published = false
125
194
  let lastError = null
126
195
  await Promise.allSettled(relayUrls.map(async relayUrl => {
127
- const ws = await connectRelay(relayUrl)
196
+ if (signal?.aborted) throw new Error('nostr: aborted')
197
+ const ws = await connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal)
128
198
  try {
199
+ if (signal?.aborted) throw new Error('nostr: aborted')
129
200
  ws.send(JSON.stringify(['EVENT', event]))
130
201
  published = true
131
202
  }
@@ -134,74 +205,94 @@ async function publishEvent(relayUrls, event) {
134
205
  throw error
135
206
  }
136
207
  finally {
137
- setTimeout(() => { try { ws.close() } catch { /* ignore */ } }, 250)
208
+ dropWebSocket(ws)
138
209
  }
139
210
  }))
140
211
  if (!published) throw lastError || new Error('nostr: no relay accepted publish')
141
212
  }
142
213
 
214
+ /**
215
+ * 并行渐进连接中继:立刻返回,连上后回调;abort 后 stop 接入并 drop 已开 socket。
216
+ * @param {string[]} relayUrls 中继列表
217
+ * @param {(ws: import('ws').WebSocket, relayUrl: string) => void} onOpen 连上回调
218
+ * @param {AbortSignal} signal 取消信号
219
+ * @param {import('ws').WebSocket[]} sockets 已打开 socket 收集
220
+ * @returns {void}
221
+ */
222
+ function connectRelaysProgressive(relayUrls, onOpen, signal, sockets) {
223
+ for (const relayUrl of relayUrls)
224
+ void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal).then(ws => {
225
+ if (signal.aborted) {
226
+ dropWebSocket(ws)
227
+ return
228
+ }
229
+ sockets.push(ws)
230
+ onOpen(ws, relayUrl)
231
+ }).catch(() => { /* 单路失败正常降级 */ })
232
+ }
233
+
143
234
  /**
144
235
  * 创建 Nostr discovery provider。
145
- * @param {{ relayUrls?: string[] }} [options] 可选中继 URL 覆盖
236
+ * subscribe/onSignal/advertise 首调用不阻塞等公网中继;连上后渐进生效。
237
+ * `options.relayUrls` 为最终列表(调用方 `mergeSignalingRelayUrls`,或直接传入 `relayOverride`)。
238
+ * 省略 / `undefined` → 默认公网中继;显式 `[]` 表示无中继(不再回填默认)。
239
+ * @param {{ relayUrls?: string[] | null }} [options] 可选最终中继 URL 列表
146
240
  * @returns {import('./index.mjs').DiscoveryProvider} Nostr 发现提供者
147
241
  */
148
242
  export function createNostrDiscoveryProvider(options = {}) {
149
- const relayUrls = mergeSignalingRelayUrls(options.relayUrls)
243
+ const relayUrls = options.relayUrls != null
244
+ ? dedupeRelayUrls(options.relayUrls)
245
+ : [...DEFAULT_RELAY_URLS]
150
246
  const secretKey = randomBytes(32)
151
247
  return {
152
248
  id: 'nostr',
153
249
  priority: 100,
154
250
  caps: { canDiscover: true, canSignal: true, canRelay: false },
155
251
  /**
156
- * 周期性向中继发布 advert 事件。
252
+ * 周期性向中继发布 advert 事件(首发后台,不阻塞 ensureRuntime)。
157
253
  * @param {string} topic advert 主题
158
254
  * @param {Uint8Array} bytes advert 载荷
159
255
  * @returns {Promise<() => void>} 取消广播函数
160
256
  */
161
257
  async advertise(topic, bytes) {
162
- let closed = false
258
+ const ac = new AbortController()
163
259
  /**
164
260
  * 向中继发布当前 advert。
165
261
  * @returns {Promise<void>}
166
262
  */
167
263
  const publish = async () => {
168
- if (closed) return
264
+ if (ac.signal.aborted) return
169
265
  const event = await signNostrEvent(
170
266
  NOSTR_ADVERT_KIND,
171
267
  [['t', topic], ['x', 'advert']],
172
268
  bytesToBase64(bytes),
173
269
  secretKey,
174
270
  )
175
- await publishEvent(relayUrls, event)
271
+ await publishEvent(relayUrls, event, ac.signal)
176
272
  }
177
- await publish()
273
+ void publish().catch(() => { })
178
274
  const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
179
275
  return () => {
180
- closed = true
276
+ ac.abort()
181
277
  clearInterval(timer)
182
278
  }
183
279
  },
184
280
  /**
185
- * 订阅中继上的 advert 事件。
281
+ * 订阅中继上的 advert 事件(并行渐进连中继,立即返回 cleanup)。
186
282
  * @param {string} topic advert 主题
187
283
  * @param {Function} onAdvert advert 回调
188
284
  * @returns {Promise<() => void>} 取消订阅函数
189
285
  */
190
286
  async subscribe(topic, onAdvert) {
191
- let closed = false
287
+ const ac = new AbortController()
288
+ /** @type {import('ws').WebSocket[]} */
192
289
  const sockets = []
193
290
  const subscriptionId = randomBytes(8).toString('hex')
194
- let connected = 0
195
- for (const relayUrl of relayUrls) {
196
- let ws
197
- try { ws = await connectRelay(relayUrl) }
198
- catch { continue }
199
- connected++
200
- sockets.push(ws)
201
- ws.addEventListener('message', event => {
202
- if (closed) return
291
+ connectRelaysProgressive(relayUrls, (ws, relayUrl) => {
292
+ ws.on('message', data => {
293
+ if (ac.signal.aborted) return
203
294
  let parsed
204
- try { parsed = JSON.parse(String(event.data)) } catch { return }
295
+ try { parsed = JSON.parse(String(data)) } catch { return }
205
296
  if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return
206
297
  const nostrEvent = parsed[2]
207
298
  if (nostrEvent?.kind !== NOSTR_ADVERT_KIND) return
@@ -209,16 +300,14 @@ export function createNostrDiscoveryProvider(options = {}) {
209
300
  catch { /* ignore */ }
210
301
  })
211
302
  ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [NOSTR_ADVERT_KIND], '#t': [topic], '#x': ['advert'] }]))
212
- }
213
- if (!connected) throw new Error('nostr: no relay available for advert subscribe')
303
+ }, ac.signal, sockets)
214
304
  return () => {
215
- closed = true
216
- for (const ws of sockets)
217
- try { ws.close() } catch { /* ignore */ }
305
+ ac.abort()
306
+ for (const ws of sockets) dropWebSocket(ws)
218
307
  }
219
308
  },
220
309
  /**
221
- * 向中继发布 signal 事件。
310
+ * 向中继发布 signal 事件(按需发送,仍等待至少一路成功)。
222
311
  * @param {string} topic 信令 topic
223
312
  * @param {string} to 目标节点标识
224
313
  * @param {Uint8Array} bytes 信令载荷
@@ -234,26 +323,21 @@ export function createNostrDiscoveryProvider(options = {}) {
234
323
  await publishEvent(relayUrls, event)
235
324
  },
236
325
  /**
237
- * 订阅中继上的 signal 事件。
326
+ * 订阅中继上的 signal 事件(并行渐进连中继,立即返回 cleanup)。
238
327
  * @param {string} topic 信令 topic
239
328
  * @param {Function} onSignal 信令回调
240
329
  * @returns {Promise<() => void>} 取消订阅函数
241
330
  */
242
331
  async onSignal(topic, onSignal) {
243
- let closed = false
332
+ const ac = new AbortController()
333
+ /** @type {import('ws').WebSocket[]} */
244
334
  const sockets = []
245
335
  const subscriptionId = randomBytes(8).toString('hex')
246
- let connected = 0
247
- for (const relayUrl of relayUrls) {
248
- let ws
249
- try { ws = await connectRelay(relayUrl) }
250
- catch { continue }
251
- connected++
252
- sockets.push(ws)
253
- ws.addEventListener('message', event => {
254
- if (closed) return
336
+ connectRelaysProgressive(relayUrls, (ws, relayUrl) => {
337
+ ws.on('message', data => {
338
+ if (ac.signal.aborted) return
255
339
  let parsed
256
- try { parsed = JSON.parse(String(event.data)) } catch { return }
340
+ try { parsed = JSON.parse(String(data)) } catch { return }
257
341
  if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return
258
342
  const nostrEvent = parsed[2]
259
343
  if (nostrEvent?.kind !== NOSTR_SIGNAL_KIND) return
@@ -261,12 +345,10 @@ export function createNostrDiscoveryProvider(options = {}) {
261
345
  catch { /* ignore */ }
262
346
  })
263
347
  ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [NOSTR_SIGNAL_KIND], '#t': [topic], '#x': ['signal'] }]))
264
- }
265
- if (!connected) throw new Error('nostr: no relay available for signal subscribe')
348
+ }, ac.signal, sockets)
266
349
  return () => {
267
- closed = true
268
- for (const ws of sockets)
269
- try { ws.close() } catch { /* ignore */ }
350
+ ac.abort()
351
+ for (const ws of sockets) dropWebSocket(ws)
270
352
  }
271
353
  },
272
354
  }
@@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto'
3
3
 
4
4
  import { normalizeHex64 } from '../../core/hexIds.mjs'
5
5
  import { getBtPeerHint } from '../../discovery/bt/peer_hints.mjs'
6
- import { loadBleno, loadNoble, resolveBtRole, waitPoweredOn } from '../../discovery/bt/runtime.mjs'
6
+ import { canUseBluetoothRuntime, loadBleno, loadNoble, resolveBtRole, waitPoweredOn } from '../../discovery/bt/runtime.mjs'
7
7
  import { asLinkHandle, coercePipeInbound, createLinkPipe } from '../pipe.mjs'
8
8
 
9
9
  import { LINK_LEVEL_BLE_GATT } from './levels.mjs'
@@ -14,23 +14,12 @@ export const BLE_DATA_SERVICE_UUID = 'f017f017f017f017f017f017f017f019'
14
14
  export const BLE_DATA_CHAR_UUID = 'f017f017f017f017f017f017f017f01a'
15
15
  const BT_DEVICE_NAME = 'fount-bt'
16
16
 
17
- let cachedAvailable = null
18
-
19
17
  /**
20
- * 探测 BLE GATT 数据链路是否可用(至少 noble central)。
18
+ * 探测 BLE GATT 数据链路是否可用;失败则回落其它 link provider。
21
19
  * @returns {Promise<boolean>} 可用为 true
22
20
  */
23
21
  export async function canUseBleGattLink() {
24
- if (cachedAvailable !== null) return cachedAvailable
25
- try {
26
- const noble = await loadNoble()
27
- const wait = noble.waitForPoweredOnAsync ?? noble.waitForPoweredOn
28
- cachedAvailable = typeof noble.startScanningAsync === 'function' && typeof wait === 'function'
29
- }
30
- catch {
31
- cachedAvailable = false
32
- }
33
- return cachedAvailable
22
+ return canUseBluetoothRuntime()
34
23
  }
35
24
 
36
25
  /**
@@ -301,7 +290,7 @@ export function createBleGattLinkProvider() {
301
290
  return {
302
291
  id: instanceId,
303
292
  level: LINK_LEVEL_BLE_GATT,
304
- caps: { needsOfferAnswer: false, needsDiscoverySignal: false },
293
+ caps: { needsOfferAnswer: false, needsDiscoverySignal: false, probe: 'native' },
305
294
  isAvailable: canUseBleGattLink,
306
295
  /**
307
296
  * @param {{ nodeHash: string }} remote 远端
@@ -15,7 +15,7 @@
15
15
  * @typedef {{
16
16
  * id: string,
17
17
  * level: number,
18
- * caps?: { needsOfferAnswer?: boolean, needsDiscoverySignal?: boolean },
18
+ * caps?: { needsOfferAnswer?: boolean, needsDiscoverySignal?: boolean, probe?: 'sync' | 'native' },
19
19
  * isAvailable: () => boolean | Promise<boolean>,
20
20
  * canReach?: (remote: { nodeHash: string, hints?: object }) => boolean | Promise<boolean>,
21
21
  * dial: (options: object) => Promise<LinkHandle | null>,
@@ -57,7 +57,7 @@ export function listLinkProviders() {
57
57
  }
58
58
 
59
59
  /**
60
- * 清空全部 link provider(测试用)。
60
+ * 清空全部 link provider
61
61
  * @returns {void}
62
62
  */
63
63
  export function clearLinkProviders() {
@@ -69,17 +69,15 @@ export function clearLinkProviders() {
69
69
  * @returns {Promise<LinkProvider[]>} 按 level 降序的可用列表
70
70
  */
71
71
  export async function listAvailableLinkProviders() {
72
- const available = []
73
- for (const provider of listLinkProviders())
72
+ const results = await Promise.all(listLinkProviders().map(async provider => {
74
73
  try {
75
- if (await Promise.resolve(provider.isAvailable()))
76
- available.push(provider)
74
+ return await Promise.resolve(provider.isAvailable()) ? provider : null
77
75
  }
78
76
  catch {
79
- /* probe failure → skip */
77
+ return null
80
78
  }
81
-
82
- return available
79
+ }))
80
+ return results.filter(Boolean)
83
81
  }
84
82
 
85
83
  /** 重导出 level 常量。 */
@@ -283,7 +283,7 @@ export function createLanTcpLinkProvider() {
283
283
  return {
284
284
  id: instanceId,
285
285
  level: LINK_LEVEL_LAN_TCP,
286
- caps: { needsOfferAnswer: false, needsDiscoverySignal: false },
286
+ caps: { needsOfferAnswer: false, needsDiscoverySignal: false, probe: 'sync' },
287
287
  /**
288
288
  * @returns {boolean} 始终可用
289
289
  */
@@ -365,7 +365,7 @@ export function createWebRtcLinkProvider(options = {}) {
365
365
  return {
366
366
  id: 'webrtc',
367
367
  level: LINK_LEVEL_WEBRTC,
368
- caps: { needsOfferAnswer: true, needsDiscoverySignal: true },
368
+ caps: { needsOfferAnswer: true, needsDiscoverySignal: true, probe: 'native' },
369
369
  isAvailable: canUseWebRtcLink,
370
370
  /**
371
371
  * @param {object} dialOptions dial 参数(含 signal / iceServers 等)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -61,7 +61,8 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@noble/curves": "latest",
64
- "node-datachannel": "latest"
64
+ "node-datachannel": "latest",
65
+ "ws": "latest"
65
66
  },
66
67
  "devDependencies": {
67
68
  "@steve02081504/exec": "latest"
@@ -1,12 +1,12 @@
1
1
  import { noteAdvertPeerHints } from '../discovery/advert_peer_hints.mjs'
2
2
  import { advertiseTopic, subscribeTopic } from '../discovery/index.mjs'
3
3
  import { verifySignedAdvert } from '../link/handshake.mjs'
4
+ import { getLinkRegistry } from '../transport/link_registry.mjs'
4
5
  import {
5
- groupRendezvousTopic,
6
6
  decryptSignalPacket,
7
7
  encryptSignalPacket,
8
- getLinkRegistry,
9
- } from '../transport/link_registry.mjs'
8
+ groupRendezvousTopic,
9
+ } from '../transport/signal_crypto.mjs'
10
10
 
11
11
  /**
12
12
  * 在指定 scope 与 roomSecret 下创建 link 层房间(discovery + registry 转发)。
@@ -4,13 +4,13 @@ import { verifySignedAdvert } from '../link/handshake.mjs'
4
4
  import { loadPeerPoolView } from '../node/network.mjs'
5
5
  import { loadReputation } from '../node/reputation_store.mjs'
6
6
 
7
+ import { getLinkRegistry } from './link_registry.mjs'
8
+ import { resolveFederationPoolLimits, selectLinkTargetsFromMembers } from './peer_pool.mjs'
7
9
  import {
8
- groupRendezvousTopic,
9
10
  decryptSignalPacket,
10
11
  encryptSignalPacket,
11
- getLinkRegistry,
12
- } from './link_registry.mjs'
13
- import { resolveFederationPoolLimits, selectLinkTargetsFromMembers } from './peer_pool.mjs'
12
+ groupRendezvousTopic,
13
+ } from './signal_crypto.mjs'
14
14
 
15
15
 
16
16
  /**
@@ -3,11 +3,20 @@
3
3
  */
4
4
 
5
5
  const ICE_URL_RE = /^(stun|turn|turns):/iu
6
- const MAX_ICE_SERVERS = 8
6
+ const MAX_ICE_SERVERS = 12
7
7
 
8
8
  /** @type {{ urls: string, username?: string, credential?: string }[]} */
9
9
  export const DEFAULT_ICE_SERVERS = [
10
+ // Global first — ICE tries in order; regional CN servers are fallback when global STUN is blocked.
10
11
  { urls: 'stun:stun.l.google.com:19302' },
12
+ { urls: 'stun:stun.cloudflare.com:3478' },
13
+ { urls: 'stun:global.stun.twilio.com:3478' },
14
+ { urls: 'stun:stun.nextcloud.com:3478' },
15
+ { urls: 'stun:stun.voip.blackberry.com:3478' },
16
+ { urls: 'stun:stun.freeswitch.org:3478' },
17
+ { urls: 'stun:stun.chat.bilibili.com:3478' },
18
+ { urls: 'stun:stun.hitv.com:3478' },
19
+ { urls: 'stun:stun.miwifi.com:3478' },
11
20
  ]
12
21
 
13
22
  /**