@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 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'
@@ -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.roomSecret
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.ws
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.ws
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
- 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.28",
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