@steve02081504/fount-p2p 0.0.12 → 0.0.13

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.
Files changed (75) hide show
  1. package/README.md +41 -10
  2. package/core/composite_key.mjs +5 -5
  3. package/crypto/crypto.mjs +3 -3
  4. package/crypto/key.mjs +12 -12
  5. package/dag/storage.mjs +36 -36
  6. package/discovery/advert_peer_hints.mjs +1 -1
  7. package/discovery/adverts.mjs +109 -0
  8. package/discovery/bt/index.mjs +153 -112
  9. package/discovery/bt/probe_child.mjs +2 -1
  10. package/discovery/bt/runtime.mjs +2 -9
  11. package/discovery/index.mjs +267 -62
  12. package/discovery/internal/signal_crypto.mjs +109 -0
  13. package/discovery/lan.mjs +228 -0
  14. package/discovery/nostr.mjs +394 -59
  15. package/federation/chunk_fetch_pending.mjs +10 -13
  16. package/federation/manifest_fetch_pending.mjs +1 -3
  17. package/files/assemble.mjs +14 -14
  18. package/files/assemble_stream.mjs +6 -6
  19. package/files/chunk_responder.mjs +29 -20
  20. package/files/evfs.mjs +29 -28
  21. package/files/manifest_fetch.mjs +16 -3
  22. package/files/public_manifest.mjs +11 -11
  23. package/files/transfer_key_registry.mjs +2 -2
  24. package/index.mjs +86 -11
  25. package/infra/cli.mjs +62 -0
  26. package/infra/debug_log.mjs +56 -0
  27. package/infra/default_node_dir.mjs +22 -0
  28. package/infra/priority.mjs +56 -0
  29. package/infra/service.mjs +140 -0
  30. package/infra/tunables.json +5 -0
  31. package/link/frame.mjs +9 -16
  32. package/link/handshake.mjs +14 -15
  33. package/link/pipe.mjs +8 -8
  34. package/link/providers/ble_gatt.mjs +6 -6
  35. package/link/rtc.mjs +3 -3
  36. package/mailbox/consumer_registry.mjs +2 -2
  37. package/mailbox/deliver_or_store.mjs +5 -5
  38. package/mailbox/wire.mjs +28 -24
  39. package/node/entity_store.mjs +6 -6
  40. package/node/instance.mjs +46 -27
  41. package/node/local_data_revision.mjs +26 -0
  42. package/node/log.mjs +56 -0
  43. package/node/network.mjs +37 -5
  44. package/node/reputation_store.mjs +4 -4
  45. package/node/reputation_sync.mjs +318 -0
  46. package/node/routing_profile.mjs +21 -0
  47. package/node/signaling_config.mjs +30 -11
  48. package/overlay/index.mjs +22 -1
  49. package/package.json +13 -2
  50. package/rooms/scoped_link.mjs +17 -166
  51. package/transport/advert_ingest.mjs +11 -14
  52. package/transport/group_link_set.mjs +149 -99
  53. package/transport/link_registry.mjs +211 -60
  54. package/transport/mesh_keepalive.mjs +217 -0
  55. package/transport/node_scope.mjs +289 -0
  56. package/transport/offer_answer.mjs +45 -48
  57. package/transport/peer_pool.mjs +116 -14
  58. package/transport/remote_user_room.mjs +6 -9
  59. package/transport/{rtc_mdns_filter.mjs → rtc_ice_local_hostname.mjs} +13 -13
  60. package/transport/runtime_bootstrap.mjs +170 -108
  61. package/transport/tunables.json +11 -0
  62. package/transport/tunables.mjs +18 -0
  63. package/transport/user_room.mjs +83 -158
  64. package/trust_graph/build.mjs +0 -2
  65. package/trust_graph/cache.mjs +12 -3
  66. package/trust_graph/registry.mjs +6 -6
  67. package/trust_graph/send.mjs +0 -3
  68. package/utils/async_mutex.mjs +4 -4
  69. package/utils/emit_safe.mjs +3 -3
  70. package/utils/json_io.mjs +12 -12
  71. package/utils/map_pool.mjs +5 -5
  72. package/wire/part_ingress.mjs +32 -28
  73. package/wire/part_query.mjs +26 -20
  74. package/discovery/mdns.mjs +0 -197
  75. package/transport/signal_crypto.mjs +0 -104
@@ -2,18 +2,21 @@ import { Buffer } from 'node:buffer'
2
2
 
3
3
  import { compareHex64Asc, normalizeHex64 } from '../core/hexIds.mjs'
4
4
  import { keyPairFromSeed } from '../crypto/crypto.mjs'
