@steve02081504/fount-p2p 0.0.27 → 0.0.29
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 +2 -2
- package/discovery/bt/index.mjs +3 -0
- package/discovery/nostr.mjs +193 -24
- package/index.mjs +7 -1
- package/link/pipe.mjs +68 -3
- package/link/providers/webrtc.mjs +1 -1
- package/link/rtc/ice_local_hostname.mjs +1 -1
- package/package.json +1 -1
- package/transport/group_link_set.mjs +1 -1
- package/transport/link_registry.mjs +45 -2
- package/transport/peer_health.mjs +127 -0
- package/transport/runtime_bootstrap.mjs +1 -1
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ Reputation pull/apply is separate: `pullReputationFromNode` → JSON; `setReputa
|
|
|
69
69
|
## Layout
|
|
70
70
|
|
|
71
71
|
| Layer | Directory | Role |
|
|
72
|
-
|
|
72
|
+
| --- | --- | --- |
|
|
73
73
|
| L0 | `core/` | Pure primitives: `hexIds`, `entity_id*`, `canonical_json` |
|
|
74
74
|
| L1 | `crypto/`, `wire/`, `schemas/` | Cryptography, wire protocol, canonical validation |
|
|
75
75
|
| L2 | `node/` | Node runtime: `identity`, `entity_store`, `denylist`, `reputation_store` |
|
|
@@ -83,7 +83,7 @@ Facade entry: `index.mjs` (`startNode`, `createGroupLinkSet`, `registerDiscovery
|
|
|
83
83
|
**Public transport modules:**
|
|
84
84
|
|
|
85
85
|
| Module | Role |
|
|
86
|
-
|
|
86
|
+
| --- | --- |
|
|
87
87
|
| `link_registry.mjs` | fount-network facade: dial fallback, scope/overlay |
|
|
88
88
|
| `user_room.mjs` / `group_link_set.mjs` / `node_scope/wire` + `node_scope/features` | rooms + composable node-scope wires (`registerNodeScopeWireHook`, attaches) |
|
|
89
89
|
| `room_scopes.mjs` / `remote_user_room.mjs` | scope constants / remote user slot |
|
package/discovery/bt/index.mjs
CHANGED
|
@@ -9,6 +9,9 @@ import { noteDiscoveryPeerClue } from '../peer_clue.mjs'
|
|
|
9
9
|
import { getBtPeerHint } from './peer_hints.mjs'
|
|
10
10
|
import { canUseBluetoothRuntime, loadBleno, loadNoble, resolveBtRole, waitPoweredOn } from './runtime.mjs'
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* 重导出有关 BT 生命期的工具函数。
|
|
14
|
+
*/
|
|
12
15
|
export { canUseBluetoothRuntime, waitPoweredOn }
|
|
13
16
|
|
|
14
17
|
const BT_SERVICE_UUID = 'f017f017f017f017f017f017f017f017'
|
package/discovery/nostr.mjs
CHANGED
|
@@ -34,6 +34,8 @@ export const NOSTR_CLOSE_GRACE_MS = 1_000
|
|
|
34
34
|
export const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
|
|
35
35
|
/** 共享 relay 会话断线后重连间隔。 */
|
|
36
36
|
export const NOSTR_RECONNECT_DELAY_MS = 500
|
|
37
|
+
/** 无 sub/publish 工作时共享 relay 空闲回收延迟(给连续 send 复用窗口)。 */
|
|
38
|
+
export const NOSTR_IDLE_DROP_MS = 2_000
|
|
37
39
|
|
|
38
40
|
/** Nostr network advert 事件 kind(addressable,可存储)。 */
|
|
39
41
|
export const NOSTR_ADVERT_KIND = 30787
|
|
@@ -128,7 +130,7 @@ export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
|
|
|
128
130
|
const hash = ingested.verifiedNodeHash
|
|
129
131
|
const skipHash = options.skipNodeHash ? normalizeHex64(options.skipNodeHash) : null
|
|
130
132
|
if (skipHash && hash === skipHash) return hash
|
|
131
|
-
const roomSecret = options
|
|
133
|
+
const { roomSecret } = options
|
|
132
134
|
let firstSeen = true
|
|
133
135
|
if (roomSecret) {
|
|
134
136
|
firstSeen = !visibleByGroup.get(roomSecret)?.has(hash)
|
|
@@ -265,9 +267,10 @@ function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
|
|
|
265
267
|
* @param {string} relayUrl 中继 URL
|
|
266
268
|
* @param {object} event 待发布事件
|
|
267
269
|
* @param {AbortSignal} [signal] 取消信号
|
|
270
|
+
* @param {() => boolean} [isCurrent] 该尝试是否仍是 publishRequest 的当前尝试(断线重发后过期)
|
|
268
271
|
* @returns {Promise<boolean>} relay 是否接受 EVENT
|
|
269
272
|
*/
|
|
270
|
-
function publishEventOnRelay(ws, relayUrl, event, signal) {
|
|
273
|
+
function publishEventOnRelay(ws, relayUrl, event, signal, isCurrent) {
|
|
271
274
|
return new Promise((resolve, reject) => {
|
|
272
275
|
if (signal?.aborted) {
|
|
273
276
|
reject(new Error('nostr: aborted'))
|
|
@@ -285,6 +288,8 @@ function publishEventOnRelay(ws, relayUrl, event, signal) {
|
|
|
285
288
|
clearTimeout(timer)
|
|
286
289
|
signal?.removeEventListener('abort', onAbort)
|
|
287
290
|
ws.off('message', onMessage)
|
|
291
|
+
// 过期的 in-flight 尝试:只清理自身监听器/定时器,不结算,避免影响重发后的当前尝试。
|
|
292
|
+
if (!isCurrent()) return
|
|
288
293
|
if (error) reject(error)
|
|
289
294
|
else resolve(ok)
|
|
290
295
|
}
|
|
@@ -331,42 +336,93 @@ function publishEventOnRelay(ws, relayUrl, event, signal) {
|
|
|
331
336
|
* @returns {Promise<void>}
|
|
332
337
|
*/
|
|
333
338
|
async function publishEvent(relayUrls, event, signal) {
|
|
334
|
-
|
|
339
|
+
const urls = dedupeRelayUrls(relayUrls)
|
|
340
|
+
if (!urls.length) throw new Error('nostr: no relay')
|
|
335
341
|
let published = false
|
|
336
342
|
let lastError = null
|
|
337
|
-
await Promise.allSettled(
|
|
338
|
-
if (signal?.aborted) throw new Error('nostr: aborted')
|
|
339
|
-
const ws = await connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal)
|
|
343
|
+
await Promise.allSettled(urls.map(async relayUrl => {
|
|
340
344
|
try {
|
|
341
|
-
if (signal
|
|
342
|
-
const ok = await publishEventOnRelay(ws, relayUrl, event, signal)
|
|
343
|
-
if (ok) published = true
|
|
345
|
+
if (await publishViaSharedRelay(relayUrl, event, signal)) published = true
|
|
344
346
|
}
|
|
345
347
|
catch (error) {
|
|
346
348
|
lastError = error
|
|
347
|
-
throw error
|
|
348
|
-
}
|
|
349
|
-
finally {
|
|
350
|
-
dropWebSocket(ws)
|
|
351
349
|
}
|
|
352
350
|
}))
|
|
353
351
|
if (!published) throw lastError || new Error('nostr: no relay accepted publish')
|
|
354
352
|
}
|
|
355
353
|
|
|
356
354
|
/**
|
|
357
|
-
*
|
|
358
|
-
*
|
|
355
|
+
* 一次共享 relay publish 请求:入队(pending)与已派发(inflight)时共用同一结构。
|
|
356
|
+
* attempt 标识每次实际发送尝试,断线重发会递增;旧的 in-flight 尝试据此自检为过期后自我清理,
|
|
357
|
+
* 不再结算当前尝试。
|
|
358
|
+
* @typedef {{
|
|
359
|
+
* event: object,
|
|
360
|
+
* signal: AbortSignal | undefined,
|
|
361
|
+
* attempt: number,
|
|
362
|
+
* onAbort: (() => void) | null,
|
|
363
|
+
* removeAbort: (() => void) | null,
|
|
364
|
+
* resolve: (ok: boolean) => void,
|
|
365
|
+
* reject: (err: Error) => void,
|
|
366
|
+
* }} NostrPublishRequest
|
|
367
|
+
*/
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* 共享 relay 会话:多 SUB / publish 复用同一 URL 的 WebSocket,避免 signal/presence/advert 各建一池、
|
|
371
|
+
* 以及每次 send 重开一条连接(内存泄漏)。
|
|
372
|
+
* 仍有活跃 sub 或待发 publish 时断线会自动重连并重发 REQ / 重发 EVENT。
|
|
359
373
|
* @typedef {{
|
|
360
374
|
* ws: import('ws').WebSocket | null,
|
|
361
375
|
* connecting: boolean,
|
|
362
376
|
* reconnectTimer: ReturnType<typeof setTimeout> | null,
|
|
377
|
+
* idleTimer: ReturnType<typeof setTimeout> | null,
|
|
363
378
|
* subs: Map<string, { filter: object, onEvent: (event: object, relayUrl: string) => void }>,
|
|
379
|
+
* pendingPublishes: Array<NostrPublishRequest>,
|
|
380
|
+
* inflightPublishes: Array<NostrPublishRequest>,
|
|
364
381
|
* }} SharedRelaySession
|
|
365
382
|
*/
|
|
366
383
|
|
|
367
384
|
/** @type {Map<string, SharedRelaySession>} */
|
|
368
385
|
const sharedRelaySessions = new Map()
|
|
369
386
|
|
|
387
|
+
/**
|
|
388
|
+
* 会话是否仍有需要连接的工作(sub 或待发 publish)。
|
|
389
|
+
* @param {SharedRelaySession} session 会话
|
|
390
|
+
* @returns {boolean} 是否有活跃工作
|
|
391
|
+
*/
|
|
392
|
+
function hasPendingWork(session) {
|
|
393
|
+
return session.subs.size || session.pendingPublishes.length || session.inflightPublishes.length
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* 取消会话的空闲回收定时器。
|
|
398
|
+
* @param {SharedRelaySession} session 会话
|
|
399
|
+
* @returns {void}
|
|
400
|
+
*/
|
|
401
|
+
function clearIdleDrop(session) {
|
|
402
|
+
if (!session.idleTimer) return
|
|
403
|
+
clearTimeout(session.idleTimer)
|
|
404
|
+
session.idleTimer = null
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 会话无 sub 时,待 publish 清空后延迟回收空闲 socket(给连续 send 复用窗口)。
|
|
409
|
+
* @param {string} relayUrl 中继 URL
|
|
410
|
+
* @param {SharedRelaySession} session 会话
|
|
411
|
+
* @returns {void}
|
|
412
|
+
*/
|
|
413
|
+
function scheduleIdleDrop(relayUrl, session) {
|
|
414
|
+
if (session.subs.size || session.idleTimer) return
|
|
415
|
+
session.idleTimer = setTimeout(() => {
|
|
416
|
+
session.idleTimer = null
|
|
417
|
+
if (!isLiveSharedSession(relayUrl, session) || hasPendingWork(session)) return
|
|
418
|
+
sharedRelaySessions.delete(relayUrl)
|
|
419
|
+
clearSharedRelayReconnect(session)
|
|
420
|
+
if (session.ws) dropWebSocket(session.ws)
|
|
421
|
+
session.ws = null
|
|
422
|
+
}, NOSTR_IDLE_DROP_MS)
|
|
423
|
+
session.idleTimer.unref?.()
|
|
424
|
+
}
|
|
425
|
+
|
|
370
426
|
/**
|
|
371
427
|
* @param {string} relayUrl 中继 URL
|
|
372
428
|
* @param {SharedRelaySession} session 会话
|
|
@@ -408,7 +464,14 @@ function attachSharedRelaySocket(relayUrl, session, ws) {
|
|
|
408
464
|
ws.once('close', () => {
|
|
409
465
|
if (!isLiveSharedSession(relayUrl, session)) return
|
|
410
466
|
session.ws = null
|
|
411
|
-
|
|
467
|
+
// 尚未确认的 inflight publish 重新入队,重连后由 flushPendingPublishes 重发。
|
|
468
|
+
if (session.inflightPublishes.length) {
|
|
469
|
+
session.pendingPublishes.push(...session.inflightPublishes)
|
|
470
|
+
for (const publishRequest of session.inflightPublishes)
|
|
471
|
+
attachQueuedAbort(session, publishRequest)
|
|
472
|
+
session.inflightPublishes = []
|
|
473
|
+
}
|
|
474
|
+
if (!hasPendingWork(session)) {
|
|
412
475
|
sharedRelaySessions.delete(relayUrl)
|
|
413
476
|
return
|
|
414
477
|
}
|
|
@@ -416,6 +479,8 @@ function attachSharedRelaySocket(relayUrl, session, ws) {
|
|
|
416
479
|
})
|
|
417
480
|
for (const [subId, sub] of session.subs)
|
|
418
481
|
try { ws.send(JSON.stringify(['REQ', subId, sub.filter])) } catch { /* ignore */ }
|
|
482
|
+
flushPendingPublishes(relayUrl, session, ws)
|
|
483
|
+
if (!hasPendingWork(session)) scheduleIdleDrop(relayUrl, session)
|
|
419
484
|
}
|
|
420
485
|
|
|
421
486
|
/**
|
|
@@ -433,16 +498,15 @@ function scheduleSharedRelayConnect(relayUrl, session, delayMs = 0) {
|
|
|
433
498
|
const start = () => {
|
|
434
499
|
session.reconnectTimer = null
|
|
435
500
|
if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
|
|
436
|
-
if (!session
|
|
501
|
+
if (!hasPendingWork(session)) {
|
|
437
502
|
sharedRelaySessions.delete(relayUrl)
|
|
438
503
|
return
|
|
439
504
|
}
|
|
440
505
|
session.connecting = true
|
|
441
506
|
void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS).then(ws => {
|
|
442
507
|
session.connecting = false
|
|
443
|
-
if (!isLiveSharedSession(relayUrl, session) || !session
|
|
444
|
-
|
|
445
|
-
sharedRelaySessions.delete(relayUrl)
|
|
508
|
+
if (!isLiveSharedSession(relayUrl, session) || !hasPendingWork(session)) {
|
|
509
|
+
sharedRelaySessions.delete(relayUrl)
|
|
446
510
|
dropWebSocket(ws)
|
|
447
511
|
return
|
|
448
512
|
}
|
|
@@ -454,7 +518,7 @@ function scheduleSharedRelayConnect(relayUrl, session, delayMs = 0) {
|
|
|
454
518
|
url: relayUrl,
|
|
455
519
|
err: String(error?.message || error),
|
|
456
520
|
})
|
|
457
|
-
if (!session
|
|
521
|
+
if (!hasPendingWork(session)) {
|
|
458
522
|
sharedRelaySessions.delete(relayUrl)
|
|
459
523
|
return
|
|
460
524
|
}
|
|
@@ -483,7 +547,10 @@ function acquireSharedRelay(relayUrl) {
|
|
|
483
547
|
ws: null,
|
|
484
548
|
connecting: false,
|
|
485
549
|
reconnectTimer: null,
|
|
550
|
+
idleTimer: null,
|
|
486
551
|
subs: new Map(),
|
|
552
|
+
pendingPublishes: [],
|
|
553
|
+
inflightPublishes: [],
|
|
487
554
|
}
|
|
488
555
|
sharedRelaySessions.set(relayUrl, session)
|
|
489
556
|
scheduleSharedRelayConnect(relayUrl, session)
|
|
@@ -499,12 +566,13 @@ function releaseSharedRelaySub(relayUrl, subscriptionId) {
|
|
|
499
566
|
const session = sharedRelaySessions.get(relayUrl)
|
|
500
567
|
if (!session) return
|
|
501
568
|
session.subs.delete(subscriptionId)
|
|
502
|
-
const ws = session
|
|
569
|
+
const { ws } = session
|
|
503
570
|
if (ws?.readyState === WebSocket.OPEN)
|
|
504
571
|
try { ws.send(JSON.stringify(['CLOSE', subscriptionId])) } catch { /* ignore */ }
|
|
505
|
-
if (session
|
|
572
|
+
if (hasPendingWork(session)) return
|
|
506
573
|
sharedRelaySessions.delete(relayUrl)
|
|
507
574
|
clearSharedRelayReconnect(session)
|
|
575
|
+
clearIdleDrop(session)
|
|
508
576
|
if (ws) dropWebSocket(ws)
|
|
509
577
|
}
|
|
510
578
|
|
|
@@ -518,11 +586,112 @@ function releaseSharedRelaySub(relayUrl, subscriptionId) {
|
|
|
518
586
|
*/
|
|
519
587
|
function registerSharedRelaySub(session, subscriptionId, filter, onEvent) {
|
|
520
588
|
session.subs.set(subscriptionId, { filter, onEvent })
|
|
521
|
-
|
|
589
|
+
clearIdleDrop(session)
|
|
590
|
+
const { ws } = session
|
|
522
591
|
if (ws?.readyState !== WebSocket.OPEN) return
|
|
523
592
|
try { ws.send(JSON.stringify(['REQ', subscriptionId, filter])) } catch { /* ignore */ }
|
|
524
593
|
}
|
|
525
594
|
|
|
595
|
+
/**
|
|
596
|
+
* 将已入队的 publish 逐个派发到已打开 socket 上;复用 publishEventOnRelay 等 OK 回执。
|
|
597
|
+
* 每个 publish 独立携带自己的 message/abort 监听,socket 共享安全。
|
|
598
|
+
* @param {string} relayUrl 中继 URL
|
|
599
|
+
* @param {SharedRelaySession} session 会话
|
|
600
|
+
* @param {import('ws').WebSocket} socket 已打开连接
|
|
601
|
+
* @returns {void}
|
|
602
|
+
*/
|
|
603
|
+
function flushPendingPublishes(relayUrl, session, socket) {
|
|
604
|
+
const pending = session.pendingPublishes
|
|
605
|
+
session.pendingPublishes = []
|
|
606
|
+
for (const publishRequest of pending) {
|
|
607
|
+
publishRequest.removeAbort?.()
|
|
608
|
+
publishRequest.onAbort = null
|
|
609
|
+
publishRequest.removeAbort = null
|
|
610
|
+
const attempt = ++publishRequest.attempt
|
|
611
|
+
session.inflightPublishes.push(publishRequest)
|
|
612
|
+
void publishEventOnRelay(socket, relayUrl, publishRequest.event, publishRequest.signal, () => publishRequest.attempt === attempt)
|
|
613
|
+
.then(
|
|
614
|
+
ok => settleInflightPublish(relayUrl, session, publishRequest, attempt, () => publishRequest.resolve(ok)),
|
|
615
|
+
error => settleInflightPublish(relayUrl, session, publishRequest, attempt, () => publishRequest.reject(error)),
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* 为队列中的 publish 挂上 abort 处理:先移除监听器(释放 signal 对回调/event/session 的引用),
|
|
622
|
+
* 再从队列删除并 reject。
|
|
623
|
+
* @param {SharedRelaySession} session 会话
|
|
624
|
+
* @param {SharedRelaySession['pendingPublishes'][number]} publishRequest 待发请求
|
|
625
|
+
* @returns {void}
|
|
626
|
+
*/
|
|
627
|
+
function attachQueuedAbort(session, publishRequest) {
|
|
628
|
+
const { signal, reject } = publishRequest
|
|
629
|
+
/**
|
|
630
|
+
* 处理 abort 事件
|
|
631
|
+
*/
|
|
632
|
+
publishRequest.onAbort = () => {
|
|
633
|
+
publishRequest.removeAbort?.()
|
|
634
|
+
const pendingIndex = session.pendingPublishes.indexOf(publishRequest)
|
|
635
|
+
if (pendingIndex < 0) return
|
|
636
|
+
session.pendingPublishes.splice(pendingIndex, 1)
|
|
637
|
+
reject(new Error('nostr: aborted'))
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* 移除 abort 监听器
|
|
641
|
+
*/
|
|
642
|
+
publishRequest.removeAbort = () => {
|
|
643
|
+
if (signal) signal.removeEventListener('abort', publishRequest.onAbort)
|
|
644
|
+
}
|
|
645
|
+
if (signal) signal.addEventListener('abort', publishRequest.onAbort, { once: true })
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* publish 在 socket 上得到 OK / 失败 / abort 后结算:从 inflight 移除并 settle。
|
|
650
|
+
* 仅结算仍与当前 attempt 匹配的请求:断线重发后 attempt 已递增,旧尝试的结算在此短路,
|
|
651
|
+
* 避免提前结算重发后的当前尝试。已被 requeue(断线重发)的请求不在 inflight,跳过结算。
|
|
652
|
+
* @param {string} relayUrl 中继 URL
|
|
653
|
+
* @param {SharedRelaySession} session 会话
|
|
654
|
+
* @param {SharedRelaySession['inflightPublishes'][number]} publishRequest 已发送请求
|
|
655
|
+
* @param {number} attempt 本次发送的 attempt 标识
|
|
656
|
+
* @param {() => void} settle 结算回调(resolve/reject)
|
|
657
|
+
* @returns {void}
|
|
658
|
+
*/
|
|
659
|
+
function settleInflightPublish(relayUrl, session, publishRequest, attempt, settle) {
|
|
660
|
+
if (publishRequest.attempt !== attempt) return
|
|
661
|
+
const inflightIndex = session.inflightPublishes.indexOf(publishRequest)
|
|
662
|
+
if (inflightIndex < 0) return
|
|
663
|
+
session.inflightPublishes.splice(inflightIndex, 1)
|
|
664
|
+
settle()
|
|
665
|
+
if (!hasPendingWork(session)) scheduleIdleDrop(relayUrl, session)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* 通过共享 relay 会话发布 EVENT:复用已打开 socket,避免每次 send 重开一条连接(内存泄漏)。
|
|
670
|
+
* 无现成连接时入队并触发连接,连上后由 flushPendingPublishes 统一派发。
|
|
671
|
+
* @param {string} relayUrl 中继 URL
|
|
672
|
+
* @param {object} event 待发布事件
|
|
673
|
+
* @param {AbortSignal} [signal] 取消信号
|
|
674
|
+
* @returns {Promise<boolean>} relay 是否接受 EVENT
|
|
675
|
+
*/
|
|
676
|
+
function publishViaSharedRelay(relayUrl, event, signal) {
|
|
677
|
+
return new Promise((resolve, reject) => {
|
|
678
|
+
if (signal?.aborted) {
|
|
679
|
+
reject(new Error('nostr: aborted'))
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
const session = acquireSharedRelay(relayUrl)
|
|
683
|
+
clearIdleDrop(session)
|
|
684
|
+
/** @type {SharedRelaySession['pendingPublishes'][number]} */
|
|
685
|
+
const publishRequest = { event, signal, attempt: 0, resolve, reject, onAbort: null, removeAbort: null }
|
|
686
|
+
attachQueuedAbort(session, publishRequest)
|
|
687
|
+
session.pendingPublishes.push(publishRequest)
|
|
688
|
+
if (session.ws?.readyState === WebSocket.OPEN)
|
|
689
|
+
flushPendingPublishes(relayUrl, session, session.ws)
|
|
690
|
+
else
|
|
691
|
+
scheduleSharedRelayConnect(relayUrl, session)
|
|
692
|
+
})
|
|
693
|
+
}
|
|
694
|
+
|
|
526
695
|
/**
|
|
527
696
|
* 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。多订阅共享每 URL 一条连接。
|
|
528
697
|
* @param {string[]} relayUrls 中继 URL 列表
|
package/index.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
setSignalingRuntimeConfig,
|
|
19
19
|
} from './node/instance.mjs'
|
|
20
20
|
import { setConnectivityDebug } from './node/log.mjs'
|
|
21
|
+
import { loadReputation } from './node/reputation_store.mjs'
|
|
21
22
|
import {
|
|
22
23
|
attachReputationSyncWire,
|
|
23
24
|
getReputationExportAllowlist,
|
|
@@ -30,7 +31,6 @@ import {
|
|
|
30
31
|
setTrustSyncDonors,
|
|
31
32
|
unlockReputationMax,
|
|
32
33
|
} from './node/reputation_sync.mjs'
|
|
33
|
-
import { loadReputation } from './node/reputation_store.mjs'
|
|
34
34
|
import { getRoutingProfile, setRoutingProfile } from './node/routing_profile.mjs'
|
|
35
35
|
import { createGroupLinkSet } from './transport/group_link_set.mjs'
|
|
36
36
|
import {
|
|
@@ -38,6 +38,9 @@ import {
|
|
|
38
38
|
ensureLinkToNode,
|
|
39
39
|
ensureOverlayRouter,
|
|
40
40
|
getLinkRegistry,
|
|
41
|
+
getPeerHealth,
|
|
42
|
+
listPeerHealth,
|
|
43
|
+
onPeerHealth,
|
|
41
44
|
reloadDiscoveryRelays,
|
|
42
45
|
sendToNodeLink,
|
|
43
46
|
} from './transport/link_registry.mjs'
|
|
@@ -66,6 +69,7 @@ export {
|
|
|
66
69
|
getLinkRegistry,
|
|
67
70
|
getNodeDir,
|
|
68
71
|
getNodeHash,
|
|
72
|
+
getPeerHealth,
|
|
69
73
|
getReputationExportAllowlist,
|
|
70
74
|
getReputationLocks,
|
|
71
75
|
getRoutingProfile,
|
|
@@ -74,8 +78,10 @@ export {
|
|
|
74
78
|
initNode,
|
|
75
79
|
isInfraRunning,
|
|
76
80
|
isNodeInitialized,
|
|
81
|
+
listPeerHealth,
|
|
77
82
|
loadReputation,
|
|
78
83
|
lockReputationMax,
|
|
84
|
+
onPeerHealth,
|
|
79
85
|
pullReputationFromNode,
|
|
80
86
|
registerDiscoveryProvider,
|
|
81
87
|
registerLinkProvider,
|
package/link/pipe.mjs
CHANGED
|
@@ -10,6 +10,11 @@ import { buildAuth, buildHello, parseHello, verifyAuth } from './handshake.mjs'
|
|
|
10
10
|
const encoder = new TextEncoder()
|
|
11
11
|
const decoder = new TextDecoder()
|
|
12
12
|
|
|
13
|
+
/** RTT 样本滑动窗口大小。 */
|
|
14
|
+
const RTT_WINDOW_SIZE = 10
|
|
15
|
+
/** 未收到 pong 的 ping 记录保留时长。 */
|
|
16
|
+
const PING_RECORD_TTL_MS = 30000
|
|
17
|
+
|
|
13
18
|
/**
|
|
14
19
|
* 把 createLinkPipe 句柄收成上层 LinkHandle(可附带测试/内部字段)。
|
|
15
20
|
* @param {ReturnType<typeof createLinkPipe>} pipe pipe 句柄
|
|
@@ -42,6 +47,11 @@ export function asLinkHandle(pipe, extras = {}) {
|
|
|
42
47
|
* @returns {ReturnType<typeof pipe.onDown>} 取消订阅
|
|
43
48
|
*/
|
|
44
49
|
onDown: (...callArguments) => pipe.onDown(...callArguments),
|
|
50
|
+
/**
|
|
51
|
+
* @param {...any} callArguments 透传 onRtt
|
|
52
|
+
* @returns {ReturnType<typeof pipe.onRtt>} 取消订阅
|
|
53
|
+
*/
|
|
54
|
+
onRtt: (...callArguments) => pipe.onRtt(...callArguments),
|
|
45
55
|
/**
|
|
46
56
|
* @param {...any} callArguments 透传 close
|
|
47
57
|
* @returns {ReturnType<typeof pipe.close>} 关闭完成
|
|
@@ -70,6 +80,7 @@ export function asLinkHandle(pipe, extras = {}) {
|
|
|
70
80
|
* @param {number} [options.heartbeatMs] 心跳间隔
|
|
71
81
|
* @param {number} [options.idleTimeoutMs] 空闲超时
|
|
72
82
|
* @param {number} [options.handshakeTimeoutMs] 握手超时
|
|
83
|
+
* @param {number} [options.rttWindowSize] RTT 样本滑动窗口大小
|
|
73
84
|
* @returns {object} link 句柄 + 入站 API
|
|
74
85
|
*/
|
|
75
86
|
export function createLinkPipe(options) {
|
|
@@ -96,6 +107,20 @@ export function createLinkPipe(options) {
|
|
|
96
107
|
let lastOutboundAt = 0
|
|
97
108
|
let sentFrames = 0
|
|
98
109
|
let recvFrames = 0
|
|
110
|
+
const rttWindowSize = Math.max(2, Math.floor(Number(options.rttWindowSize) || RTT_WINDOW_SIZE))
|
|
111
|
+
let pingSeq = 0
|
|
112
|
+
/** @type {Map<number, number>} seq -> 发送时间戳,未收到 pong 的 ping */
|
|
113
|
+
const outstandingPings = new Map()
|
|
114
|
+
let rttMs = null
|
|
115
|
+
let avgRttMs = null
|
|
116
|
+
let minRttMs = null
|
|
117
|
+
let maxRttMs = null
|
|
118
|
+
let pingCount = 0
|
|
119
|
+
let pongCount = 0
|
|
120
|
+
/** @type {number[]} */
|
|
121
|
+
const rttSamples = []
|
|
122
|
+
/** @type {Set<(rttMs: number) => void>} */
|
|
123
|
+
const rttListeners = new Set()
|
|
99
124
|
const envelopeListeners = new Set()
|
|
100
125
|
const downListeners = new Set()
|
|
101
126
|
const completedFrameIds = createLruMap(4096)
|
|
@@ -142,7 +167,14 @@ export function createLinkPipe(options) {
|
|
|
142
167
|
ready = true
|
|
143
168
|
clearTimeout(handshakeTimer)
|
|
144
169
|
heartbeatTimer = setInterval(() => {
|
|
145
|
-
|
|
170
|
+
pingCount++
|
|
171
|
+
const seq = pingSeq++
|
|
172
|
+
const ts = Date.now()
|
|
173
|
+
const cutoff = ts - PING_RECORD_TTL_MS
|
|
174
|
+
for (const [outSeq, outTs] of outstandingPings)
|
|
175
|
+
if (outTs < cutoff) outstandingPings.delete(outSeq)
|
|
176
|
+
outstandingPings.set(seq, ts)
|
|
177
|
+
void send({ scope: 'link', action: 'ping', payload: { ts, seq } }).catch(() => { })
|
|
146
178
|
}, heartbeatMs)
|
|
147
179
|
idleTimer = setInterval(() => {
|
|
148
180
|
if (Date.now() - lastInboundAt > idleTimeoutMs)
|
|
@@ -218,10 +250,29 @@ export function createLinkPipe(options) {
|
|
|
218
250
|
if (frameId) completedFrameIds.touch(frameId, true)
|
|
219
251
|
if (envelope?.scope === 'link') {
|
|
220
252
|
if (envelope.action === 'ping') {
|
|
221
|
-
|
|
253
|
+
const pingPayload = envelope.payload ?? {}
|
|
254
|
+
void send({ scope: 'link', action: 'pong', payload: { ts: pingPayload.ts, seq: pingPayload.seq } }).catch(() => { })
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
if (envelope.action === 'pong') {
|
|
258
|
+
const pongPayload = envelope.payload ?? {}
|
|
259
|
+
const sentTs = outstandingPings.get(pongPayload.seq)
|
|
260
|
+
if (Number.isFinite(pongPayload.ts) && sentTs !== undefined && sentTs === pongPayload.ts) {
|
|
261
|
+
outstandingPings.delete(pongPayload.seq)
|
|
262
|
+
pongCount++
|
|
263
|
+
const sample = Date.now() - sentTs
|
|
264
|
+
if (sample >= 0) {
|
|
265
|
+
rttMs = sample
|
|
266
|
+
rttSamples.push(sample)
|
|
267
|
+
if (rttSamples.length > rttWindowSize) rttSamples.shift()
|
|
268
|
+
avgRttMs = Math.round(rttSamples.reduce((total, value) => total + value, 0) / rttSamples.length)
|
|
269
|
+
minRttMs = rttSamples.reduce((minimum, sample) => Math.min(minimum, sample))
|
|
270
|
+
maxRttMs = rttSamples.reduce((minimum, sample) => Math.max(minimum, sample))
|
|
271
|
+
emitSafe(rttListeners, rttMs)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
222
274
|
return
|
|
223
275
|
}
|
|
224
|
-
if (envelope.action === 'pong') return
|
|
225
276
|
}
|
|
226
277
|
if (ready && remoteNodeHash)
|
|
227
278
|
emitSafe(envelopeListeners, envelope, remoteNodeHash)
|
|
@@ -350,6 +401,14 @@ export function createLinkPipe(options) {
|
|
|
350
401
|
downListeners.add(callback)
|
|
351
402
|
return () => downListeners.delete(callback)
|
|
352
403
|
},
|
|
404
|
+
/**
|
|
405
|
+
* @param {(rttMs: number) => void} callback RTT 样本回调
|
|
406
|
+
* @returns {() => void} 取消订阅
|
|
407
|
+
*/
|
|
408
|
+
onRtt(callback) {
|
|
409
|
+
rttListeners.add(callback)
|
|
410
|
+
return () => rttListeners.delete(callback)
|
|
411
|
+
},
|
|
353
412
|
close,
|
|
354
413
|
handleInbound,
|
|
355
414
|
startHandshake,
|
|
@@ -369,6 +428,12 @@ export function createLinkPipe(options) {
|
|
|
369
428
|
lastOutboundAt,
|
|
370
429
|
sentFrames,
|
|
371
430
|
recvFrames,
|
|
431
|
+
rttMs,
|
|
432
|
+
avgRttMs,
|
|
433
|
+
minRttMs,
|
|
434
|
+
maxRttMs,
|
|
435
|
+
pingCount,
|
|
436
|
+
pongCount,
|
|
372
437
|
closeReason,
|
|
373
438
|
...options.extraStats?.() ?? {},
|
|
374
439
|
}
|
|
@@ -264,7 +264,7 @@ export async function createWebRtcLink(options) {
|
|
|
264
264
|
*/
|
|
265
265
|
channel.onmessage = event => {
|
|
266
266
|
try {
|
|
267
|
-
const data = event
|
|
267
|
+
const { data } = event
|
|
268
268
|
pipe.handleInbound(typeof data === 'string' ? data : toBytes(data, { allowString: true }))
|
|
269
269
|
}
|
|
270
270
|
catch { /* drop */ }
|
|
@@ -35,7 +35,7 @@ export function filterIceLocalHostnameCandidate(candidate, RTCIceCandidateCtor,
|
|
|
35
35
|
if (!rewritten) return null
|
|
36
36
|
if (rewritten === raw) return candidate
|
|
37
37
|
const init = {
|
|
38
|
-
...
|
|
38
|
+
...candidate.toJSON?.() || { sdpMid: candidate.sdpMid, sdpMLineIndex: candidate.sdpMLineIndex },
|
|
39
39
|
candidate: rewritten,
|
|
40
40
|
}
|
|
41
41
|
return new RTCIceCandidateCtor(init)
|
package/package.json
CHANGED
|
@@ -30,7 +30,7 @@ export function createGroupLinkSet(options) {
|
|
|
30
30
|
const allowNode = options.allowNode ?? (() => true)
|
|
31
31
|
const { groupId } = options
|
|
32
32
|
const scope = options.scope ?? `group:${groupId}`
|
|
33
|
-
const roomSecret = options
|
|
33
|
+
const { roomSecret } = options
|
|
34
34
|
const members = new Set(options.members || [])
|
|
35
35
|
const selfNodeHash = registry.localIdentity.nodeHash
|
|
36
36
|
const groupSettings = options.groupSettings ?? {}
|
|
@@ -17,6 +17,7 @@ import { createLruMap } from '../utils/lru.mjs'
|
|
|
17
17
|
import { DEFAULT_ICE_SERVERS } from './ice_servers.mjs'
|
|
18
18
|
import { createMeshKeepalive } from './mesh_keepalive.mjs'
|
|
19
19
|
import { createOfferAnswerDial } from './offer_answer.mjs'
|
|
20
|
+
import { createPeerHealthTracker } from './peer_health.mjs'
|
|
20
21
|
import { pickMeshEvictionVictim } from './peer_pool.mjs'
|
|
21
22
|
import { createRuntimeBootstrap } from './runtime_bootstrap.mjs'
|
|
22
23
|
|
|
@@ -480,9 +481,9 @@ export function createLinkRegistry(options = {}) {
|
|
|
480
481
|
async function dispatchEnvelope(senderNodeHash, envelope, link) {
|
|
481
482
|
const { scope } = envelope
|
|
482
483
|
for (const [prefix, authorizer] of scopeAuthorizers.entries())
|
|
483
|
-
if (scope.startsWith(prefix))
|
|
484
|
+
if (scope.startsWith(prefix))
|
|
484
485
|
if (!await authorizer(scope, senderNodeHash, envelope, link)) return
|
|
485
|
-
|
|
486
|
+
|
|
486
487
|
for (const [prefix, listeners] of scopeListeners.entries())
|
|
487
488
|
if (scope.startsWith(prefix))
|
|
488
489
|
for (const listener of listeners)
|
|
@@ -510,6 +511,25 @@ export function createLinkRegistry(options = {}) {
|
|
|
510
511
|
return () => scopeAuthorizers.delete(prefix)
|
|
511
512
|
}
|
|
512
513
|
|
|
514
|
+
const peerHealth = createPeerHealthTracker({
|
|
515
|
+
/**
|
|
516
|
+
* @param {(nodeHash: string, link: unknown) => void} listener link up 回调
|
|
517
|
+
* @returns {() => void} 取消订阅
|
|
518
|
+
*/
|
|
519
|
+
onLinkUp: listener => {
|
|
520
|
+
linkUpListeners.add(listener)
|
|
521
|
+
return () => linkUpListeners.delete(listener)
|
|
522
|
+
},
|
|
523
|
+
/**
|
|
524
|
+
* @param {(nodeHash: string) => void} listener link down 回调
|
|
525
|
+
* @returns {() => void} 取消订阅
|
|
526
|
+
*/
|
|
527
|
+
onLinkDown: listener => {
|
|
528
|
+
linkDownListeners.add(listener)
|
|
529
|
+
return () => linkDownListeners.delete(listener)
|
|
530
|
+
},
|
|
531
|
+
})
|
|
532
|
+
|
|
513
533
|
return {
|
|
514
534
|
localIdentity,
|
|
515
535
|
buildLocalAdvert: bootstrap.buildLocalAdvert,
|
|
@@ -629,6 +649,22 @@ export function createLinkRegistry(options = {}) {
|
|
|
629
649
|
},
|
|
630
650
|
registerScopeAuthorizer,
|
|
631
651
|
subscribeScope,
|
|
652
|
+
/**
|
|
653
|
+
* 获取指定邻居的健康记录。
|
|
654
|
+
* @param {string} nodeHash 节点 64 hex
|
|
655
|
+
* @returns {import('./peer_health.mjs').PeerHealthEntry | null} 健康记录;无记录时 null
|
|
656
|
+
*/
|
|
657
|
+
getPeerHealth: peerHealth.getPeerHealth,
|
|
658
|
+
/**
|
|
659
|
+
* @returns {import('./peer_health.mjs').PeerHealthEntry[]} 所有邻居健康记录
|
|
660
|
+
*/
|
|
661
|
+
listPeerHealth: peerHealth.listPeerHealth,
|
|
662
|
+
/**
|
|
663
|
+
* 订阅邻居健康变化。
|
|
664
|
+
* @param {(nodeHash: string, entry: import('./peer_health.mjs').PeerHealthEntry) => void} listener 回调
|
|
665
|
+
* @returns {() => void} 取消订阅
|
|
666
|
+
*/
|
|
667
|
+
onPeerHealth: peerHealth.onPeerHealth,
|
|
632
668
|
/**
|
|
633
669
|
* 监听指定节点的 advert(per-hash,无 topic)。
|
|
634
670
|
* @param {string} nodeHash 目标节点 64 hex
|
|
@@ -652,6 +688,7 @@ export function createLinkRegistry(options = {}) {
|
|
|
652
688
|
*/
|
|
653
689
|
async shutdown() {
|
|
654
690
|
await meshKeepalive?.stop()
|
|
691
|
+
peerHealth.stop()
|
|
655
692
|
setDiscoveryLinkDialer(null)
|
|
656
693
|
setDiscoveryPeerClueListener(null)
|
|
657
694
|
await bootstrap.shutdown()
|
|
@@ -744,6 +781,12 @@ export const registerScopeInterest = bindRegistryMethod('registerScopeInterest')
|
|
|
744
781
|
export const releaseScopeInterest = bindRegistryMethod('releaseScopeInterest')
|
|
745
782
|
/** @type {(...methodArguments: unknown[]) => unknown} */
|
|
746
783
|
export const subscribeScope = bindRegistryMethod('subscribeScope')
|
|
784
|
+
/** @type {(...methodArguments: unknown[]) => unknown} */
|
|
785
|
+
export const getPeerHealth = bindRegistryMethod('getPeerHealth')
|
|
786
|
+
/** @type {(...methodArguments: unknown[]) => unknown} */
|
|
787
|
+
export const listPeerHealth = bindRegistryMethod('listPeerHealth')
|
|
788
|
+
/** @type {(...methodArguments: unknown[]) => unknown} */
|
|
789
|
+
export const onPeerHealth = bindRegistryMethod('onPeerHealth')
|
|
747
790
|
|
|
748
791
|
/**
|
|
749
792
|
* 注册默认 registry 的 scope authorizer。
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { normalizeHex64 } from '../core/hexIds.mjs'
|
|
2
|
+
import { emitSafe } from '../utils/emit_safe.mjs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {{
|
|
6
|
+
* nodeHash: string
|
|
7
|
+
* connected: boolean
|
|
8
|
+
* rttMs: number | null
|
|
9
|
+
* avgRttMs: number | null
|
|
10
|
+
* lastSeenAt: number
|
|
11
|
+
* source: string | null
|
|
12
|
+
* }} PeerHealthEntry
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 聚合邻居链路健康:在 mesh/link 常态维护期间顺带统计各邻居的 RTT 与可达性。
|
|
17
|
+
* 订阅 registry 的 link up/down,并对每条活跃链路监听 onRtt/onDown。
|
|
18
|
+
* @param {object} registry link registry(onLinkUp / onLinkDown)
|
|
19
|
+
* @returns {{
|
|
20
|
+
* getPeerHealth: (nodeHash: string) => PeerHealthEntry | null
|
|
21
|
+
* listPeerHealth: () => PeerHealthEntry[]
|
|
22
|
+
* onPeerHealth: (listener: (nodeHash: string, entry: PeerHealthEntry) => void) => () => void
|
|
23
|
+
* stop: () => void
|
|
24
|
+
* }} peer health 聚合器
|
|
25
|
+
*/
|
|
26
|
+
export function createPeerHealthTracker(registry) {
|
|
27
|
+
/** @type {Map<string, PeerHealthEntry>} */
|
|
28
|
+
const entries = new Map()
|
|
29
|
+
/** @type {Map<string, () => void>} 每条链路在 registry/link 上的取消订阅回调 */
|
|
30
|
+
const cleanups = new Map()
|
|
31
|
+
/** @type {Set<(nodeHash: string, entry: PeerHealthEntry) => void>} */
|
|
32
|
+
const listeners = new Set()
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 合并补丁并通知订阅方。
|
|
36
|
+
* @param {string} nodeHash 远端节点 64 hex
|
|
37
|
+
* @param {Partial<PeerHealthEntry>} patch 补丁
|
|
38
|
+
* @returns {void}
|
|
39
|
+
*/
|
|
40
|
+
function update(nodeHash, patch) {
|
|
41
|
+
const entry = entries.get(nodeHash) ?? {
|
|
42
|
+
nodeHash,
|
|
43
|
+
connected: false,
|
|
44
|
+
rttMs: null,
|
|
45
|
+
avgRttMs: null,
|
|
46
|
+
lastSeenAt: 0,
|
|
47
|
+
source: null,
|
|
48
|
+
}
|
|
49
|
+
if (patch.connected !== undefined) entry.connected = patch.connected
|
|
50
|
+
if (patch.rttMs !== undefined) entry.rttMs = patch.rttMs
|
|
51
|
+
if (patch.avgRttMs !== undefined) entry.avgRttMs = patch.avgRttMs
|
|
52
|
+
if (patch.lastSeenAt !== undefined) entry.lastSeenAt = patch.lastSeenAt
|
|
53
|
+
if (patch.source !== undefined) entry.source = patch.source
|
|
54
|
+
entries.set(nodeHash, entry)
|
|
55
|
+
emitSafe(listeners, nodeHash, entry)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const stopUp = registry.onLinkUp?.((nodeHash, link) => {
|
|
59
|
+
const hash = normalizeHex64(nodeHash)
|
|
60
|
+
if (!hash) return
|
|
61
|
+
const stopRtt = link?.onRtt?.(() => {
|
|
62
|
+
const stats = link.stats?.() ?? {}
|
|
63
|
+
update(hash, {
|
|
64
|
+
rttMs: stats.rttMs ?? null,
|
|
65
|
+
avgRttMs: stats.avgRttMs ?? null,
|
|
66
|
+
lastSeenAt: Date.now(),
|
|
67
|
+
})
|
|
68
|
+
}) ?? null
|
|
69
|
+
const stopDown = link?.onDown?.(() => {
|
|
70
|
+
cleanups.get(hash)?.()
|
|
71
|
+
cleanups.delete(hash)
|
|
72
|
+
update(hash, { connected: false, lastSeenAt: Date.now() })
|
|
73
|
+
}) ?? null
|
|
74
|
+
update(hash, {
|
|
75
|
+
connected: true,
|
|
76
|
+
source: link?.providerId ?? null,
|
|
77
|
+
lastSeenAt: Date.now(),
|
|
78
|
+
})
|
|
79
|
+
cleanups.set(hash, () => {
|
|
80
|
+
stopRtt?.()
|
|
81
|
+
stopDown?.()
|
|
82
|
+
})
|
|
83
|
+
}) ?? null
|
|
84
|
+
const stopDown = registry.onLinkDown?.(nodeHash => {
|
|
85
|
+
const hash = normalizeHex64(nodeHash)
|
|
86
|
+
if (!hash) return
|
|
87
|
+
cleanups.get(hash)?.()
|
|
88
|
+
cleanups.delete(hash)
|
|
89
|
+
update(hash, { connected: false, lastSeenAt: Date.now() })
|
|
90
|
+
}) ?? null
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} nodeHash 远端节点 64 hex
|
|
95
|
+
* @returns {PeerHealthEntry | null} 健康记录;无记录时 null
|
|
96
|
+
*/
|
|
97
|
+
getPeerHealth(nodeHash) {
|
|
98
|
+
return entries.get(normalizeHex64(nodeHash)) ?? null
|
|
99
|
+
},
|
|
100
|
+
/**
|
|
101
|
+
* @returns {PeerHealthEntry[]} 所有邻居健康记录
|
|
102
|
+
*/
|
|
103
|
+
listPeerHealth() {
|
|
104
|
+
return [...entries.values()]
|
|
105
|
+
},
|
|
106
|
+
/**
|
|
107
|
+
* @param {(nodeHash: string, entry: PeerHealthEntry) => void} listener 变化回调
|
|
108
|
+
* @returns {() => void} 取消订阅
|
|
109
|
+
*/
|
|
110
|
+
onPeerHealth(listener) {
|
|
111
|
+
listeners.add(listener)
|
|
112
|
+
return () => listeners.delete(listener)
|
|
113
|
+
},
|
|
114
|
+
/**
|
|
115
|
+
* 停止监听并清空聚合。
|
|
116
|
+
* @returns {void}
|
|
117
|
+
*/
|
|
118
|
+
stop() {
|
|
119
|
+
stopUp?.()
|
|
120
|
+
stopDown?.()
|
|
121
|
+
for (const cleanup of cleanups.values()) cleanup()
|
|
122
|
+
cleanups.clear()
|
|
123
|
+
entries.clear()
|
|
124
|
+
listeners.clear()
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -41,7 +41,7 @@ export function collectFastListenProviders(ownedLanTcp) {
|
|
|
41
41
|
const listenProviders = []
|
|
42
42
|
if (ownedLanTcp) listenProviders.push(ownedLanTcp)
|
|
43
43
|
for (const provider of listLinkProviders()) {
|
|
44
|
-
const id = provider
|
|
44
|
+
const { id } = provider
|
|
45
45
|
if (id.startsWith('lan_tcp') || id.startsWith('ble_gatt')) continue
|
|
46
46
|
if (!provider.ensureListening) continue
|
|
47
47
|
if (providerHasNativeProbe(provider)) continue
|