@steve02081504/fount-p2p 0.0.14 → 0.0.15

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
@@ -44,6 +44,14 @@ npx @steve02081504/fount-p2p
44
44
 
45
45
  Default `nodeDir`: Windows `%LOCALAPPDATA%/fount-p2p/node`; elsewhere `~/.local/share/fount-p2p/node`.
46
46
 
47
+ Deno (optional; WebRTC needs explicit native scripts — **not** blanket `--allow-scripts`):
48
+
49
+ ```bash
50
+ deno run -A --minimum-dependency-age=0 --node-modules-dir=auto --allow-scripts=npm:node-datachannel npm:@steve02081504/fount-p2p
51
+ ```
52
+
53
+ See [docs/runtime.md](./docs/runtime.md).
54
+
47
55
  ```javascript
48
56
  import { initNode, startNode, startInfra, stopInfra, setInfraPriority } from '@steve02081504/fount-p2p'
49
57
 
package/deno.json CHANGED
@@ -1,4 +1,7 @@
1
1
  {
2
2
  "nodeModulesDir": "none",
3
- "lock": false
3
+ "lock": false,
4
+ "allowScripts": [
5
+ "npm:node-datachannel"
6
+ ]
4
7
  }
@@ -1,8 +1,6 @@
1
1
  import { normalizeHex64 } from '../core/hexIds.mjs'
2
2
  import { buildSignedAdvert, verifySignedAdvert } from '../link/handshake.mjs'
3
3
 
4
- import { listMulticastIpv4Addresses } from './lan_interfaces.mjs'
5
-
6
4
  import {
7
5
  decryptSignalPacket,
8
6
  encryptSignalPacket,
@@ -10,6 +8,7 @@ import {
10
8
  networkRendezvousKey,
11
9
  nodeRendezvousKey,
12
10
  } from './internal/signal_crypto.mjs'
11
+ import { listMulticastIpv4Addresses } from './lan_interfaces.mjs'
13
12
 
14
13
  /** @typedef {'node' | 'network' | { roomSecret: string }} AdvertScope */
15
14
 
@@ -4,6 +4,7 @@ import { isHex64, normalizeHex64 } from '../../core/hexIds.mjs'
4
4
  import { nodeDebug, shortHash } from '../../node/log.mjs'
5
5
  import { noteAdvertPeerHints } from '../advert_peer_hints.mjs'
6
6
  import { ingestNetworkAdvert } from '../adverts.mjs'
7
+ import { noteDiscoveryPeerClue } from '../peer_clue.mjs'
7
8
 
8
9
  import { getBtPeerHint } from './peer_hints.mjs'
9
10
  import { canUseBluetoothRuntime, loadBleno, loadNoble, resolveBtRole, waitPoweredOn } from './runtime.mjs'
@@ -106,11 +107,13 @@ export async function acceptBtScannedPresence(bytes, meta) {
106
107
  const firstSeen = !visibleByHash.has(ingested.verifiedNodeHash)
107
108
  noteBtVisibleNode(ingested.verifiedNodeHash)
108
109
  noteAdvertPeerHints(ingested.verifiedNodeHash, ingested.body, meta)
109
- if (firstSeen)
110
+ if (firstSeen) {
111
+ noteDiscoveryPeerClue(ingested.verifiedNodeHash)
110
112
  nodeDebug('p2p:bt peer visible', {
111
113
  peer: shortHash(ingested.verifiedNodeHash),
112
114
  peripheralId,
113
115
  })
116
+ }
114
117
  return ingested
115
118
  }
116
119
 
package/discovery/lan.mjs CHANGED
@@ -9,6 +9,7 @@ import { noteAdvertPeerHints } from './advert_peer_hints.mjs'
9
9
  import { ingestNetworkAdvert } from './adverts.mjs'
10
10
  import { listMulticastIpv4Addresses } from './lan_interfaces.mjs'
11
11
  import { getLanPeerHint } from './lan_peer_hints.mjs'
12
+ import { noteDiscoveryPeerClue } from './peer_clue.mjs'
12
13
 
13
14
  const DEFAULT_PORT = 53531
14
15
  const DEFAULT_GROUP = '239.255.42.99'
@@ -66,6 +67,7 @@ export async function acceptLanPresenceAdvert(advertBytes, meta = {}) {
66
67
  noteLanVisibleNode(ingested.verifiedNodeHash)
67
68
  noteAdvertPeerHints(ingested.verifiedNodeHash, ingested.body, meta)
68
69
  if (firstSeen) {
70
+ noteDiscoveryPeerClue(ingested.verifiedNodeHash)
69
71
  const host = String(meta.address || '').trim()
70
72
  nodeDebug('p2p:lan peer visible', {
71
73
  peer: shortHash(ingested.verifiedNodeHash),
@@ -78,7 +80,7 @@ export async function acceptLanPresenceAdvert(advertBytes, meta = {}) {
78
80
 
79
81
  /**
80
82
  * LAN UDP presence:段内 beacon,非 topic 订阅模型。
81
- * @param {{ port?: number, group?: string }} [options] 配置
83
+ * @param {{ port?: number, group?: string, localNodeHash?: string }} [options] 配置
82
84
  * @returns {import('./index.mjs').DiscoveryProvider} LAN 发现提供者
83
85
  */
84
86
  export function createLanDiscoveryProvider(options = {}) {
@@ -91,12 +93,14 @@ export function createLanDiscoveryProvider(options = {}) {
91
93
  let refs = 0
92
94
  /** @type {ReturnType<typeof setInterval> | null} */
93
95
  let beaconTimer = null
96
+ const seededSelf = normalizeHex64(options.localNodeHash)
94
97
  /** @type {string | null} */
95
- let selfNodeHash = null
98
+ let selfNodeHash = isHex64(seededSelf) ? seededSelf : null
96
99
  /** @type {Set<string>} */
97
100
  const joinedAddresses = new Set()
98
101
 
99
102
  /**
103
+ * 在每个本机 IPv4 接口上加入组播组;全部失败则回退到默认接口。
100
104
  * @param {import('node:dgram').Socket} sock UDP socket
101
105
  * @returns {void}
102
106
  */
@@ -112,6 +116,11 @@ export function createLanDiscoveryProvider(options = {}) {
112
116
  if (!joinedAddresses.size)
113
117
  try { sock.addMembership(group) } catch { /* ignore */ }
114
118
  }
119
+
120
+ /**
121
+ * 懒创建复用的 udp4 socket(reuseAddr,便于同机多实例绑同一 port)。
122
+ * @returns {import('node:dgram').Socket} 组播 presence socket
123
+ */
115
124
  function getSocket() {
116
125
  return socket ||= dgram.createSocket({ type: 'udp4', reuseAddr: true })
117
126
  }
@@ -190,7 +199,7 @@ export function createLanDiscoveryProvider(options = {}) {
190
199
  let sent = 0
191
200
  /** @type {unknown} */
192
201
  let lastError = null
193
- for (const addr of addrs) {
202
+ for (const addr of addrs)
194
203
  try {
195
204
  await sendOnce(addr)
196
205
  sent++
@@ -198,7 +207,6 @@ export function createLanDiscoveryProvider(options = {}) {
198
207
  catch (error) {
199
208
  lastError = error
200
209
  }
201
- }
202
210
  if (!sent) throw lastError || new Error('p2p: lan multicast send failed')
203
211
  }
204
212
 
@@ -8,14 +8,15 @@ import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
8
8
  import { sha256Hex } from '../crypto/crypto.mjs'
9
9
  import { nodeDebug, shortHash } from '../node/log.mjs'
10
10
 
11
- import { ingestEncryptedAdvert } from './adverts.mjs'
12
11
  import { noteAdvertPeerHints } from './advert_peer_hints.mjs'
12
+ import { ingestEncryptedAdvert } from './adverts.mjs'
13
13
  import {
14
14
  encryptSignalPacket,
15
15
  groupRendezvousKey,
16
16
  networkRendezvousKey,
17
17
  nodeRendezvousKey,
18
18
  } from './internal/signal_crypto.mjs'
19
+ import { noteDiscoveryPeerClue } from './peer_clue.mjs'
19
20
 
20
21
  /** 默认 Nostr 中继 URL 列表。 */
21
22
  export const DEFAULT_RELAY_URLS = [
@@ -31,6 +32,8 @@ export const NOSTR_CLOSE_GRACE_MS = 1_000
31
32
 
32
33
  /** 单 relay 等待 EVENT OK 回执超时。 */
33
34
  export const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
35
+ /** 共享 relay 会话断线后重连间隔。 */
36
+ export const NOSTR_RECONNECT_DELAY_MS = 500
34
37
 
35
38
  /** Nostr network advert 事件 kind(addressable,可存储)。 */
36
39
  export const NOSTR_ADVERT_KIND = 30787
@@ -56,7 +59,6 @@ function listPoolHashes(pool, now, ttlMs) {
56
59
  for (const [hash, seenAt] of pool)
57
60
  if (now - seenAt <= ttlMs) out.push(hash)
58
61
  else pool.delete(hash)
59
-
60
62
  return out
61
63
  }
62
64
 
@@ -119,13 +121,15 @@ export function listNostrGroupVisibleNodeHashes(roomSecret, now = Date.now(), tt
119
121
  * 解密并验签后写入 Nostr 可见池;伪造 body.nodeHash 无效。
120
122
  * @param {string} rendezvousKey rendezvous 键
121
123
  * @param {Uint8Array} bytes 加密 advert
122
- * @param {{ roomSecret?: string }} [options] roomSecret 时写入群池
124
+ * @param {{ roomSecret?: string, skipNodeHash?: string, meta?: object }} [options] 群池 / 本机回环过滤 / meta
123
125
  * @returns {Promise<string | null>} 验签通过的 nodeHash
124
126
  */
125
127
  export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
126
128
  const ingested = await ingestEncryptedAdvert(rendezvousKey, bytes)
127
129
  if (!ingested) return null
128
130
  const hash = ingested.verifiedNodeHash
131
+ const skipHash = options.skipNodeHash ? normalizeHex64(options.skipNodeHash) : null
132
+ if (skipHash && hash === skipHash) return hash
129
133
  const roomSecret = options.roomSecret
130
134
  let firstSeen = true
131
135
  if (roomSecret) {
@@ -137,8 +141,10 @@ export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
137
141
  firstSeen = !visibleByHash.has(hash)
138
142
  noteNostrVisibleNode(hash)
139
143
  }
140
- if (firstSeen)
144
+ if (firstSeen) {
145
+ noteDiscoveryPeerClue(hash)
141
146
  nodeDebug('p2p:nostr peer visible', { peer: shortHash(hash), group: !!roomSecret })
147
+ }
142
148
  noteAdvertPeerHints(hash, ingested.body, options.meta || {})
143
149
  return hash
144
150
  }
@@ -351,69 +357,210 @@ async function publishEvent(relayUrls, event, signal) {
351
357
  }
352
358
 
353
359
  /**
354
- * @param {string[]} relayUrls 中继列表
355
- * @param {(ws: import('ws').WebSocket, relayUrl: string) => void} onOpen 连上回调
356
- * @param {AbortSignal} signal 取消信号
357
- * @param {import('ws').WebSocket[]} sockets 已打开 socket 收集
360
+ * 共享 relay 会话:多 SUB 复用同一 URL 的 WebSocket,避免 signal/presence/advert 各建一池。
361
+ * 仍有活跃 sub 时断线会自动重连并重发 REQ。
362
+ * @typedef {{
363
+ * ws: import('ws').WebSocket | null,
364
+ * connecting: boolean,
365
+ * reconnectTimer: ReturnType<typeof setTimeout> | null,
366
+ * subs: Map<string, { filter: object, onEvent: (event: object, relayUrl: string) => void }>,
367
+ * }} SharedRelaySession
368
+ */
369
+
370
+ /** @type {Map<string, SharedRelaySession>} */
371
+ const sharedRelaySessions = new Map()
372
+
373
+ /**
374
+ * @param {string} relayUrl 中继 URL
375
+ * @param {SharedRelaySession} session 会话
376
+ * @returns {boolean} session 是否仍是该 URL 的活动会话
377
+ */
378
+ function isLiveSharedSession(relayUrl, session) {
379
+ return sharedRelaySessions.get(relayUrl) === session
380
+ }
381
+
382
+ /**
383
+ * @param {SharedRelaySession} session 会话
358
384
  * @returns {void}
359
385
  */
360
- function connectRelaysProgressive(relayUrls, onOpen, signal, sockets) {
361
- for (const relayUrl of relayUrls)
362
- void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal).then(ws => {
363
- if (signal.aborted) {
386
+ function clearSharedRelayReconnect(session) {
387
+ if (!session.reconnectTimer) return
388
+ clearTimeout(session.reconnectTimer)
389
+ session.reconnectTimer = null
390
+ }
391
+
392
+ /**
393
+ * @param {string} relayUrl 中继 URL
394
+ * @param {SharedRelaySession} session 会话
395
+ * @param {import('ws').WebSocket} ws 已打开连接
396
+ * @returns {void}
397
+ */
398
+ function attachSharedRelaySocket(relayUrl, session, ws) {
399
+ session.ws = ws
400
+ nodeDebug('p2p:nostr relay up', { url: relayUrl })
401
+ ws.on('message', data => {
402
+ let parsed
403
+ try { parsed = JSON.parse(String(data)) } catch { return }
404
+ if (parsed?.[0] !== 'EVENT') return
405
+ const subId = String(parsed[1] || '')
406
+ const nostrEvent = parsed[2]
407
+ const sub = session.subs.get(subId)
408
+ if (!sub || !nostrEvent) return
409
+ try { sub.onEvent(nostrEvent, relayUrl) } catch { /* ignore */ }
410
+ })
411
+ ws.once('close', () => {
412
+ if (!isLiveSharedSession(relayUrl, session)) return
413
+ session.ws = null
414
+ if (!session.subs.size) {
415
+ sharedRelaySessions.delete(relayUrl)
416
+ return
417
+ }
418
+ scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
419
+ })
420
+ for (const [subId, sub] of session.subs)
421
+ try { ws.send(JSON.stringify(['REQ', subId, sub.filter])) } catch { /* ignore */ }
422
+ }
423
+
424
+ /**
425
+ * @param {string} relayUrl 中继 URL
426
+ * @param {SharedRelaySession} session 会话
427
+ * @param {number} [delayMs=0] 延迟毫秒(断线重连用)
428
+ * @returns {void}
429
+ */
430
+ function scheduleSharedRelayConnect(relayUrl, session, delayMs = 0) {
431
+ if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
432
+ clearSharedRelayReconnect(session)
433
+ /**
434
+ * @returns {void}
435
+ */
436
+ const start = () => {
437
+ session.reconnectTimer = null
438
+ if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
439
+ if (!session.subs.size) {
440
+ sharedRelaySessions.delete(relayUrl)
441
+ return
442
+ }
443
+ session.connecting = true
444
+ void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS).then(ws => {
445
+ session.connecting = false
446
+ if (!isLiveSharedSession(relayUrl, session) || !session.subs.size) {
447
+ if (!session.subs.size && isLiveSharedSession(relayUrl, session))
448
+ sharedRelaySessions.delete(relayUrl)
364
449
  dropWebSocket(ws)
365
450
  return
366
451
  }
367
- sockets.push(ws)
368
- nodeDebug('p2p:nostr relay up', { url: relayUrl })
369
- onOpen(ws, relayUrl)
452
+ attachSharedRelaySocket(relayUrl, session, ws)
370
453
  }).catch(error => {
454
+ session.connecting = false
455
+ if (!isLiveSharedSession(relayUrl, session)) return
371
456
  nodeDebug('p2p:nostr relay fail', {
372
457
  url: relayUrl,
373
458
  err: String(error?.message || error),
374
459
  })
460
+ if (!session.subs.size) {
461
+ sharedRelaySessions.delete(relayUrl)
462
+ return
463
+ }
464
+ scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
375
465
  })
466
+ }
467
+ if (delayMs <= 0) {
468
+ // 让同一 tick 内的 registerSharedRelaySub 先写入 subs,再决定是否连接。
469
+ queueMicrotask(start)
470
+ return
471
+ }
472
+ nodeDebug('p2p:nostr relay reconnect', { url: relayUrl, delayMs })
473
+ session.reconnectTimer = setTimeout(start, delayMs)
474
+ session.reconnectTimer.unref?.()
475
+ }
476
+
477
+ /**
478
+ * @param {string} relayUrl 中继 URL
479
+ * @returns {SharedRelaySession} 共享会话
480
+ */
481
+ function acquireSharedRelay(relayUrl) {
482
+ const existing = sharedRelaySessions.get(relayUrl)
483
+ if (existing) return existing
484
+ /** @type {SharedRelaySession} */
485
+ const session = {
486
+ ws: null,
487
+ connecting: false,
488
+ reconnectTimer: null,
489
+ subs: new Map(),
490
+ }
491
+ sharedRelaySessions.set(relayUrl, session)
492
+ scheduleSharedRelayConnect(relayUrl, session)
493
+ return session
494
+ }
495
+
496
+ /**
497
+ * @param {string} relayUrl 中继 URL
498
+ * @param {string} subscriptionId 订阅 id
499
+ * @returns {void}
500
+ */
501
+ function releaseSharedRelaySub(relayUrl, subscriptionId) {
502
+ const session = sharedRelaySessions.get(relayUrl)
503
+ if (!session) return
504
+ session.subs.delete(subscriptionId)
505
+ const ws = session.ws
506
+ if (ws?.readyState === WebSocket.OPEN)
507
+ try { ws.send(JSON.stringify(['CLOSE', subscriptionId])) } catch { /* ignore */ }
508
+ if (session.subs.size) return
509
+ sharedRelaySessions.delete(relayUrl)
510
+ clearSharedRelayReconnect(session)
511
+ if (ws) dropWebSocket(ws)
512
+ }
513
+
514
+ /**
515
+ * 在已打开的共享连接上登记 REQ;连接中则等 attach 时统一重放。
516
+ * @param {SharedRelaySession} session 会话
517
+ * @param {string} subscriptionId 订阅 id
518
+ * @param {object} filter Nostr filter
519
+ * @param {(event: object, relayUrl: string) => void} onEvent 事件回调
520
+ * @returns {void}
521
+ */
522
+ function registerSharedRelaySub(session, subscriptionId, filter, onEvent) {
523
+ session.subs.set(subscriptionId, { filter, onEvent })
524
+ const ws = session.ws
525
+ if (ws?.readyState !== WebSocket.OPEN) return
526
+ try { ws.send(JSON.stringify(['REQ', subscriptionId, filter])) } catch { /* ignore */ }
376
527
  }
377
528
 
378
529
  /**
379
- * 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。
530
+ * 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。多订阅共享每 URL 一条连接。
380
531
  * @param {string[]} relayUrls 中继 URL 列表
381
- * @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void> }} options 订阅选项
532
+ * @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void>, addressable?: boolean }} options 订阅选项
382
533
  * @returns {() => void} 取消订阅
383
534
  */
384
535
  function subscribeNostrKind(relayUrls, options) {
385
536
  const { kind, rendezvousKey, tagX, onPayload, addressable = false } = options
386
- const abortController = new AbortController()
387
- /** @type {import('ws').WebSocket[]} */
388
- const sockets = []
389
537
  const subscriptionId = randomBytes(8).toString('hex')
390
- connectRelaysProgressive(relayUrls, (ws, relayUrl) => {
391
- ws.on('message', data => {
392
- if (abortController.signal.aborted) return
393
- let parsed
394
- try { parsed = JSON.parse(String(data)) } catch { return }
395
- if (parsed?.[0] !== 'EVENT') return
396
- const nostrEvent = parsed[2]
397
- if (nostrEvent?.kind !== kind) return
398
- try {
399
- const result = onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent })
400
- if (result?.then) void result.catch(() => { })
401
- }
402
- catch { /* ignore */ }
403
- })
404
- const filter = { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }
405
- if (addressable) filter['#d'] = [rendezvousKey]
406
- ws.send(JSON.stringify(['REQ', subscriptionId, filter]))
407
- }, abortController.signal, sockets)
538
+ const filter = { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }
539
+ if (addressable) filter['#d'] = [rendezvousKey]
540
+ /**
541
+ * @param {object} nostrEvent Nostr EVENT
542
+ * @param {string} relayUrl 来源中继
543
+ * @returns {void}
544
+ */
545
+ const onEvent = (nostrEvent, relayUrl) => {
546
+ if (nostrEvent?.kind !== kind) return
547
+ try {
548
+ const result = onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent })
549
+ if (result?.then) void result.catch(() => { })
550
+ }
551
+ catch { /* ignore */ }
552
+ }
553
+ const urls = dedupeRelayUrls(relayUrls)
554
+ for (const relayUrl of urls)
555
+ registerSharedRelaySub(acquireSharedRelay(relayUrl), subscriptionId, filter, onEvent)
408
556
  return () => {
409
- abortController.abort()
410
- for (const ws of sockets) dropWebSocket(ws)
557
+ for (const relayUrl of urls) releaseSharedRelaySub(relayUrl, subscriptionId)
411
558
  }
412
559
  }
413
560
 
414
561
  /**
415
562
  * 创建 Nostr discovery provider(list+connect;topic 仅内部)。
416
- * @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined }} [options] 中继配置
563
+ * @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined, localNodeHash?: string }} [options] 中继配置与本机 hash
417
564
  * @returns {import('./index.mjs').DiscoveryProvider} Nostr discovery provider
418
565
  */
419
566
  export function createNostrDiscoveryProvider(options = {}) {
@@ -430,57 +577,124 @@ export function createNostrDiscoveryProvider(options = {}) {
430
577
  return dedupeRelayUrls(options.relayUrls)
431
578
  }
432
579
  const secretKey = randomBytes(32)
433
- /** @type {(() => void) | null} */
434
- let stopNetworkAdvertSub = null
435
- /** @type {Map<string, () => void>} */
436
- const groupSubs = new Map()
437
- /** @type {Map<string, () => void>} */
438
- const nodeAdvertSubs = new Map()
580
+ const seededSelf = normalizeHex64(options.localNodeHash)
581
+ /** @type {string | null} */
582
+ let selfNodeHash = isHex64(seededSelf) ? seededSelf : null
583
+ const NETWORK_SUB_KEY = 'network'
584
+ /**
585
+ * @typedef {{ stop: () => void, held: boolean, listeners: Set<(bytes: Uint8Array, meta: object) => void> }} AdvertSubEntry
586
+ */
587
+ /** @type {Map<string, AdvertSubEntry>} */
588
+ const advertSubs = new Map()
439
589
  /** @type {Map<string, () => void>} */
440
590
  const nodeSignalSubs = new Map()
441
591
 
442
592
  /**
593
+ * @param {string | undefined | null} nodeHash 本机 hash
443
594
  * @returns {void}
444
595
  */
596
+ function noteSelfNodeHash(nodeHash) {
597
+ const hash = normalizeHex64(nodeHash)
598
+ if (isHex64(hash)) selfNodeHash = hash
599
+ }
600
+
601
+ /**
602
+ * @param {string} key 订阅键
603
+ * @param {AdvertSubEntry} entry 条目
604
+ * @returns {void}
605
+ */
606
+ function releaseAdvertEntryIfIdle(key, entry) {
607
+ if (entry.held || entry.listeners.size) return
608
+ try { entry.stop() } catch { /* ignore */ }
609
+ advertSubs.delete(key)
610
+ }
611
+
612
+ /**
613
+ * pool/connect 永久 hold;watch listener 归零且无 hold 时拆 REQ。
614
+ * @param {string} key 订阅键
615
+ * @param {{ rendezvousKey: string, roomSecret?: string }} bind advert 绑定
616
+ * @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外监听
617
+ * @returns {() => void} 取消 listener;无 listener 时 no-op(hold 至 dispose)
618
+ */
619
+ function ensureAdvertSubscription(key, bind, listener) {
620
+ if (!key) return () => { }
621
+ let entry = advertSubs.get(key)
622
+ if (!entry) {
623
+ /** @type {AdvertSubEntry} */
624
+ const created = {
625
+ /**
626
+ * @returns {void}
627
+ */
628
+ stop: () => { }, held: false, listeners: new Set()
629
+ }
630
+ created.stop = subscribeNostrKind(resolveRelayUrls(), {
631
+ kind: NOSTR_ADVERT_KIND,
632
+ rendezvousKey: bind.rendezvousKey,
633
+ tagX: 'advert',
634
+ addressable: true,
635
+ /**
636
+ * @param {Uint8Array} bytes 加密 advert 载荷
637
+ * @param {object} meta relay 元数据
638
+ * @returns {Promise<void>}
639
+ */
640
+ async onPayload(bytes, meta) {
641
+ await acceptNostrAdvert(bind.rendezvousKey, bytes, {
642
+ roomSecret: bind.roomSecret,
643
+ skipNodeHash: selfNodeHash || undefined,
644
+ meta,
645
+ })
646
+ for (const fn of created.listeners)
647
+ try { fn(bytes, meta) } catch { /* ignore */ }
648
+ },
649
+ })
650
+ entry = created
651
+ advertSubs.set(key, entry)
652
+ }
653
+ if (!listener) {
654
+ entry.held = true
655
+ return () => { }
656
+ }
657
+ entry.listeners.add(listener)
658
+ return () => {
659
+ entry.listeners.delete(listener)
660
+ releaseAdvertEntryIfIdle(key, entry)
661
+ }
662
+ }
663
+
664
+ /**
665
+ * @returns {() => void} no-op(network hold 至 dispose)
666
+ */
445
667
  function ensureNetworkAdvertSubscription() {
446
- if (stopNetworkAdvertSub) return
447
- const rendezvousKey = networkRendezvousKey()
448
- stopNetworkAdvertSub = subscribeNostrKind(resolveRelayUrls(), {
449
- kind: NOSTR_ADVERT_KIND,
450
- rendezvousKey,
451
- tagX: 'advert',
452
- addressable: true,
453
- /**
454
- * @param {Uint8Array} bytes 加密 advert 载荷
455
- * @returns {Promise<void>}
456
- */
457
- async onPayload(bytes) {
458
- await acceptNostrAdvert(rendezvousKey, bytes)
459
- },
668
+ return ensureAdvertSubscription(NETWORK_SUB_KEY, {
669
+ rendezvousKey: networkRendezvousKey(),
460
670
  })
461
671
  }
462
672
 
463
673
  /**
464
674
  * @param {string} roomSecret 房间密钥
465
- * @returns {void}
675
+ * @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
676
+ * @returns {() => void} 取消 listener
466
677
  */
467
- function ensureGroupSubscription(roomSecret) {
678
+ function ensureGroupSubscription(roomSecret, listener) {
468
679
  const key = String(roomSecret || '')
469
- if (!key || groupSubs.has(key)) return
470
- const rendezvousKey = groupRendezvousKey(key)
471
- groupSubs.set(key, subscribeNostrKind(resolveRelayUrls(), {
472
- kind: NOSTR_ADVERT_KIND,
473
- rendezvousKey,
474
- tagX: 'advert',
475
- addressable: true,
476
- /**
477
- * @param {Uint8Array} bytes 加密 advert 载荷
478
- * @returns {Promise<void>}
479
- */
480
- async onPayload(bytes) {
481
- await acceptNostrAdvert(rendezvousKey, bytes, { roomSecret: key })
482
- },
483
- }))
680
+ if (!key) return () => { }
681
+ return ensureAdvertSubscription('group:' + key, {
682
+ rendezvousKey: groupRendezvousKey(key),
683
+ roomSecret: key,
684
+ }, listener)
685
+ }
686
+
687
+ /**
688
+ * @param {string} nodeHash 目标
689
+ * @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
690
+ * @returns {() => void} 取消 listener
691
+ */
692
+ function ensureNodeAdvertSubscription(nodeHash, listener) {
693
+ const hash = normalizeHex64(nodeHash)
694
+ if (!isHex64(hash)) return () => { }
695
+ return ensureAdvertSubscription('node:' + hash, {
696
+ rendezvousKey: nodeRendezvousKey(hash),
697
+ }, listener)
484
698
  }
485
699
 
486
700
  return {
@@ -495,10 +709,14 @@ export function createNostrDiscoveryProvider(options = {}) {
495
709
  const limit = Math.max(1, Number(options.limit) || 64)
496
710
  if (options.roomSecret) {
497
711
  ensureGroupSubscription(options.roomSecret)
498
- return listNostrGroupVisibleNodeHashes(options.roomSecret).slice(0, limit)
712
+ return listNostrGroupVisibleNodeHashes(options.roomSecret)
713
+ .filter(hash => hash !== selfNodeHash)
714
+ .slice(0, limit)
499
715
  }
500
716
  ensureNetworkAdvertSubscription()
501
- return listNostrVisibleNodeHashes().slice(0, limit)
717
+ return listNostrVisibleNodeHashes()
718
+ .filter(hash => hash !== selfNodeHash)
719
+ .slice(0, limit)
502
720
  },
503
721
  /**
504
722
  * 挂上对该节点的内部 advert 订阅(建链由 registry dialer / ensureLinkToNode 完成)。
@@ -508,22 +726,7 @@ export function createNostrDiscoveryProvider(options = {}) {
508
726
  async connectToNode(nodeHash) {
509
727
  const hash = normalizeHex64(nodeHash)
510
728
  if (!isHex64(hash)) return false
511
- if (!nodeAdvertSubs.has(hash)) {
512
- const rendezvousKey = nodeRendezvousKey(hash)
513
- nodeAdvertSubs.set(hash, subscribeNostrKind(resolveRelayUrls(), {
514
- kind: NOSTR_ADVERT_KIND,
515
- rendezvousKey,
516
- tagX: 'advert',
517
- addressable: true,
518
- /**
519
- * @param {Uint8Array} bytes 加密 advert 载荷
520
- * @returns {Promise<void>}
521
- */
522
- async onPayload(bytes) {
523
- await acceptNostrAdvert(rendezvousKey, bytes)
524
- },
525
- }))
526
- }
729
+ ensureNodeAdvertSubscription(hash)
527
730
  return true
528
731
  },
529
732
  /**
@@ -541,6 +744,7 @@ export function createNostrDiscoveryProvider(options = {}) {
541
744
  if (abortController.signal.aborted) return
542
745
  const beacon = await getBeacon?.()
543
746
  if (!beacon?.nodeHash) return
747
+ noteSelfNodeHash(beacon.nodeHash)
544
748
  const advertBody = beacon.advertBody || beacon.body || beacon
545
749
  const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
546
750
  const event = await signNostrEvent(
@@ -591,6 +795,7 @@ export function createNostrDiscoveryProvider(options = {}) {
591
795
  async listenNodeSignals(localNodeHash, onSignal) {
592
796
  const hash = normalizeHex64(localNodeHash)
593
797
  if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
798
+ noteSelfNodeHash(hash)
594
799
  const rendezvousKey = nodeRendezvousKey(hash)
595
800
  const existing = nodeSignalSubs.get(hash)
596
801
  if (existing) existing()
@@ -615,20 +820,7 @@ export function createNostrDiscoveryProvider(options = {}) {
615
820
  async watchNodeAdvert(nodeHash, onAdvert) {
616
821
  const hash = normalizeHex64(nodeHash)
617
822
  if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
618
- const rendezvousKey = nodeRendezvousKey(hash)
619
- const stop = subscribeNostrKind(resolveRelayUrls(), {
620
- kind: NOSTR_ADVERT_KIND,
621
- rendezvousKey,
622
- tagX: 'advert',
623
- addressable: true,
624
- /**
625
- * @param {Uint8Array} bytes 加密 advert 载荷
626
- * @param {object} meta relay 元数据
627
- * @returns {void}
628
- */
629
- onPayload: (bytes, meta) => onAdvert(bytes, meta),
630
- })
631
- return stop
823
+ return ensureNodeAdvertSubscription(hash, onAdvert)
632
824
  },
633
825
  /**
634
826
  * @param {string} roomSecret 房间密钥
@@ -647,6 +839,7 @@ export function createNostrDiscoveryProvider(options = {}) {
647
839
  if (abortController.signal.aborted) return
648
840
  const beacon = await getBeacon?.()
649
841
  if (!beacon?.nodeHash) return
842
+ noteSelfNodeHash(beacon.nodeHash)
650
843
  const advertBody = beacon.advertBody || beacon.body || beacon
651
844
  const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
652
845
  const event = await signNostrEvent(
@@ -671,21 +864,7 @@ export function createNostrDiscoveryProvider(options = {}) {
671
864
  * @returns {Promise<() => void>} 取消群 advert 监听
672
865
  */
673
866
  async watchGroupAdverts(roomSecret, onAdvert) {
674
- const key = String(roomSecret || '')
675
- const rendezvousKey = groupRendezvousKey(key)
676
- ensureGroupSubscription(key)
677
- return subscribeNostrKind(resolveRelayUrls(), {
678
- kind: NOSTR_ADVERT_KIND,
679
- rendezvousKey,
680
- tagX: 'advert',
681
- addressable: true,
682
- /**
683
- * @param {Uint8Array} bytes 加密 advert 载荷
684
- * @param {object} meta relay 元数据
685
- * @returns {void}
686
- */
687
- onPayload: (bytes, meta) => onAdvert(bytes, meta),
688
- })
867
+ return ensureGroupSubscription(roomSecret, onAdvert)
689
868
  },
690
869
  /**
691
870
  * 供 advert 解析路径写入可见 hash。
@@ -702,14 +881,9 @@ export function createNostrDiscoveryProvider(options = {}) {
702
881
  * @returns {void}
703
882
  */
704
883
  dispose() {
705
- stopNetworkAdvertSub?.()
706
- stopNetworkAdvertSub = null
707
- for (const stop of groupSubs.values())
708
- try { stop() } catch { /* ignore */ }
709
- groupSubs.clear()
710
- for (const stop of nodeAdvertSubs.values())
711
- try { stop() } catch { /* ignore */ }
712
- nodeAdvertSubs.clear()
884
+ for (const entry of advertSubs.values())
885
+ try { entry.stop() } catch { /* ignore */ }
886
+ advertSubs.clear()
713
887
  for (const stop of nodeSignalSubs.values())
714
888
  try { stop() } catch { /* ignore */ }
715
889
  nodeSignalSubs.clear()
@@ -0,0 +1,23 @@
1
+ import { normalizeHex64 } from '../core/hexIds.mjs'
2
+
3
+ /** @type {((nodeHash: string) => void) | null} */
4
+ let peerClueListener = null
5
+
6
+ /**
7
+ * 由 link registry 注入:peer 首次可见时清 dial 冷却。
8
+ * @param {((nodeHash: string) => void) | null} listener 回调
9
+ * @returns {void}
10
+ */
11
+ export function setDiscoveryPeerClueListener(listener) {
12
+ peerClueListener = listener
13
+ }
14
+
15
+ /**
16
+ * discovery accept 路径:peer 首次进入可见池时通知 registry。
17
+ * @param {string} nodeHash 对端
18
+ * @returns {void}
19
+ */
20
+ export function noteDiscoveryPeerClue(nodeHash) {
21
+ const hash = normalizeHex64(nodeHash)
22
+ if (hash) peerClueListener?.(hash)
23
+ }
@@ -2,9 +2,9 @@ import { Buffer } from 'node:buffer'
2
2
  import { randomBytes } from 'node:crypto'
3
3
 
4
4
  import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
5
- import { normalizeLanHosts } from '../discovery/lan_interfaces.mjs'
6
5
  import { normalizeTcpPort } from '../core/tcp_port.mjs'
7
6
  import { keyPairFromSeed, pubKeyHash, sign, verify } from '../crypto/crypto.mjs'
7
+ import { normalizeLanHosts } from '../discovery/lan_interfaces.mjs'
8
8
  import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
9
9
 
10
10
  import { normalizeDtlsFingerprint } from './sdp_fingerprint.mjs'
@@ -153,10 +153,10 @@ export function buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort = null,
153
153
  }
154
154
 
155
155
  /**
156
- * 构造带签名的 discovery advert
156
+ * 构造带签名的 discovery advert(tcpPort / lanHosts 一并签入消息)。
157
157
  * @param {string} rendezvousKey discovery 内部汇合键
158
158
  * @param {number} [ts=Date.now()] 时间戳(毫秒)
159
- * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number, lanHosts?: unknown }} | null} [options] 签名身份与可选 tcpPort / lanHosts
159
+ * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number, lanHosts?: unknown } | null} [options] 签名身份;可选 LAN 监听端口与本机 IPv4 列表
160
160
  * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number, lanHosts?: string[] }>} 签名 advert
161
161
  */
162
162
  export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options = null) {
@@ -34,6 +34,13 @@ function formatErrorReason(error) {
34
34
 
35
35
  let cachedAvailable = null
36
36
 
37
+ /**
38
+ * @returns {boolean} 是否跑在 Deno
39
+ */
40
+ function isDenoRuntime() {
41
+ return typeof globalThis.Deno !== 'undefined'
42
+ }
43
+
37
44
  /**
38
45
  * 探测 WebRTC(node-datachannel)是否可用。
39
46
  * @returns {Promise<boolean>} 可用为 true
@@ -46,7 +53,12 @@ export async function canUseWebRtcLink() {
46
53
  }
47
54
  catch (error) {
48
55
  cachedAvailable = false
49
- nodeDebug('p2p:webrtc unavailable', { err: formatErrorReason(error) })
56
+ const reason = formatErrorReason(error)
57
+ nodeDebug('p2p:webrtc unavailable', {
58
+ err: isDenoRuntime()
59
+ ? `${reason} | deno: see docs/runtime.md (node-datachannel scripts only)`
60
+ : reason,
61
+ })
50
62
  }
51
63
  return cachedAvailable
52
64
  }
package/link/rtc.mjs CHANGED
@@ -4,15 +4,23 @@ import { toBytes } from '../core/bytes_codec.mjs'
4
4
  import { getSignalingRuntimeConfig } from '../node/instance.mjs'
5
5
  import { wrapRtcPeerConnectionForIceLocalHostname } from '../transport/rtc_ice_local_hostname.mjs'
6
6
 
7
+ /** @type {boolean} */
8
+ let exitCleanupHooked = false
9
+
7
10
  /**
8
- * 注册进程退出时销毁 libdatachannel 全部原生资源(仅一次)。
11
+ * 注册进程退出时销毁 libdatachannel 原生资源(首次成功加载后挂一次)。
9
12
  * libdatachannel 的原生线程在 pc.close() 后仍需时间回收;进程退出时若原生资源未同步销毁,
10
13
  * Windows 上会触发堆损坏(退出码 0xC0000374)。
14
+ * @returns {Promise<void>}
11
15
  */
12
- const { cleanup = undefined } = await import('node-datachannel').catch(() => ({}))
13
- process.on('exit', () => {
14
- try { cleanup?.() } catch { /* already torn down */ }
15
- })
16
+ async function ensureNodeDatachannelExitCleanup() {
17
+ if (exitCleanupHooked) return
18
+ exitCleanupHooked = true
19
+ const { cleanup = undefined } = await import('node-datachannel').catch(() => ({}))
20
+ process.on('exit', () => {
21
+ try { cleanup?.() } catch { /* already torn down */ }
22
+ })
23
+ }
16
24
 
17
25
  /**
18
26
  * 加载 node-datachannel polyfill,并按配置包装 RTCPeerConnection。
@@ -20,6 +28,7 @@ process.on('exit', () => {
20
28
  */
21
29
  export async function loadNodeRtcPolyfill() {
22
30
  const mod = await import('node-datachannel/polyfill')
31
+ await ensureNodeDatachannelExitCleanup()
23
32
  const { iceLocalHostnamePolicy } = getSignalingRuntimeConfig()
24
33
  return {
25
34
  RTCPeerConnection: wrapRtcPeerConnectionForIceLocalHostname(mod.RTCPeerConnection, mod.RTCIceCandidate, iceLocalHostnamePolicy),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -3,11 +3,13 @@ import { Buffer } from 'node:buffer'
3
3
  import { compareHex64Asc, normalizeHex64 } from '../core/hexIds.mjs'
4
4
  import { keyPairFromSeed } from '../crypto/crypto.mjs'
5
5
  import { watchVerifiedNodeAdvert, setDiscoveryLinkDialer, prepareConnectToNode } from '../discovery/index.mjs'
6
+ import { setDiscoveryPeerClueListener } from '../discovery/peer_clue.mjs'
6
7
  import { listLinkProviders } from '../link/providers/index.mjs'
7
8
  import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
8
9
  import { nodeDebug, shortHash } from '../node/log.mjs'
9
10
  import { loadPeerPoolView } from '../node/network.mjs'
10
11
  import { createOverlayRouter } from '../overlay/index.mjs'
12
+ import { ms } from '../utils/duration.mjs'
11
13
  import { emitSafe } from '../utils/emit_safe.mjs'
12
14
  import { createLruMap } from '../utils/lru.mjs'
13
15
 
@@ -18,6 +20,11 @@ import { createOfferAnswerDial } from './offer_answer.mjs'
18
20
  import { pickMeshEvictionVictim } from './peer_pool.mjs'
19
21
  import { createRuntimeBootstrap } from './runtime_bootstrap.mjs'
20
22
 
23
+ /** dial exhausted 后的初始冷却。 */
24
+ const DIAL_COOLDOWN_BASE_MS = ms('30s')
25
+ /** dial 冷却上限。 */
26
+ const DIAL_COOLDOWN_MAX_MS = ms('10m')
27
+
21
28
  /**
22
29
  * 解析或从节点种子推导本地身份。
23
30
  * @param {{ nodeHash?: string, nodePubKey?: string, secretKey?: Uint8Array } | undefined} localIdentity 可选的预置身份
@@ -84,6 +91,8 @@ export function createLinkRegistry(options = {}) {
84
91
  const links = new Map()
85
92
  /** @type {Map<string, Promise<object | null>>} */
86
93
  const inflights = new Map()
94
+ /** @type {Map<string, { until: number, failures: number }> & { touch: (key: string, value: { until: number, failures: number }) => void }} */
95
+ const dialCooldown = createLruMap(1024)
87
96
  /** @type {Map<string, ReturnType<typeof import('./offer_answer.mjs').createBufferedSignalSession>>} */
88
97
  const signalSessions = new Map()
89
98
  /** @type {Map<string, Set<string>>} */
@@ -265,6 +274,19 @@ export function createLinkRegistry(options = {}) {
265
274
  if (!normalized || normalized === localIdentity.nodeHash) return null
266
275
  if (links.has(normalized)) return links.get(normalized)
267
276
  if (inflights.has(normalized)) return await inflights.get(normalized)
277
+ const cool = dialCooldown.get(normalized)
278
+ const now = Date.now()
279
+ if (cool) {
280
+ if (now < cool.until) {
281
+ nodeDebug('p2p:dial cooldown', {
282
+ peer: shortHash(normalized),
283
+ failures: cool.failures,
284
+ retryInMs: cool.until - now,
285
+ })
286
+ return null
287
+ }
288
+ dialCooldown.delete(normalized)
289
+ }
268
290
  const task = (async () => {
269
291
  nodeDebug('p2p:dial start', { peer: shortHash(normalized) })
270
292
  await prepareConnectToNode(normalized)
@@ -289,6 +311,7 @@ export function createLinkRegistry(options = {}) {
289
311
  if (provider.caps?.needsOfferAnswer) {
290
312
  const link = await dialOfferAnswer(provider, normalized)
291
313
  if (link) {
314
+ dialCooldown.delete(normalized)
292
315
  nodeDebug('p2p:dial ok', { peer: shortHash(normalized), provider: provider.id })
293
316
  return link
294
317
  }
@@ -297,6 +320,7 @@ export function createLinkRegistry(options = {}) {
297
320
  }
298
321
  const link = await dialProvider(provider, normalized)
299
322
  if (link) {
323
+ dialCooldown.delete(normalized)
300
324
  nodeDebug('p2p:dial ok', { peer: shortHash(normalized), provider: provider.id })
301
325
  return link
302
326
  }
@@ -310,7 +334,10 @@ export function createLinkRegistry(options = {}) {
310
334
  })
311
335
  }
312
336
 
313
- nodeDebug('p2p:dial exhausted', { peer: shortHash(normalized) })
337
+ const failures = (cool?.failures || 0) + 1
338
+ const delay = Math.min(DIAL_COOLDOWN_MAX_MS, DIAL_COOLDOWN_BASE_MS * (2 ** Math.min(failures - 1, 5)))
339
+ dialCooldown.touch(normalized, { until: Date.now() + delay, failures })
340
+ nodeDebug('p2p:dial exhausted', { peer: shortHash(normalized), cooldownMs: delay, failures })
314
341
  return null
315
342
  })().finally(() => inflights.delete(normalized))
316
343
  inflights.set(normalized, task)
@@ -351,6 +378,12 @@ export function createLinkRegistry(options = {}) {
351
378
  })
352
379
  exploreLinkHashes = meshKeepalive.exploreLinkHashes
353
380
  setDiscoveryLinkDialer(ensureDirectLinkToNode)
381
+ setDiscoveryPeerClueListener(nodeHash => {
382
+ const hash = normalizeHex64(nodeHash)
383
+ if (!hash) return
384
+ dialCooldown.delete(hash)
385
+ recentAdverts.touch(hash, Date.now())
386
+ })
354
387
 
355
388
  /**
356
389
  *
@@ -619,6 +652,7 @@ export function createLinkRegistry(options = {}) {
619
652
  return await watchVerifiedNodeAdvert(hash, async (verifiedNodeHash, body, meta) => {
620
653
  applyAdvertPeerHints(verifiedNodeHash, body, meta)
621
654
  recentAdverts.touch(verifiedNodeHash, Date.now())
655
+ dialCooldown.delete(verifiedNodeHash)
622
656
  await Promise.resolve(onAdvert(verifiedNodeHash, body))
623
657
  })
624
658
  },
@@ -631,6 +665,7 @@ export function createLinkRegistry(options = {}) {
631
665
  async shutdown() {
632
666
  await meshKeepalive?.stop()
633
667
  setDiscoveryLinkDialer(null)
668
+ setDiscoveryPeerClueListener(null)
634
669
  await bootstrap.shutdown()
635
670
  overlayRouter?.close()
636
671
  overlayRouter = null
@@ -638,6 +673,7 @@ export function createLinkRegistry(options = {}) {
638
673
  await link.close('registry-shutdown')
639
674
  links.clear()
640
675
  inflights.clear()
676
+ dialCooldown.clear()
641
677
  for (const session of signalSessions.values()) session.clear()
642
678
  signalSessions.clear()
643
679
  },
@@ -116,7 +116,9 @@ export function createRuntimeBootstrap(deps) {
116
116
  function registerDiscoveryDefaults() {
117
117
  const providerIds = new Set(listDiscoveryProviders().map(provider => provider.id))
118
118
  if (!providerIds.has('lan'))
119
- registerDiscoveryProvider(createLanDiscoveryProvider())
119
+ registerDiscoveryProvider(createLanDiscoveryProvider({
120
+ localNodeHash: localIdentity.nodeHash,
121
+ }))
120
122
  if (!providerIds.has('nostr'))
121
123
  registerNostrProvider()
122
124
  }
@@ -128,6 +130,7 @@ export function createRuntimeBootstrap(deps) {
128
130
  unregisterDiscoveryProvider('nostr')
129
131
  registerDiscoveryProvider(createNostrDiscoveryProvider({
130
132
  getRelayUrls: resolveNostrRelayUrls,
133
+ localNodeHash: localIdentity.nodeHash,
131
134
  }))
132
135
  }
133
136