@steve02081504/fount-p2p 0.0.27 → 0.0.28
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 +3 -3
- 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
|
@@ -128,7 +128,7 @@ export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
|
|
|
128
128
|
const hash = ingested.verifiedNodeHash
|
|
129
129
|
const skipHash = options.skipNodeHash ? normalizeHex64(options.skipNodeHash) : null
|
|
130
130
|
if (skipHash && hash === skipHash) return hash
|
|
131
|
-
const roomSecret = options
|
|
131
|
+
const { roomSecret } = options
|
|
132
132
|
let firstSeen = true
|
|
133
133
|
if (roomSecret) {
|
|
134
134
|
firstSeen = !visibleByGroup.get(roomSecret)?.has(hash)
|
|
@@ -499,7 +499,7 @@ function releaseSharedRelaySub(relayUrl, subscriptionId) {
|
|
|
499
499
|
const session = sharedRelaySessions.get(relayUrl)
|
|
500
500
|
if (!session) return
|
|
501
501
|
session.subs.delete(subscriptionId)
|
|
502
|
-
const ws = session
|
|
502
|
+
const { ws } = session
|
|
503
503
|
if (ws?.readyState === WebSocket.OPEN)
|
|
504
504
|
try { ws.send(JSON.stringify(['CLOSE', subscriptionId])) } catch { /* ignore */ }
|
|
505
505
|
if (session.subs.size) return
|
|
@@ -518,7 +518,7 @@ function releaseSharedRelaySub(relayUrl, subscriptionId) {
|
|
|
518
518
|
*/
|
|
519
519
|
function registerSharedRelaySub(session, subscriptionId, filter, onEvent) {
|
|
520
520
|
session.subs.set(subscriptionId, { filter, onEvent })
|
|
521
|
-
const ws = session
|
|
521
|
+
const { ws } = session
|
|
522
522
|
if (ws?.readyState !== WebSocket.OPEN) return
|
|
523
523
|
try { ws.send(JSON.stringify(['REQ', subscriptionId, filter])) } catch { /* ignore */ }
|
|
524
524
|
}
|
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
|