@steve02081504/fount-p2p 0.0.13 → 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.
@@ -8,6 +8,7 @@ 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 { noteAdvertPeerHints } from './advert_peer_hints.mjs'
11
12
  import { ingestEncryptedAdvert } from './adverts.mjs'
12
13
  import {
13
14
  encryptSignalPacket,
@@ -15,6 +16,7 @@ import {
15
16
  networkRendezvousKey,
16
17
  nodeRendezvousKey,
17
18
  } from './internal/signal_crypto.mjs'
19
+ import { noteDiscoveryPeerClue } from './peer_clue.mjs'
18
20
 
19
21
  /** 默认 Nostr 中继 URL 列表。 */
20
22
  export const DEFAULT_RELAY_URLS = [
@@ -28,10 +30,15 @@ export const NOSTR_CONNECT_TIMEOUT_MS = 2_000
28
30
  /** 先 close,超时未 CLOSED 再 terminate(给对端优雅关闭的窗口)。 */
29
31
  export const NOSTR_CLOSE_GRACE_MS = 1_000
30
32
 
31
- /** Nostr advert 事件 kind。 */
32
- export const NOSTR_ADVERT_KIND = 27235
33
- /** Nostr signal 事件 kind。 */
34
- export const NOSTR_SIGNAL_KIND = 27236
33
+ /** relay 等待 EVENT OK 回执超时。 */
34
+ export const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
35
+ /** 共享 relay 会话断线后重连间隔。 */
36
+ export const NOSTR_RECONNECT_DELAY_MS = 500
37
+
38
+ /** Nostr network advert 事件 kind(addressable,可存储)。 */
39
+ export const NOSTR_ADVERT_KIND = 30787
40
+ /** Nostr signal 事件 kind(ephemeral,实时转发)。 */
41
+ export const NOSTR_SIGNAL_KIND = 20787
35
42
 
36
43
  const ADVERT_TTL_MS = 10 * 60_000
37
44
 
@@ -52,7 +59,6 @@ function listPoolHashes(pool, now, ttlMs) {
52
59
  for (const [hash, seenAt] of pool)
53
60
  if (now - seenAt <= ttlMs) out.push(hash)
54
61
  else pool.delete(hash)
55
-
56
62
  return out
57
63
  }
58
64
 
@@ -115,13 +121,15 @@ export function listNostrGroupVisibleNodeHashes(roomSecret, now = Date.now(), tt
115
121
  * 解密并验签后写入 Nostr 可见池;伪造 body.nodeHash 无效。
116
122
  * @param {string} rendezvousKey rendezvous 键
117
123
  * @param {Uint8Array} bytes 加密 advert
118
- * @param {{ roomSecret?: string }} [options] roomSecret 时写入群池
124
+ * @param {{ roomSecret?: string, skipNodeHash?: string, meta?: object }} [options] 群池 / 本机回环过滤 / meta
119
125
  * @returns {Promise<string | null>} 验签通过的 nodeHash
120
126
  */
121
127
  export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
122
128
  const ingested = await ingestEncryptedAdvert(rendezvousKey, bytes)
123
129
  if (!ingested) return null
124
130
  const hash = ingested.verifiedNodeHash
131
+ const skipHash = options.skipNodeHash ? normalizeHex64(options.skipNodeHash) : null
132
+ if (skipHash && hash === skipHash) return hash
125
133
  const roomSecret = options.roomSecret
126
134
  let firstSeen = true
127
135
  if (roomSecret) {
@@ -133,8 +141,11 @@ export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
133
141
  firstSeen = !visibleByHash.has(hash)
134
142
  noteNostrVisibleNode(hash)
135
143
  }
136
- if (firstSeen)
144
+ if (firstSeen) {
145
+ noteDiscoveryPeerClue(hash)
137
146
  nodeDebug('p2p:nostr peer visible', { peer: shortHash(hash), group: !!roomSecret })
147
+ }
148
+ noteAdvertPeerHints(hash, ingested.body, options.meta || {})
138
149
  return hash
139
150
  }
140
151
 
@@ -252,6 +263,70 @@ function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
252
263
  })