5
- import { subscribeTopic } from '../discovery/index.mjs'
5
+ import { watchVerifiedNodeAdvert, setDiscoveryLinkDialer, prepareConnectToNode } from '../discovery/index.mjs'
6
6
  import { listLinkProviders } from '../link/providers/index.mjs'
7
7
  import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
8
+ import { nodeDebug, shortHash } from '../node/log.mjs'
9
+ import { loadPeerPoolView } from '../node/network.mjs'
8
10
  import { createOverlayRouter } from '../overlay/index.mjs'
9
11
  import { emitSafe } from '../utils/emit_safe.mjs'
10
12
  import { createLruMap } from '../utils/lru.mjs'
11
13
 
12
- import { ingestSignedAdvert } from './advert_ingest.mjs'
14
+ import { applyAdvertPeerHints } from './advert_ingest.mjs'
13
15
  import { DEFAULT_ICE_SERVERS } from './ice_servers.mjs'
16
+ import { createMeshKeepalive } from './mesh_keepalive.mjs'
14
17
  import { createOfferAnswerDial } from './offer_answer.mjs'
18
+ import { pickMeshEvictionVictim } from './peer_pool.mjs'
15
19
  import { createRuntimeBootstrap } from './runtime_bootstrap.mjs'
16
- import { nodeRendezvousTopic } from './signal_crypto.mjs'
17
20
 
18
21
  /**
19
22
  * 解析或从节点种子推导本地身份。
@@ -60,15 +63,23 @@ function subscribeBucket(buckets, key, listener) {
60
63
  * @param {{ nodeHash?: string, nodePubKey?: string, secretKey?: Uint8Array }} [options.localIdentity] 本地身份
61
64
  * @param {RTCConfiguration['iceServers']} [options.iceServers] ICE 服务器列表(包内 webrtc provider 使用)
62
65
  * @param {number} [options.maxActive] 最大并发活跃链路数
66
+ * @param {boolean} [options.meshKeepalive=true] 是否启用 mesh 保活(N/K 扫描拨号)
63
67
  * @param {boolean} [options.autoRegisterDiscoveryProviders] 是否自动注册 discovery provider
64
68
  * @param {boolean} [options.autoRegisterLinkProviders] 是否自动注册内置 link provider
65
69
  * @returns {object} link registry 接口(对上层即 fount 网络:ensure/send/subscribe,无传输类型)
66
70
  */
