@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 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 |
@@ -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'
@@ -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.roomSecret
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
- if (!relayUrls.length) throw new Error('nostr: no relay')
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(relayUrls.map(async relayUrl => {
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?.aborted) throw new Error('nostr: aborted')
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
- * 共享 relay 会话:多 SUB 复用同一 URL 的 WebSocket,避免 signal/presence/advert 各建一池。
358
- * 仍有活跃 sub 时断线会自动重连并重发 REQ。
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
- if (!session.subs.size) {
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.subs.size) {
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.subs.size) {
444
- if (!session.subs.size && isLiveSharedSession(relayUrl, session))
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.subs.size) {
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.ws
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.subs.size) return
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
- const ws = session.ws
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
- void send({ scope: 'link', action: 'ping', payload: {} }).catch(() => { })
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
- void send({ scope: 'link', action: 'pong', payload: {} }).catch(() => { })
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.data
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
- ...(candidate.toJSON?.() || { sdpMid: candidate.sdpMid, sdpMLineIndex: candidate.sdpMLineIndex }),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -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.roomSecret
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.id
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