253
264
  }
254
265
 
266
+ /**
267
+ * @param {import('ws').WebSocket} ws 已连接 relay
268
+ * @param {string} relayUrl 中继 URL
269
+ * @param {object} event 待发布事件
270
+ * @param {AbortSignal} [signal] 取消信号
271
+ * @returns {Promise<boolean>} relay 是否接受 EVENT
272
+ */
273
+ function publishEventOnRelay(ws, relayUrl, event, signal) {
274
+ return new Promise((resolve, reject) => {
275
+ if (signal?.aborted) {
276
+ reject(new Error('nostr: aborted'))
277
+ return
278
+ }
279
+ let settled = false
280
+ /**
281
+ * @param {boolean} ok relay 是否接受
282
+ * @param {Error | null} [error] 失败原因
283
+ * @returns {void}
284
+ */
285
+ const finish = (ok, error = null) => {
286
+ if (settled) return
287
+ settled = true
288
+ clearTimeout(timer)
289
+ signal?.removeEventListener('abort', onAbort)
290
+ ws.off('message', onMessage)
291
+ if (error) reject(error)
292
+ else resolve(ok)
293
+ }
294
+ /**
295
+ * @returns {void}
296
+ */
297
+ const onAbort = () => finish(false, new Error('nostr: aborted'))
298
+ /**
299
+ * @param {import('ws').RawData} data relay 消息
300
+ * @returns {void}
301
+ */
302
+ const onMessage = data => {
303
+ let parsed
304
+ try { parsed = JSON.parse(String(data)) } catch { return }
305
+ if (parsed?.[0] !== 'OK' || parsed[1] !== event.id) return
306
+ const accepted = parsed[2] === true
307
+ if (!accepted)
308
+ nodeDebug('p2p:nostr publish rejected', {
309
+ url: relayUrl,
310
+ reason: String(parsed[3] || 'rejected'),
311
+ })
312
+ finish(accepted)
313
+ }
314
+ const timer = setTimeout(
315
+ () => finish(false, new Error(`nostr: publish ok timeout for ${relayUrl}`)),
316
+ NOSTR_PUBLISH_OK_TIMEOUT_MS,
317
+ )
318
+ timer.unref()
319
+ signal?.addEventListener('abort', onAbort, { once: true })
320
+ ws.on('message', onMessage)
321
+ try {
322
+ ws.send(JSON.stringify(['EVENT', event]))
323
+ }
324
+ catch (error) {
325
+ finish(false, error instanceof Error ? error : new Error(String(error)))
326
+ }
327
+ })
328
+ }
329
+
255
330
  /**
256
331
  * @param {string[]} relayUrls 中继 URL 列表
257
332
  * @param {object} event 待发布事件
@@ -267,8 +342,8 @@ async function publishEvent(relayUrls, event, signal) {
267
342
  const ws = await connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal)
268
343
  try {
269
344
  if (signal?.aborted) throw new Error('nostr: aborted')
270
- ws.send(JSON.stringify(['EVENT', event]))
271
- published = true
345
+ const ok = await publishEventOnRelay(ws, relayUrl, event, signal)
346
+ if (ok) published = true
272
347
  }
273
348
  catch (error) {
274
349
  lastError = error
@@ -282,67 +357,210 @@ async function publishEvent(relayUrls, event, signal) {
282
357
  }
283
358
 
284
359
  /**
285
- * @param {string[]} relayUrls 中继列表
286
- * @param {(ws: import('ws').WebSocket, relayUrl: string) => void} onOpen 连上回调
287
- * @param {AbortSignal} signal 取消信号
288
- * @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 会话
384
+ * @returns {void}
385
+ */
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 已打开连接
289
396
  * @returns {void}
290
397
  */