67
71
  export function createLinkRegistry(options = {}) {
68
72
  const localIdentity = resolveLocalIdentity(options.localIdentity)
69
- const iceServers = options.iceServers?.length ? options.iceServers : DEFAULT_ICE_SERVERS
70
- const maxActive = Math.max(4, Number(options.maxActive) || 32)
71
- const selfTopic = nodeRendezvousTopic(localIdentity.nodeHash)
73
+ let iceServers = options.iceServers?.length ? options.iceServers : DEFAULT_ICE_SERVERS
74
+ let maxActive = Math.max(4, Number(options.maxActive) || 32)
75
+ const savedMaxActive = maxActive
76
+ const meshKeepaliveEnabled = options.meshKeepalive !== false
77
+ /** @type {((nodeHash: string) => number) | null} */
78
+ let priorityWeightFunction = null
79
+ /** @type {Set<string>} */
80
+ let exploreLinkHashes = new Set()
81
+ /** @type {ReturnType<typeof createMeshKeepalive> | null} */
82
+ let meshKeepalive = null
72
83
  /** @type {Map<string, object>} */
73
84
  const links = new Map()
74
85
  /** @type {Map<string, Promise<object | null>>} */
@@ -168,13 +179,12 @@ export function createLinkRegistry(options = {}) {
168
179
 
169
180
  const bootstrap = createRuntimeBootstrap({
170
181
  localIdentity,
171
- selfTopic,
172
182
  autoRegisterDiscoveryProviders: options.autoRegisterDiscoveryProviders !== false,
173
183
  autoRegisterLinkProviders: options.autoRegisterLinkProviders !== false,
174
184
  onInboundLink,
175
185
  /**
176
- * @param {Uint8Array} bytes 入站信令字节
177
- * @returns {void}
186
+ * @param {Uint8Array} bytes 入站加密信令
187
+ * @returns {Promise<void>}
178
188
  */
179
189
  handleIncomingSignal: bytes => handleIncomingSignal(bytes),
180
190
  })
@@ -185,47 +195,41 @@ export function createLinkRegistry(options = {}) {
185
195
  * @returns {number} 权重值
186
196
  */
187
197
  function scopeWeight(remoteNodeHash) {
188
- let weight = 0
198
+ let weight = priorityWeightFunction?.(remoteNodeHash) ?? 0
189
199
  for (const hashes of scopeInterests.values())
190
200
  if (hashes.has(remoteNodeHash)) weight++
191
201
  return weight
192
202
  }
193
203
 
194
204
  /**
195
- * 超出 maxActive 时驱逐权重最低的链路。
205
+ * 超出 maxActive 时驱逐:探索链优先于熟人/scope 权重。
196
206
  * @returns {Promise<void>}
197
207
  */
198
208
  async function trimToBudget() {
199
209
  if (links.size < maxActive) return
200
- let victimHash = null
201
- let victimLink = null
202
- let victimWeight = Infinity
203
- for (const [nodeHash, link] of links) {
204
- const weight = scopeWeight(nodeHash)
205
- if (
206
- victimHash == null
207
- || weight < victimWeight
208
- || (weight === victimWeight && compareHex64Asc(nodeHash, victimHash) < 0)
209
- ) {
210
- victimHash = nodeHash
211
- victimLink = link
212
- victimWeight = weight
213
- }
214
- }
210
+ const peers = loadPeerPoolView()
211
+ const victimHash = pickMeshEvictionVictim(
212
+ [...links.keys()],
213
+ exploreLinkHashes,
214
+ peers.trustedPeers,
215
+ scopeWeight,
216
+ )
217
+ const victimLink = victimHash ? links.get(victimHash) : null
215
218
  if (victimLink) await victimLink.close('budget-evict')
216
219
  }
217
220
 
218
221
  ; ({ handleIncomingSignal, dialOfferAnswer } = createOfferAnswerDial({
219
222
  localIdentity,
220
- iceServers,
221
- selfTopic,
223
+ /**
224
+ * @returns {RTCConfiguration['iceServers']} 当前 ICE 服务器列表
225
+ */
226
+ get iceServers() { return iceServers },
222
227
  signalSessions,
223
228
  registerResolvedLink,
224
229
  trimToBudget,
225
230
  /**
226
- * 读取当前规范链。
227
- * @param {string} remoteNodeHash 远端节点 64 hex
228
- * @returns {object | null | undefined} 规范链实例
231
+ * @param {string} remoteNodeHash 远端 nodeHash
232
+ * @returns {object | null} 已有规范链路
229
233
  */
230
234
  getCanonicalLink: remoteNodeHash => links.get(normalizeHex64(remoteNodeHash)),
231
235
  }))
@@ -262,33 +266,100 @@ export function createLinkRegistry(options = {}) {
262
266
  if (links.has(normalized)) return links.get(normalized)
263
267
  if (inflights.has(normalized)) return await inflights.get(normalized)
264
268
  const task = (async () => {
269
+ nodeDebug('p2p:dial start', { peer: shortHash(normalized) })
270
+ await prepareConnectToNode(normalized)
265
271
  const providers = listLinkProviders()
266
272
  for (const provider of providers)
267
273
  try {
268
274
  if (typeof provider.canReach === 'function') {
269
275
  const reachable = await Promise.resolve(provider.canReach({ nodeHash: normalized }))
270
- if (!reachable) continue
276
+ if (!reachable) {
277
+ nodeDebug('p2p:dial skip', { peer: shortHash(normalized), provider: provider.id, reason: 'canReach=false' })
278
+ continue
279
+ }
271
280
  }
272
281
  if (typeof provider.isAvailable === 'function') {
273
282
  const available = await Promise.resolve(provider.isAvailable())
274
- if (!available) continue
283
+ if (!available) {
284
+ nodeDebug('p2p:dial skip', { peer: shortHash(normalized), provider: provider.id, reason: 'isAvailable=false' })
285
+ continue
286
+ }
275
287
  }
288
+ nodeDebug('p2p:dial try', { peer: shortHash(normalized), provider: provider.id })
276
289
  if (provider.caps?.needsOfferAnswer) {
277
290
  const link = await dialOfferAnswer(provider, normalized)
278
- if (link) return link
291
+ if (link) {
292
+ nodeDebug('p2p:dial ok', { peer: shortHash(normalized), provider: provider.id })
293
+ return link
294
+ }
295
+ nodeDebug('p2p:dial miss', { peer: shortHash(normalized), provider: provider.id })
279
296
  continue
280
297
  }
281
298
  const link = await dialProvider(provider, normalized)
282
- if (link) return link
299
+ if (link) {
300
+ nodeDebug('p2p:dial ok', { peer: shortHash(normalized), provider: provider.id })
301
+ return link
302
+ }
303
+ nodeDebug('p2p:dial miss', { peer: shortHash(normalized), provider: provider.id })
304
+ }
305
+ catch (error) {
306
+ nodeDebug('p2p:dial fail', {
307
+ peer: shortHash(normalized),
308
+ provider: provider.id,
309
+ err: String(error?.message || error),
310
+ })
283
311
  }
284
- catch { /* dial failed — try next provider */ }
285
312
 
313
+ nodeDebug('p2p:dial exhausted', { peer: shortHash(normalized) })
286
314
  return null
287
315
  })().finally(() => inflights.delete(normalized))
288
316
  inflights.set(normalized, task)
289
317
  return await task
290
318
  }
291
319
 
320
+ meshKeepalive = createMeshKeepalive({
321
+ registry: {
322
+ localIdentity,
323
+ /**
324
+ * @returns {Array<{ nodeHash: string, link: object }>} 当前链路列表
325
+ */
326
+ listLinks: () => [...links.entries()].map(([nodeHash, link]) => ({ nodeHash, link })),
327
+ /**
328
+ * @param {string} nodeHash 目标 nodeHash
329
+ * @returns {object | null} 已有链路或 null
330
+ */
331
+ getLink: nodeHash => links.get(normalizeHex64(nodeHash)) || null,
332
+ ensureLinkToNode: ensureDirectLinkToNode,
333
+ /**
334
+ * @param {(nodeHash: string) => void} listener link up 回调
335
+ * @returns {() => void} 取消订阅
336
+ */
337
+ onLinkUp: listener => {
338
+ linkUpListeners.add(listener)
339
+ return () => linkUpListeners.delete(listener)
340
+ },
341
+ /**
342
+ * @param {(nodeHash: string, reason: string) => void} listener link down 回调
343
+ * @returns {() => void} 取消订阅
344
+ */
345
+ onLinkDown: listener => {
346
+ linkDownListeners.add(listener)
347
+ return () => linkDownListeners.delete(listener)
348
+ },
349
+ },
350
+ enabled: meshKeepaliveEnabled,
351
+ })
352
+ exploreLinkHashes = meshKeepalive.exploreLinkHashes
353
+ setDiscoveryLinkDialer(ensureDirectLinkToNode)
354
+
355
+ /**
356
+ *
357
+ */
358
+ const ensureRuntimeWithMesh = async () => {
359
+ await bootstrap.ensureRuntime()
360
+ meshKeepalive?.start()
361
+ }
362
+
292
363
  /**
293
364
  * 经已有直连发送 envelope。
294
365
  * @param {string} remoteNodeHash 远端节点 64 hex
@@ -329,6 +400,13 @@ export function createLinkRegistry(options = {}) {
329
400
  return overlayRouter
330
401
  }
331
402
 
403
+ /**
404
+ * @returns {import('../overlay/index.mjs').OverlayRouter} overlay 路由器单例
405
+ */
406
+ function ensureOverlayRouter() {
407
+ return getOverlayRouter()
408
+ }
409
+
332
410
  /**
333
411
  * 经 overlay 多跳 relay envelope 到无直连的节点。
334
412
  * @param {string} remoteNodeHash 远端节点 64 hex
@@ -416,8 +494,55 @@ export function createLinkRegistry(options = {}) {
416
494
  buildLocalAdvert: bootstrap.buildLocalAdvert,
417
495
  lanTcpPort: bootstrap.lanTcpPort,
418
496
  whenListening: bootstrap.whenListening,
419
- ensureRuntime: bootstrap.ensureRuntime,
497
+ ensureRuntime: ensureRuntimeWithMesh,
498
+ reloadDiscoveryRelays: bootstrap.reloadDiscoveryRelays,
499
+ ensureOverlayRouter,
500
+ getOverlayRouter,
420
501
  ensureLinkToNode: ensureDirectLinkToNode,
502
+ /**
503
+ * 设置最大并发活跃链路数。
504
+ * @param {number} value 最大并发活跃链路数
505
+ * @returns {Promise<void>}
506
+ */
507
+ async setMaxActive(value) {
508
+ maxActive = Math.max(4, Math.min(128, Math.floor(Number(value) || savedMaxActive)))
509
+ await trimToBudget()
510
+ },
511
+ /**
512
+ * @returns {number} 当前 maxActive
513
+ */
514
+ getMaxActive() {
515
+ return maxActive
516
+ },
517
+ /**
518
+ * 设置 ICE 服务器列表。
519
+ * @param {RTCConfiguration['iceServers']} servers ICE 列表
520
+ * @returns {void}
521
+ */
522
+ setIceServers(servers) {
523
+ iceServers = servers?.length ? servers : DEFAULT_ICE_SERVERS
524
+ },
525
+ /**
526
+ * @returns {RTCConfiguration['iceServers']} 当前 ICE 服务器列表
527
+ */
528
+ getIceServers() {
529
+ return iceServers
530
+ },
531
+ /**
532
+ * infra/trim:额外优先级权重;null 清除。
533
+ * @param {((nodeHash: string) => number) | null} weightFunction 额外 trim 权重;null 清除
534
+ * @returns {void}
535
+ */
536
+ setPriorityWeightFunction(weightFunction) {
537
+ priorityWeightFunction = typeof weightFunction === 'function' ? weightFunction : null
538
+ },
539
+ /**
540
+ * @param {string} nodeHash - 节点 64-hex hash
541
+ * @returns {number} 额外路由 trim 权重
542
+ */
543
+ getPriorityWeight(nodeHash) {
544
+ return priorityWeightFunction?.(nodeHash) ?? 0
545
+ },
421
546
  /**
422
547
  * 获取到指定节点的活跃链路。
423
548
  * @param {string} nodeHash 节点 64 hex
@@ -484,18 +609,17 @@ export function createLinkRegistry(options = {}) {
484
609
  registerScopeAuthorizer,
485
610
  subscribeScope,
486
611
  /**
487
- * 订阅指定节点的 advert 广播。
612
+ * 监听指定节点的 advert(per-hash,无 topic)。
488
613
  * @param {string} nodeHash 目标节点 64 hex
489
614
  * @param {(verifiedNodeHash: string, body: object) => void | Promise<void>} onAdvert advert 回调
490
- * @returns {Promise<() => void>} 取消订阅函数
615
+ * @returns {Promise<() => void>} 取消函数
491
616
  */
492
- async subscribeNodeAdvert(nodeHash, onAdvert) {
493
- const topic = nodeRendezvousTopic(nodeHash)
494
- return await subscribeTopic(topic, async (bytes, meta) => {
495
- const ingested = await ingestSignedAdvert(topic, bytes, meta)
496
- if (!ingested) return
497
- recentAdverts.touch(ingested.verifiedNodeHash, Date.now())
498
- await Promise.resolve(onAdvert(ingested.verifiedNodeHash, ingested.body))
617
+ async watchNodeAdvert(nodeHash, onAdvert) {
618
+ const hash = normalizeHex64(nodeHash)
619
+ return await watchVerifiedNodeAdvert(hash, async (verifiedNodeHash, body, meta) => {
620
+ applyAdvertPeerHints(verifiedNodeHash, body, meta)
621
+ recentAdverts.touch(verifiedNodeHash, Date.now())
622
+ await Promise.resolve(onAdvert(verifiedNodeHash, body))
499
623
  })
500
624
  },
501
625
  recentAdverts,
@@ -505,6 +629,8 @@ export function createLinkRegistry(options = {}) {
505
629
  * @returns {Promise<void>}
506
630
  */
507
631
  async shutdown() {
632
+ await meshKeepalive?.stop()
633
+ setDiscoveryLinkDialer(null)
508
634
  await bootstrap.shutdown()
509
635
  overlayRouter?.close()
510
636
  overlayRouter = null
@@ -519,6 +645,26 @@ export function createLinkRegistry(options = {}) {
519
645
  }
520
646
 
521
647
  let defaultRegistry = null
648
+ /** @type {object | null} */
649
+ let pendingRegistryOptions = null
650
+
651
+ /**
652
+ * 在首次 getLinkRegistry 前配置默认 registry 选项。
653
+ * @param {object} options createLinkRegistry 选项
654
+ * @returns {void}
655
+ */
656
+ export function configureLinkRegistry(options = {}) {
657
+ if (defaultRegistry) throw new Error('p2p: configureLinkRegistry must run before getLinkRegistry')
658
+ pendingRegistryOptions = { ...pendingRegistryOptions, ...options }
659
+ }
660
+
661
+ /**
662
+ * @returns {void}
663
+ */
664
+ export function resetLinkRegistryForTests() {
665
+ defaultRegistry = null
666
+ pendingRegistryOptions = null
667
+ }
522
668
 
523
669
  /**
524
670
  * 默认 registry 尚未创建时暂存的 scope authorizer 条目。
@@ -533,7 +679,8 @@ const pendingScopeAuthorizers = []
533
679
  */
534
680
  export function getLinkRegistry() {
535
681
  if (defaultRegistry) return defaultRegistry
536
- defaultRegistry = createLinkRegistry()
682
+ defaultRegistry = createLinkRegistry(pendingRegistryOptions || {})
683
+ pendingRegistryOptions = null
537
684
  for (const entry of pendingScopeAuthorizers)
538
685
  entry.unregister = defaultRegistry.registerScopeAuthorizer(entry.prefix, entry.authorizer)
539
686
  pendingScopeAuthorizers.length = 0
@@ -543,31 +690,35 @@ export function getLinkRegistry() {
543
690
  /**
544
691
  * 默认 registry 方法代理。
545
692
  * @param {string} name registry 方法名
546
- * @returns {(...args: unknown[]) => unknown} 绑定到 getLinkRegistry()[name] 的函数
693
+ * @returns {(...methodArguments: unknown[]) => unknown} 绑定到 getLinkRegistry()[name] 的函数
547
694
  */
548
- const bindRegistryMethod = name => (...args) => getLinkRegistry()[name](...args)
695
+ const bindRegistryMethod = name => (...methodArguments) => getLinkRegistry()[name](...methodArguments)
549
696
 
550
- /** @type {(...args: unknown[]) => unknown} */
697
+ /** 确保 overlay 路由器已创建。 @type {(...methodArguments: unknown[]) => unknown} */
698
+ export const ensureOverlayRouter = bindRegistryMethod('ensureOverlayRouter')
699
+ /** 热重载 discovery relay 配置。 @type {(...methodArguments: unknown[]) => unknown} */
700
+ export const reloadDiscoveryRelays = bindRegistryMethod('reloadDiscoveryRelays')
701
+ /** 确保到 nodeHash 的活跃链路。 @type {(...methodArguments: unknown[]) => unknown} */
551
702
  export const ensureLinkToNode = bindRegistryMethod('ensureLinkToNode')
552
- /** @type {(...args: unknown[]) => unknown} */
703
+ /** @type {(...methodArguments: unknown[]) => unknown} */
553
704
  export const getLink = bindRegistryMethod('getLink')
554
- /** @type {(...args: unknown[]) => unknown} */
705
+ /** @type {(...methodArguments: unknown[]) => unknown} */
555
706
  export const listLinks = bindRegistryMethod('listLinks')
556
- /** @type {(...args: unknown[]) => unknown} */
707
+ /** @type {(...methodArguments: unknown[]) => unknown} */
557
708
  export const closeLink = bindRegistryMethod('closeLink')
558
- /** @type {(...args: unknown[]) => unknown} */
709
+ /** 经活跃链路向节点发送 envelope。 @type {(...methodArguments: unknown[]) => unknown} */
559
710
  export const sendToNodeLink = bindRegistryMethod('sendToNodeLink')
560
- /** @type {(...args: unknown[]) => unknown} */
711
+ /** @type {(...methodArguments: unknown[]) => unknown} */
561
712
  export const relayEnvelopeToNode = bindRegistryMethod('relayEnvelopeToNode')
562
- /** @type {(...args: unknown[]) => unknown} */
713
+ /** @type {(...methodArguments: unknown[]) => unknown} */
563
714
  export const onLinkUp = bindRegistryMethod('onLinkUp')
564
- /** @type {(...args: unknown[]) => unknown} */
715
+ /** @type {(...methodArguments: unknown[]) => unknown} */
565
716
  export const onLinkDown = bindRegistryMethod('onLinkDown')
566
- /** @type {(...args: unknown[]) => unknown} */
717
+ /** @type {(...methodArguments: unknown[]) => unknown} */
567
718
  export const registerScopeInterest = bindRegistryMethod('registerScopeInterest')
568
- /** @type {(...args: unknown[]) => unknown} */
719
+ /** @type {(...methodArguments: unknown[]) => unknown} */
569
720
  export const releaseScopeInterest = bindRegistryMethod('releaseScopeInterest')
570
- /** @type {(...args: unknown[]) => unknown} */
721
+ /** @type {(...methodArguments: unknown[]) => unknown} */
571
722
  export const subscribeScope = bindRegistryMethod('subscribeScope')
572
723
 
573
724
  /**
@@ -0,0 +1,217 @@
1
+ import { normalizeHex64 } from '../core/hexIds.mjs'
2
+ import { listVisibleNodeHashes as discoveryListVisible } from '../discovery/index.mjs'
3
+ import { nodeDebug, shortHash } from '../node/log.mjs'
4
+ import { applyNetworkHint, loadPeerPoolView, promoteExplorePeer } from '../node/network.mjs'
5
+ import { loadReputation } from '../node/reputation_store.mjs'
6
+ import { getRoutingProfile } from '../node/routing_profile.mjs'
7
+
8
+ import { pickMeshEvictionVictim, resolveMeshPoolLimits, selectMeshLinkTargets } from './peer_pool.mjs'
9
+ import { loadTransportTunables } from './tunables.mjs'
10
+
11
+ /** 本机主动关链:不清槽重拨,由下次 tick / 调用方决定。 */
12
+ const INTENTIONAL_CLOSE = new Set([
13
+ 'budget-evict',
14
+ 'manual-close',
15
+ 'registry-shutdown',
16
+ 'inbound-no-nodehash',
17
+ ])
18
+
19
+ /**
20
+ * @param {string | undefined} reason 关链原因
21
+ * @returns {boolean} 是否为本机主动关链(不重拨)
22
+ */
23
+ export function isMeshIntentionalClose(reason) {
24
+ return INTENTIONAL_CLOSE.has(String(reason || ''))
25
+ }
26
+
27
+ /**
28
+ * 创建 mesh 保活控制器:扫描可见节点、按 N/K 拨号、稳定探索晋升熟人。
29
+ * @param {object} deps 依赖
30
+ * @param {object} deps.registry link registry(ensureLinkToNode / listLinks / onLinkUp / onLinkDown)
31
+ * @param {boolean} [deps.enabled=true] 是否启用
32
+ * @returns {{ exploreLinkHashes: Set<string>, start: () => void, stop: () => Promise<void> }} mesh 保活控制器
33
+ */
34
+ export function createMeshKeepalive(deps) {
35
+ const { registry, enabled = true } = deps
36
+ const tunables = loadTransportTunables()
37
+ /** @type {ReturnType<typeof setInterval> | null} */
38
+ let timer = null
39
+ /** @type {Set<string>} */
40
+ const exploreLinks = new Set()
41
+ /** @type {Map<string, number>} */
42
+ const exploreStableSince = new Map()
43
+ /** @type {(() => void) | null} */
44
+ let stopLinkDown = null
45
+ /** @type {(() => void) | null} */
46
+ let stopLinkUp = null
47
+ /** @type {Promise<void> | null} */
48
+ let tickInflight = null
49
+
50
+ /**
51
+ * 非熟人活跃链记入探索槽(含入站);熟人则清掉探索标记。
52
+ * @param {string} nodeHash 对端
53
+ * @param {string[]} [trustedPeers] 熟人表;省略则读盘
54
+ * @returns {void}
55
+ */
56
+ function syncExploreMark(nodeHash, trustedPeers) {
57
+ const hash = normalizeHex64(nodeHash)
58
+ if (!hash) return
59
+ const trusted = trustedPeers ?? loadPeerPoolView().trustedPeers
60
+ if (trusted.includes(hash)) {
61
+ exploreLinks.delete(hash)
62
+ exploreStableSince.delete(hash)
63
+ return
64
+ }
65
+ exploreLinks.add(hash)
66
+ }
67
+
68
+ /**
69
+ * 为拨号腾出空位:优先踢探索。
70
+ * @param {number} needSlots 需要空位
71
+ * @param {string[]} trustedPeers 熟人表
72
+ * @returns {Promise<void>}
73
+ */
74
+ async function evictExploreForRoom(needSlots, trustedPeers) {
75
+ for (let i = 0; i < needSlots; i++) {
76
+ const connected = registry.listLinks().map(entry => entry.nodeHash)
77
+ const victimHash = pickMeshEvictionVictim(connected, exploreLinks, trustedPeers, () => 0)
78
+ if (!victimHash || trustedPeers.includes(victimHash)) break
79
+ const entry = registry.listLinks().find(item => item.nodeHash === victimHash)
80
+ if (!entry?.link?.close) break
81
+ await entry.link.close('budget-evict')
82
+ exploreLinks.delete(victimHash)
83
+ exploreStableSince.delete(victimHash)
84
+ nodeDebug('p2p:mesh evict', { peer: shortHash(victimHash), reason: 'budget-evict' })
85
+ }
86
+ }
87
+
88
+ /**
89
+ * @returns {Promise<void>}
90
+ */
91
+ async function tick() {
92
+ if (!enabled) return
93
+ const limits = resolveMeshPoolLimits(getRoutingProfile(), tunables)
94
+ const promoteMs = Math.max(60_000, Number(tunables.meshPromoteStableMs) || 30 * 60_000)
95
+ const scanLimit = Math.max(8, Number(tunables.meshScanLimit) || 64)
96
+ const visible = await discoveryListVisible({ limit: scanLimit })
97
+ for (const hash of visible)
98
+ applyNetworkHint({ nodeHash: hash, source: 'mesh:scan', kind: 'visible', weight: 0.15 })
99
+
100
+ const peers = loadPeerPoolView()
101
+ const rep = loadReputation()
102
+ const now = Date.now()
103
+ const connected = new Set(registry.listLinks().map(entry => entry.nodeHash))
104
+ for (const nodeHash of connected)
105
+ syncExploreMark(nodeHash, peers.trustedPeers)
106
+
107
+ const targets = selectMeshLinkTargets({
108
+ selfNodeHash: registry.localIdentity.nodeHash,
109
+ trustedPeers: peers.trustedPeers,
110
+ exploreCandidates: [...new Set([...peers.explorePeers, ...visible])],
111
+ hintSources: peers.hintSources,
112
+ limits,
113
+ connectedHashes: connected,
114
+ rep,
115
+ blockedPeers: peers.blockedPeers,
116
+ now,
117
+ })
118
+ const toDial = targets.filter(nodeHash => !connected.has(nodeHash))
119
+ nodeDebug('p2p:mesh tick', {
120
+ N: limits.N,
121
+ K_max: limits.K_max,
122
+ visible: visible.map(hash => shortHash(hash)),
123
+ connected: [...connected].map(hash => shortHash(hash)),
124
+ dial: toDial.map(hash => shortHash(hash)),
125
+ })
126
+ const overflow = connected.size + toDial.length - limits.N
127
+ if (overflow > 0)
128
+ await evictExploreForRoom(overflow, peers.trustedPeers)
129
+
130
+ for (const nodeHash of toDial)
131
+ void registry.ensureLinkToNode(nodeHash).then(link => {
132
+ if (link) {
133
+ syncExploreMark(nodeHash, peers.trustedPeers)
134
+ nodeDebug('p2p:mesh dial ok', { peer: shortHash(nodeHash), provider: link.providerId })
135
+ }
136
+ else
137
+ nodeDebug('p2p:mesh dial miss', { peer: shortHash(nodeHash) })
138
+ }).catch(error => {
139
+ nodeDebug('p2p:mesh dial fail', {
140
+ peer: shortHash(nodeHash),
141
+ err: String(error?.message || error),
142
+ })
143
+ })
144
+
145
+ const connectedAfter = new Set(registry.listLinks().map(entry => entry.nodeHash))
146
+ for (const nodeHash of connectedAfter) {
147
+ if (peers.trustedPeers.includes(nodeHash)) {
148
+ exploreStableSince.delete(nodeHash)
149
+ continue
150
+ }
151
+ if (!exploreLinks.has(nodeHash)) continue
152
+ const since = exploreStableSince.get(nodeHash) ?? now
153
+ if (!exploreStableSince.has(nodeHash)) exploreStableSince.set(nodeHash, since)
154
+ else if (now - since >= promoteMs) {
155
+ promoteExplorePeer(nodeHash)
156
+ exploreLinks.delete(nodeHash)
157
+ exploreStableSince.delete(nodeHash)
158
+ nodeDebug('p2p:mesh promote', { peer: shortHash(nodeHash) })
159
+ }
160
+ }
161
+ for (const hash of [...exploreStableSince.keys()])
162
+ if (!connectedAfter.has(hash)) exploreStableSince.delete(hash)
163
+ }
164
+
165
+ /**
166
+ * @returns {Promise<void>}
167
+ */
168
+ function runTick() {
169
+ if (tickInflight) return tickInflight
170
+ tickInflight = tick().catch(error => {
171
+ nodeDebug('p2p:mesh tick fail', { err: String(error?.message || error) })
172
+ }).finally(() => { tickInflight = null })
173
+ return tickInflight
174
+ }
175
+
176
+ return {
177
+ /** @returns {Set<string>} 探索链路集合(trim 时优先驱逐) */
178
+ exploreLinkHashes: exploreLinks,
179
+ /**
180
+ * 启动 mesh 扫描 / 拨号 / 晋升循环。
181
+ * @returns {void}
182
+ */
183
+ start() {
184
+ if (!enabled || timer) return
185
+ nodeDebug('p2p:mesh start', { self: shortHash(registry.localIdentity?.nodeHash) })
186
+ stopLinkUp = registry.onLinkUp?.(nodeHash => {
187
+ syncExploreMark(nodeHash)
188
+ }) ?? null
189
+ stopLinkDown = registry.onLinkDown((nodeHash, reason) => {
190
+ const hash = normalizeHex64(nodeHash)
191
+ exploreLinks.delete(hash)
192
+ exploreStableSince.delete(hash)
193
+ nodeDebug('p2p:mesh link down', { peer: shortHash(hash), reason })
194
+ // 主动关链不立刻补洞(避免 budget-evict 刚踢又连);意外断链用 tick 按 N/K 重选,而非粘住原对端。
195
+ if (isMeshIntentionalClose(reason)) return
196
+ void runTick()
197
+ })
198
+ void runTick()
199
+ timer = setInterval(() => { void runTick() }, Math.max(15_000, Number(tunables.meshKeepaliveIntervalMs) || 60_000))
200
+ timer.unref?.()
201
+ },
202
+ /**
203
+ * 停止保活并清空探索标记。
204
+ * @returns {Promise<void>}
205
+ */
206
+ async stop() {
207
+ if (timer) { clearInterval(timer); timer = null }
208
+ stopLinkUp?.()
209
+ stopLinkUp = null
210
+ stopLinkDown?.()
211
+ stopLinkDown = null
212
+ exploreLinks.clear()
213
+ exploreStableSince.clear()
214
+ if (tickInflight) await tickInflight.catch(() => { })
215
+ },
216
+ }
217
+ }