291
- function connectRelaysProgressive(relayUrls, onOpen, signal, sockets) {
292
- for (const relayUrl of relayUrls)
293
- void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal).then(ws => {
294
- if (signal.aborted) {
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)
295
449
  dropWebSocket(ws)
296
450
  return
297
451
  }
298
- sockets.push(ws)
299
- nodeDebug('p2p:nostr relay up', { url: relayUrl })
300
- onOpen(ws, relayUrl)
452
+ attachSharedRelaySocket(relayUrl, session, ws)
301
453
  }).catch(error => {
454
+ session.connecting = false
455
+ if (!isLiveSharedSession(relayUrl, session)) return
302
456
  nodeDebug('p2p:nostr relay fail', {
303
457
  url: relayUrl,
304
458
  err: String(error?.message || error),
305
459
  })
460
+ if (!session.subs.size) {
461
+ sharedRelaySessions.delete(relayUrl)
462
+ return
463
+ }
464
+ scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
306
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
307
494
  }
308
495
 
309
496
  /**
310
- * 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。
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 */ }
527
+ }
528
+
529
+ /**
530
+ * 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。多订阅共享每 URL 一条连接。
311
531
  * @param {string[]} relayUrls 中继 URL 列表
312
- * @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 订阅选项
313
533
  * @returns {() => void} 取消订阅
314
534
  */
315
535
  function subscribeNostrKind(relayUrls, options) {
316
- const { kind, rendezvousKey, tagX, onPayload } = options
317
- const abortController = new AbortController()
318
- /** @type {import('ws').WebSocket[]} */
319
- const sockets = []
536
+ const { kind, rendezvousKey, tagX, onPayload, addressable = false } = options
320
537
  const subscriptionId = randomBytes(8).toString('hex')
321
- connectRelaysProgressive(relayUrls, (ws, relayUrl) => {
322
- ws.on('message', data => {
323
- if (abortController.signal.aborted) return
324
- let parsed
325
- try { parsed = JSON.parse(String(data)) } catch { return }
326
- if (parsed?.[0] !== 'EVENT') return
327
- const nostrEvent = parsed[2]
328
- if (nostrEvent?.kind !== kind) return
329
- try {
330
- const result = onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent })
331
- if (result?.then) void result.catch(() => { })
332
- }
333
- catch { /* ignore */ }
334
- })
335
- ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }]))
336
- }, 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)
337
556
  return () => {
338
- abortController.abort()
339
- for (const ws of sockets) dropWebSocket(ws)
557
+ for (const relayUrl of urls) releaseSharedRelaySub(relayUrl, subscriptionId)
340
558
  }
341
559
  }
342
560
 
343
561
  /**
344
562
  * 创建 Nostr discovery provider(list+connect;topic 仅内部)。
345
- * @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined }} [options] 中继配置
563
+ * @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined, localNodeHash?: string }} [options] 中继配置与本机 hash
346
564
  * @returns {import('./index.mjs').DiscoveryProvider} Nostr discovery provider
347
565
  */
348
566
  export function createNostrDiscoveryProvider(options = {}) {
@@ -359,55 +577,124 @@ export function createNostrDiscoveryProvider(options = {}) {
359
577
  return dedupeRelayUrls(options.relayUrls)
360
578
  }
361
579
  const secretKey = randomBytes(32)
362
- /** @type {(() => void) | null} */
363
- let stopNetworkAdvertSub = null
364
- /** @type {Map<string, () => void>} */
365
- const groupSubs = new Map()
366
- /** @type {Map<string, () => void>} */
367
- 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()
368
589
  /** @type {Map<string, () => void>} */
369
590
  const nodeSignalSubs = new Map()
370
591
 
371
592
  /**
593
+ * @param {string | undefined | null} nodeHash 本机 hash
372
594
  * @returns {void}
373
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
+ */
374
667
  function ensureNetworkAdvertSubscription() {
375
- if (stopNetworkAdvertSub) return
376
- const rendezvousKey = networkRendezvousKey()
377
- stopNetworkAdvertSub = subscribeNostrKind(resolveRelayUrls(), {
378
- kind: NOSTR_ADVERT_KIND,
379
- rendezvousKey,
380
- tagX: 'advert',
381
- /**
382
- * @param {Uint8Array} bytes 加密 advert 载荷
383
- * @returns {Promise<void>}
384
- */
385
- async onPayload(bytes) {
386
- await acceptNostrAdvert(rendezvousKey, bytes)
387
- },
668
+ return ensureAdvertSubscription(NETWORK_SUB_KEY, {
669
+ rendezvousKey: networkRendezvousKey(),
388
670
  })
389
671
  }
390
672
 
391
673
  /**
392
674
  * @param {string} roomSecret 房间密钥
393
- * @returns {void}
675
+ * @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
676
+ * @returns {() => void} 取消 listener
394
677
  */
395
- function ensureGroupSubscription(roomSecret) {
678
+ function ensureGroupSubscription(roomSecret, listener) {
396
679
  const key = String(roomSecret || '')
397
- if (!key || groupSubs.has(key)) return
398
- const rendezvousKey = groupRendezvousKey(key)
399
- groupSubs.set(key, subscribeNostrKind(resolveRelayUrls(), {
400
- kind: NOSTR_ADVERT_KIND,
401
- rendezvousKey,
402
- tagX: 'advert',
403
- /**
404
- * @param {Uint8Array} bytes 加密 advert 载荷
405
- * @returns {Promise<void>}
406
- */
407
- async onPayload(bytes) {
408
- await acceptNostrAdvert(rendezvousKey, bytes, { roomSecret: key })
409
- },
410
- }))
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)
411
698
  }
412
699
 
413
700
  return {
@@ -422,10 +709,14 @@ export function createNostrDiscoveryProvider(options = {}) {
422
709
  const limit = Math.max(1, Number(options.limit) || 64)
423
710
  if (options.roomSecret) {
424
711
  ensureGroupSubscription(options.roomSecret)
425
- return listNostrGroupVisibleNodeHashes(options.roomSecret).slice(0, limit)
712
+ return listNostrGroupVisibleNodeHashes(options.roomSecret)
713
+ .filter(hash => hash !== selfNodeHash)
714
+ .slice(0, limit)
426
715
  }
427
716
  ensureNetworkAdvertSubscription()
428
- return listNostrVisibleNodeHashes().slice(0, limit)
717
+ return listNostrVisibleNodeHashes()
718
+ .filter(hash => hash !== selfNodeHash)
719
+ .slice(0, limit)
429
720
  },
430
721
  /**
431
722
  * 挂上对该节点的内部 advert 订阅(建链由 registry dialer / ensureLinkToNode 完成)。
@@ -435,21 +726,7 @@ export function createNostrDiscoveryProvider(options = {}) {
435
726
  async connectToNode(nodeHash) {
436
727
  const hash = normalizeHex64(nodeHash)
437
728
  if (!isHex64(hash)) return false
438
- if (!nodeAdvertSubs.has(hash)) {
439
- const rendezvousKey = nodeRendezvousKey(hash)
440
- nodeAdvertSubs.set(hash, subscribeNostrKind(resolveRelayUrls(), {
441
- kind: NOSTR_ADVERT_KIND,
442
- rendezvousKey,
443
- tagX: 'advert',
444
- /**
445
- * @param {Uint8Array} bytes 加密 advert 载荷
446
- * @returns {Promise<void>}
447
- */
448
- async onPayload(bytes) {
449
- await acceptNostrAdvert(rendezvousKey, bytes)
450
- },
451
- }))
452
- }
729
+ ensureNodeAdvertSubscription(hash)
453
730
  return true
454
731
  },
455
732
  /**
@@ -467,12 +744,12 @@ export function createNostrDiscoveryProvider(options = {}) {
467
744
  if (abortController.signal.aborted) return
468
745
  const beacon = await getBeacon?.()
469
746
  if (!beacon?.nodeHash) return
470
- noteNostrVisibleNode(beacon.nodeHash)
747
+ noteSelfNodeHash(beacon.nodeHash)
471
748
  const advertBody = beacon.advertBody || beacon.body || beacon
472
749
  const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
473
750
  const event = await signNostrEvent(
474
751
  NOSTR_ADVERT_KIND,
475
- [['t', rendezvousKey], ['x', 'advert']],
752
+ [['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
476
753
  bytesToBase64(bytes),
477
754
  secretKey,
478
755
  )
@@ -518,6 +795,7 @@ export function createNostrDiscoveryProvider(options = {}) {
518
795
  async listenNodeSignals(localNodeHash, onSignal) {
519
796
  const hash = normalizeHex64(localNodeHash)
520
797
  if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
798
+ noteSelfNodeHash(hash)
521
799
  const rendezvousKey = nodeRendezvousKey(hash)
522
800
  const existing = nodeSignalSubs.get(hash)
523
801
  if (existing) existing()
@@ -542,19 +820,7 @@ export function createNostrDiscoveryProvider(options = {}) {
542
820
  async watchNodeAdvert(nodeHash, onAdvert) {
543
821
  const hash = normalizeHex64(nodeHash)
544
822
  if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
545
- const rendezvousKey = nodeRendezvousKey(hash)
546
- const stop = subscribeNostrKind(resolveRelayUrls(), {
547
- kind: NOSTR_ADVERT_KIND,
548
- rendezvousKey,
549
- tagX: 'advert',
550
- /**
551
- * @param {Uint8Array} bytes 加密 advert 载荷
552
- * @param {object} meta relay 元数据
553
- * @returns {void}
554
- */
555
- onPayload: (bytes, meta) => onAdvert(bytes, meta),
556
- })
557
- return stop
823
+ return ensureNodeAdvertSubscription(hash, onAdvert)
558
824
  },
559
825
  /**
560
826
  * @param {string} roomSecret 房间密钥
@@ -573,12 +839,12 @@ export function createNostrDiscoveryProvider(options = {}) {
573
839
  if (abortController.signal.aborted) return
574
840
  const beacon = await getBeacon?.()
575
841
  if (!beacon?.nodeHash) return
576
- noteNostrGroupVisibleNode(key, beacon.nodeHash)
842
+ noteSelfNodeHash(beacon.nodeHash)
577
843
  const advertBody = beacon.advertBody || beacon.body || beacon
578
844
  const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
579
845
  const event = await signNostrEvent(
580
846
  NOSTR_ADVERT_KIND,
581
- [['t', rendezvousKey], ['x', 'advert']],
847
+ [['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
582
848
  bytesToBase64(bytes),
583
849
  secretKey,
584
850
  )
@@ -598,20 +864,7 @@ export function createNostrDiscoveryProvider(options = {}) {
598
864
  * @returns {Promise<() => void>} 取消群 advert 监听
599
865
  */
600
866
  async watchGroupAdverts(roomSecret, onAdvert) {
601
- const key = String(roomSecret || '')
602
- const rendezvousKey = groupRendezvousKey(key)
603
- ensureGroupSubscription(key)
604
- return subscribeNostrKind(resolveRelayUrls(), {
605
- kind: NOSTR_ADVERT_KIND,
606
- rendezvousKey,
607
- tagX: 'advert',
608
- /**
609
- * @param {Uint8Array} bytes 加密 advert 载荷
610
- * @param {object} meta relay 元数据
611
- * @returns {void}
612
- */
613
- onPayload: (bytes, meta) => onAdvert(bytes, meta),
614
- })
867
+ return ensureGroupSubscription(roomSecret, onAdvert)
615
868
  },
616
869
  /**
617
870
  * 供 advert 解析路径写入可见 hash。
@@ -628,14 +881,9 @@ export function createNostrDiscoveryProvider(options = {}) {
628
881
  * @returns {void}
629
882
  */
630
883
  dispose() {
631
- stopNetworkAdvertSub?.()
632
- stopNetworkAdvertSub = null
633
- for (const stop of groupSubs.values())
634
- try { stop() } catch { /* ignore */ }
635
- groupSubs.clear()
636
- for (const stop of nodeAdvertSubs.values())
637
- try { stop() } catch { /* ignore */ }
638
- nodeAdvertSubs.clear()
884
+ for (const entry of advertSubs.values())
885
+ try { entry.stop() } catch { /* ignore */ }
886
+ advertSubs.clear()
639
887
  for (const stop of nodeSignalSubs.values())
640
888
  try { stop() } catch { /* ignore */ }
641
889
  nodeSignalSubs.clear()