@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
@@ -4,7 +4,17 @@ import { schnorr } from '@noble/curves/secp256k1.js'
4
4
  import WebSocket from 'ws'
5
5
 
6
6
  import { base64ToBytes, hexToBytes, bytesToBase64, bytesToHex } from '../core/bytes_codec.mjs'
7
+ import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
7
8
  import { sha256Hex } from '../crypto/crypto.mjs'
9
+ import { nodeDebug, shortHash } from '../node/log.mjs'
10
+
11
+ import { ingestEncryptedAdvert } from './adverts.mjs'
12
+ import {
13
+ encryptSignalPacket,
14
+ groupRendezvousKey,
15
+ networkRendezvousKey,
16
+ nodeRendezvousKey,
17
+ } from './internal/signal_crypto.mjs'
8
18
 
9
19
  /** 默认 Nostr 中继 URL 列表。 */
10
20
  export const DEFAULT_RELAY_URLS = [
@@ -23,9 +33,119 @@ export const NOSTR_ADVERT_KIND = 27235
23
33
  /** Nostr signal 事件 kind。 */
24
34
  export const NOSTR_SIGNAL_KIND = 27236
25
35
 
36
+ const ADVERT_TTL_MS = 10 * 60_000
37
+
38
+ /** @type {Map<string, number>} 网络域 nodeHash → lastSeenAt */
39
+ const visibleByHash = new Map()
40
+ /** @type {Map<string, Map<string, number>>} roomSecret → (nodeHash → lastSeenAt) */
41
+ const visibleByGroup = new Map()
42
+
43
+ /**
44
+ * @param {Map<string, number>} pool 可见池
45
+ * @param {number} now 当前时间
46
+ * @param {number} ttlMs TTL
47
+ * @returns {string[]} 未过期 nodeHash
48
+ */
49
+ function listPoolHashes(pool, now, ttlMs) {
50
+ /** @type {string[]} */
51
+ const out = []
52
+ for (const [hash, seenAt] of pool)
53
+ if (now - seenAt <= ttlMs) out.push(hash)
54
+ else pool.delete(hash)
55
+
56
+ return out
57
+ }
58
+
59
+ /**
60
+ * 写入网络域可见池(非群)。
61
+ * @param {string} nodeHash 节点 hash
62
+ * @param {number} [now=Date.now()] 当前时间
63
+ * @returns {void}
64
+ */
65
+ export function noteNostrVisibleNode(nodeHash, now = Date.now()) {
66
+ const hash = normalizeHex64(nodeHash)
67
+ if (!isHex64(hash)) return
68
+ visibleByHash.set(hash, now)
69
+ }
70
+
71
+ /**
72
+ * 写入群域可见池(与网络域隔离)。
73
+ * @param {string} roomSecret 房间密钥
74
+ * @param {string} nodeHash 节点 hash
75
+ * @param {number} [now=Date.now()] 当前时间
76
+ * @returns {void}
77
+ */
78
+ export function noteNostrGroupVisibleNode(roomSecret, nodeHash, now = Date.now()) {
79
+ const key = String(roomSecret || '')
80
+ const hash = normalizeHex64(nodeHash)
81
+ if (!key || !isHex64(hash)) return
82
+ let pool = visibleByGroup.get(key)
83
+ if (!pool) {
84
+ pool = new Map()
85
+ visibleByGroup.set(key, pool)
86
+ }
87
+ pool.set(hash, now)
88
+ }
89
+
90
+ /**
91
+ * @param {number} [now=Date.now()] 当前时间
92
+ * @param {number} [ttlMs=ADVERT_TTL_MS] TTL
93
+ * @returns {string[]} 网络域可见 nodeHash
94
+ */
95
+ export function listNostrVisibleNodeHashes(now = Date.now(), ttlMs = ADVERT_TTL_MS) {
96
+ return listPoolHashes(visibleByHash, now, ttlMs)
97
+ }
98
+
99
+ /**
100
+ * @param {string} roomSecret 房间密钥
101
+ * @param {number} [now=Date.now()] 当前时间
102
+ * @param {number} [ttlMs=ADVERT_TTL_MS] TTL
103
+ * @returns {string[]} 该群可见 nodeHash
104
+ */
105
+ export function listNostrGroupVisibleNodeHashes(roomSecret, now = Date.now(), ttlMs = ADVERT_TTL_MS) {
106
+ const key = String(roomSecret || '')
107
+ const pool = visibleByGroup.get(key)
108
+ if (!pool) return []
109
+ const out = listPoolHashes(pool, now, ttlMs)
110
+ if (!pool.size) visibleByGroup.delete(key)
111
+ return out
112
+ }
113
+
114
+ /**
115
+ * 解密并验签后写入 Nostr 可见池;伪造 body.nodeHash 无效。
116
+ * @param {string} rendezvousKey rendezvous 键
117
+ * @param {Uint8Array} bytes 加密 advert
118
+ * @param {{ roomSecret?: string }} [options] 带 roomSecret 时写入群池
119
+ * @returns {Promise<string | null>} 验签通过的 nodeHash
120
+ */
121
+ export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
122
+ const ingested = await ingestEncryptedAdvert(rendezvousKey, bytes)
123
+ if (!ingested) return null
124
+ const hash = ingested.verifiedNodeHash
125
+ const roomSecret = options.roomSecret
126
+ let firstSeen = true
127
+ if (roomSecret) {
128
+ const key = String(roomSecret || '')
129
+ firstSeen = !visibleByGroup.get(key)?.has(hash)
130
+ noteNostrGroupVisibleNode(key, hash)
131
+ }
132
+ else {
133
+ firstSeen = !visibleByHash.has(hash)
134
+ noteNostrVisibleNode(hash)
135
+ }
136
+ if (firstSeen)
137
+ nodeDebug('p2p:nostr peer visible', { peer: shortHash(hash), group: !!roomSecret })
138
+ return hash
139
+ }
140
+
141
+ /** @returns {void} 测试用 */
142
+ export function clearNostrVisibleNodes() {
143
+ visibleByHash.clear()
144
+ visibleByGroup.clear()
145
+ }
146
+
26
147
  /**
27
148
  * 关掉 WebSocket:已连上则先 close,grace 内未 CLOSED 再 terminate。
28
- * 连接中直接 terminate(无握手可优雅收尾)。
29
149
  * @param {import('ws').WebSocket} ws 连接
30
150
  * @returns {void}
31
151
  */
@@ -39,7 +159,6 @@ function dropWebSocket(ws) {
39
159
  if (ws.readyState !== WebSocket.CLOSED)
40
160
  try { ws.terminate() } catch { /* ignore */ }
41
161
  }, NOSTR_CLOSE_GRACE_MS)
42
- // Grace is polite to the peer; must not pin the process after shutdown.
43
162
  timer.unref()
44
163
  ws.once('close', () => clearTimeout(timer))
45
164
  try {
@@ -52,7 +171,6 @@ function dropWebSocket(ws) {
52
171
  }
53
172
 
54
173
  /**
55
- * 去重并清洗中继 URL 列表。
56
174
  * @param {string[] | undefined | null} urls 原始列表
57
175
  * @returns {string[]} 清洗后的列表
58
176
  */
@@ -64,7 +182,6 @@ function dedupeRelayUrls(urls) {
64
182
  }
65
183
 
66
184
  /**
67
- * 合并默认与用户配置的中继 URL(去重)。
68
185
  * @param {string[] | undefined | null} userRelayUrls 用户自定义中继列表
69
186
  * @returns {string[]} 合并后的中继 URL 列表
70
187
  */
@@ -74,7 +191,6 @@ export function mergeSignalingRelayUrls(userRelayUrls) {
74
191
  }
75
192
 
76
193
  /**
77
- * 签名 Nostr 事件。
78
194
  * @param {number} kind 事件 kind
79
195
  * @param {string[][]} tags 事件标签
80
196
  * @param {string} content 事件内容
@@ -91,7 +207,6 @@ async function signNostrEvent(kind, tags, content, secretKey) {
91
207
  }
92
208
 
93
209
  /**
94
- * 连接 Nostr 中继 WebSocket(`ws` 包,支持 terminate)。
95
210
  * @param {string} relayUrl 中继 URL
96
211
  * @param {number} [timeoutMs] 超时毫秒
97
212
  * @param {AbortSignal} [signal] 取消信号
@@ -138,13 +253,13 @@ function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
138
253
  }
139
254
 
140
255
  /**
141
- * 向多个中继并行发布 Nostr 事件(任一成功即可)。
142
256
  * @param {string[]} relayUrls 中继 URL 列表
143
257
  * @param {object} event 待发布事件
144
258
  * @param {AbortSignal} [signal] 取消信号
145
259
  * @returns {Promise<void>}
146
260
  */
147
261
  async function publishEvent(relayUrls, event, signal) {
262
+ if (!relayUrls.length) throw new Error('nostr: no relay')
148
263
  let published = false
149
264
  let lastError = null
150
265
  await Promise.allSettled(relayUrls.map(async relayUrl => {
@@ -167,7 +282,6 @@ async function publishEvent(relayUrls, event, signal) {
167
282
  }
168
283
 
169
284
  /**
170
- * 并行渐进连接中继:立刻返回,连上后回调;abort 后 stop 接入并 drop 已开 socket。
171
285
  * @param {string[]} relayUrls 中继列表
172
286
  * @param {(ws: import('ws').WebSocket, relayUrl: string) => void} onOpen 连上回调
173
287
  * @param {AbortSignal} signal 取消信号
@@ -182,18 +296,24 @@ function connectRelaysProgressive(relayUrls, onOpen, signal, sockets) {
182
296
  return
183
297
  }
184
298
  sockets.push(ws)
299
+ nodeDebug('p2p:nostr relay up', { url: relayUrl })
185
300
  onOpen(ws, relayUrl)
186
- }).catch(() => { /* 单路失败正常降级 */ })
301
+ }).catch(error => {
302
+ nodeDebug('p2p:nostr relay fail', {
303
+ url: relayUrl,
304
+ err: String(error?.message || error),
305
+ })
306
+ })
187
307
  }
188
308
 
189
309
  /**
190
- * 订阅指定 kind Nostr 事件(渐进连中继,立即返回 cleanup)。
310
+ * 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。
191
311
  * @param {string[]} relayUrls 中继 URL 列表
192
- * @param {{ kind: number, topic: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void }} options 订阅选项
312
+ * @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void> }} options 订阅选项
193
313
  * @returns {() => void} 取消订阅
194
314
  */
195
315
  function subscribeNostrKind(relayUrls, options) {
196
- const { kind, topic, tagX, onPayload } = options
316
+ const { kind, rendezvousKey, tagX, onPayload } = options
197
317
  const abortController = new AbortController()
198
318
  /** @type {import('ws').WebSocket[]} */
199
319
  const sockets = []
@@ -206,10 +326,13 @@ function subscribeNostrKind(relayUrls, options) {
206
326
  if (parsed?.[0] !== 'EVENT') return
207
327
  const nostrEvent = parsed[2]
208
328
  if (nostrEvent?.kind !== kind) return
209
- try { onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent }) }
329
+ try {
330
+ const result = onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent })
331
+ if (result?.then) void result.catch(() => { })
332
+ }
210
333
  catch { /* ignore */ }
211
334
  })
212
- ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [kind], '#t': [topic], '#x': [tagX] }]))
335
+ ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }]))
213
336
  }, abortController.signal, sockets)
214
337
  return () => {
215
338
  abortController.abort()
@@ -218,92 +341,304 @@ function subscribeNostrKind(relayUrls, options) {
218
341
  }
219
342
 
220
343
  /**
221
- * 创建 Nostr discovery provider
222
- * subscribe/onSignal/advertise 首调用不阻塞等公网中继;连上后渐进生效。
223
- * `options.relayUrls` 为最终列表(调用方 `mergeSignalingRelayUrls`,或直接传入 `relayOverride`)。
224
- * 省略 / `undefined` → 默认公网中继;显式 `[]` 表示无中继(不再回填默认)。
225
- * @param {{ relayUrls?: string[] | null }} [options] 可选最终中继 URL 列表
226
- * @returns {import('./index.mjs').DiscoveryProvider} Nostr 发现提供者
344
+ * 创建 Nostr discovery provider(list+connect;topic 仅内部)。
345
+ * @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined }} [options] 中继配置
346
+ * @returns {import('./index.mjs').DiscoveryProvider} Nostr discovery provider
227
347
  */
228
348
  export function createNostrDiscoveryProvider(options = {}) {
229
- const relayUrls = dedupeRelayUrls(options.relayUrls || DEFAULT_RELAY_URLS)
349
+ /**
350
+ * @returns {string[]} 去重后的中继 URL 列表
351
+ */
352
+ const resolveRelayUrls = () => {
353
+ if (typeof options.getRelayUrls === 'function') {
354
+ const urls = options.getRelayUrls()
355
+ return dedupeRelayUrls(urls == null ? DEFAULT_RELAY_URLS : urls)
356
+ }
357
+ if (options.relayUrls === undefined || options.relayUrls === null)
358
+ return dedupeRelayUrls(DEFAULT_RELAY_URLS)
359
+ return dedupeRelayUrls(options.relayUrls)
360
+ }
230
361
  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()
368
+ /** @type {Map<string, () => void>} */
369
+ const nodeSignalSubs = new Map()
370
+
371
+ /**
372
+ * @returns {void}
373
+ */
374
+ 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
+ },
388
+ })
389
+ }
390
+
391
+ /**
392
+ * @param {string} roomSecret 房间密钥
393
+ * @returns {void}
394
+ */
395
+ function ensureGroupSubscription(roomSecret) {
396
+ 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
+ }))
411
+ }
412
+
231
413
  return {
232
414
  id: 'nostr',
233
415
  priority: 100,
234
416
  caps: { canDiscover: true, canSignal: true, canRelay: false },
235
417
  /**
236
- * 周期性向中继发布 advert 事件(首发后台,不阻塞 ensureRuntime)。
237
- * @param {string} topic advert 主题
238
- * @param {Uint8Array} bytes advert 载荷
239
- * @returns {Promise<() => void>} 取消广播函数
418
+ * @param {{ limit?: number, roomSecret?: string }} [options] 扫描选项
419
+ * @returns {Promise<string[]>} 可见 nodeHash;带 roomSecret 时仅返回该群池
420
+ */
421
+ async listVisibleNodeHashes(options = {}) {
422
+ const limit = Math.max(1, Number(options.limit) || 64)
423
+ if (options.roomSecret) {
424
+ ensureGroupSubscription(options.roomSecret)
425
+ return listNostrGroupVisibleNodeHashes(options.roomSecret).slice(0, limit)
426
+ }
427
+ ensureNetworkAdvertSubscription()
428
+ return listNostrVisibleNodeHashes().slice(0, limit)
429
+ },
430
+ /**
431
+ * 挂上对该节点的内部 advert 订阅(建链由 registry dialer / ensureLinkToNode 完成)。
432
+ * @param {string} nodeHash 目标
433
+ * @returns {Promise<boolean>} 是否已准备
240
434
  */
241
- async advertise(topic, bytes) {
435
+ async connectToNode(nodeHash) {
436
+ const hash = normalizeHex64(nodeHash)
437
+ 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
+ }
453
+ return true
454
+ },
455
+ /**
456
+ * @param {() => Promise<object | null>} getBeacon 本机 advert body 工厂
457
+ * @returns {Promise<() => void>} 停止函数
458
+ */
459
+ async startPresence(getBeacon) {
460
+ const rendezvousKey = networkRendezvousKey()
242
461
  const abortController = new AbortController()
462
+ ensureNetworkAdvertSubscription()
243
463
  /**
244
- * 向中继发布当前 advert。
245
464
  * @returns {Promise<void>}
246
465
  */
247
466
  const publish = async () => {
248
467
  if (abortController.signal.aborted) return
468
+ const beacon = await getBeacon?.()
469
+ if (!beacon?.nodeHash) return
470
+ noteNostrVisibleNode(beacon.nodeHash)
471
+ const advertBody = beacon.advertBody || beacon.body || beacon
472
+ const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
249
473
  const event = await signNostrEvent(
250
474
  NOSTR_ADVERT_KIND,
251
- [['t', topic], ['x', 'advert']],
475
+ [['t', rendezvousKey], ['x', 'advert']],
252
476
  bytesToBase64(bytes),
253
477
  secretKey,
254
478
  )
255
- await publishEvent(relayUrls, event, abortController.signal)
479
+ await publishEvent(resolveRelayUrls(), event, abortController.signal)
480
+ nodeDebug('p2p:nostr presence published', { self: shortHash(beacon.nodeHash) })
256
481
  }
257
- void publish().catch(() => { })
258
- const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
482
+ void publish().catch(error => {
483
+ nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
484
+ })
485
+ const timer = setInterval(() => {
486
+ void publish().catch(error => {
487
+ nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
488
+ })
489
+ }, 5 * 60_000)
490
+ timer.unref?.()
259
491
  return () => {
260
492
  abortController.abort()
261
493
  clearInterval(timer)
262
494
  }
263
495
  },
264
496
  /**
265
- * 订阅中继上的 advert 事件(并行渐进连中继,立即返回 cleanup)。
266
- * @param {string} topic advert 主题
267
- * @param {Function} onAdvert advert 回调
268
- * @returns {Promise<() => void>} 取消订阅函数
269
- */
270
- async subscribe(topic, onAdvert) {
271
- return subscribeNostrKind(relayUrls, {
272
- kind: NOSTR_ADVERT_KIND,
273
- topic,
274
- tagX: 'advert',
275
- onPayload: onAdvert,
276
- })
277
- },
278
- /**
279
- * 向中继发布 signal 事件(按需发送,仍等待至少一路成功)。
280
- * @param {string} topic 信令 topic
281
- * @param {string} to 目标节点标识
282
- * @param {Uint8Array} bytes 信令载荷
497
+ * @param {string} toNodeHash 目标 nodeHash
498
+ * @param {Uint8Array} bytes 加密信令
283
499
  * @returns {Promise<void>}
284
500
  */
285
- async sendSignal(topic, to, bytes) {
501
+ async sendNodeSignal(toNodeHash, bytes) {
502
+ const hash = normalizeHex64(toNodeHash)
503
+ if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
504
+ const rendezvousKey = nodeRendezvousKey(hash)
286
505
  const event = await signNostrEvent(
287
506
  NOSTR_SIGNAL_KIND,
288
- [['t', topic], ['x', 'signal'], ['p', String(to || '')]],
507
+ [['t', rendezvousKey], ['x', 'signal'], ['p', hash]],
289
508
  bytesToBase64(bytes),
290
509
  secretKey,
291
510
  )
292
- await publishEvent(relayUrls, event)
511
+ await publishEvent(resolveRelayUrls(), event)
293
512
  },
294
513
  /**
295
- * 订阅中继上的 signal 事件(并行渐进连中继,立即返回 cleanup)。
296
- * @param {string} topic 信令 topic
297
- * @param {Function} onSignal 信令回调
298
- * @returns {Promise<() => void>} 取消订阅函数
514
+ * @param {string} localNodeHash 本机 nodeHash
515
+ * @param {(bytes: Uint8Array) => void} onSignal 信令回调
516
+ * @returns {Promise<() => void>} 取消函数
299
517
  */
300
- async onSignal(topic, onSignal) {
301
- return subscribeNostrKind(relayUrls, {
518
+ async listenNodeSignals(localNodeHash, onSignal) {
519
+ const hash = normalizeHex64(localNodeHash)
520
+ if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
521
+ const rendezvousKey = nodeRendezvousKey(hash)
522
+ const existing = nodeSignalSubs.get(hash)
523
+ if (existing) existing()
524
+ nodeDebug('p2p:nostr signal listen', { self: shortHash(hash), relays: resolveRelayUrls().length })
525
+ const stop = subscribeNostrKind(resolveRelayUrls(), {
302
526
  kind: NOSTR_SIGNAL_KIND,
303
- topic,
527
+ rendezvousKey,
304
528
  tagX: 'signal',
305
529
  onPayload: onSignal,
306
530
  })
531
+ nodeSignalSubs.set(hash, stop)
532
+ return () => {
533
+ stop()
534
+ nodeSignalSubs.delete(hash)
535
+ }
536
+ },
537
+ /**
538
+ * @param {string} nodeHash 目标 nodeHash
539
+ * @param {(bytes: Uint8Array, meta: object) => void} onAdvert advert 回调
540
+ * @returns {Promise<() => void>} 取消函数
541
+ */
542
+ async watchNodeAdvert(nodeHash, onAdvert) {
543
+ const hash = normalizeHex64(nodeHash)
544
+ 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
558
+ },
559
+ /**
560
+ * @param {string} roomSecret 房间密钥
561
+ * @param {() => Promise<object | null>} getBeacon advert 工厂
562
+ * @returns {Promise<() => void>} 停止群 presence 广播
563
+ */
564
+ async startGroupPresence(roomSecret, getBeacon) {
565
+ const key = String(roomSecret || '')
566
+ const rendezvousKey = groupRendezvousKey(key)
567
+ const abortController = new AbortController()
568
+ ensureGroupSubscription(key)
569
+ /**
570
+ * @returns {Promise<void>}
571
+ */
572
+ const publish = async () => {
573
+ if (abortController.signal.aborted) return
574
+ const beacon = await getBeacon?.()
575
+ if (!beacon?.nodeHash) return
576
+ noteNostrGroupVisibleNode(key, beacon.nodeHash)
577
+ const advertBody = beacon.advertBody || beacon.body || beacon
578
+ const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
579
+ const event = await signNostrEvent(
580
+ NOSTR_ADVERT_KIND,
581
+ [['t', rendezvousKey], ['x', 'advert']],
582
+ bytesToBase64(bytes),
583
+ secretKey,
584
+ )
585
+ await publishEvent(resolveRelayUrls(), event, abortController.signal)
586
+ }
587
+ void publish().catch(() => { })
588
+ const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
589
+ timer.unref?.()
590
+ return () => {
591
+ abortController.abort()
592
+ clearInterval(timer)
593
+ }
594
+ },
595
+ /**
596
+ * @param {string} roomSecret 房间密钥
597
+ * @param {(bytes: Uint8Array, meta: object) => void} onAdvert 回调
598
+ * @returns {Promise<() => void>} 取消群 advert 监听
599
+ */
600
+ 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
+ })
615
+ },
616
+ /**
617
+ * 供 advert 解析路径写入可见 hash。
618
+ * @param {string} nodeHash 节点 hash
619
+ * @param {{ roomSecret?: string }} [options] 带 roomSecret 时写入群池
620
+ * @returns {void}
621
+ */
622
+ noteVisibleNode(nodeHash, options = {}) {
623
+ if (options.roomSecret) noteNostrGroupVisibleNode(options.roomSecret, nodeHash)
624
+ else noteNostrVisibleNode(nodeHash)
625
+ },
626
+ /**
627
+ * 停止全部内部订阅(reload / unregister 时调用)。
628
+ * @returns {void}
629
+ */
630
+ 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()
639
+ for (const stop of nodeSignalSubs.values())
640
+ try { stop() } catch { /* ignore */ }
641
+ nodeSignalSubs.clear()
307
642
  },
308
643
  }
309
644
  }
@@ -4,9 +4,7 @@ import { verifiedChunkBytes } from '../files/chunk_fetch_verify.mjs'
4
4
  /** @type {Map<string, { expectedHash: string, timer: ReturnType<typeof setTimeout>, resolve: (v: Uint8Array | null) => void, reject?: (e: Error) => void }>} */
5
5
  export const pendingChunkFetches = new Map()
6
6
 
7
- /**
8
- *
9
- */
7
+ /** 并发 pending chunk fetch 上限。 */
10
8
  export const MAX_PENDING_CHUNK_FETCHES = 2048
11
9
 
12
10
  /**
@@ -20,10 +18,11 @@ export const MAX_PENDING_CHUNK_FETCHES = 2048
20
18
  export function registerChunkFetchWait(key, expectedHash, timeoutMs, options = {}) {
21
19
  if (!key || pendingChunkFetches.size >= MAX_PENDING_CHUNK_FETCHES)
22
20
  return {
23
- done: Promise.resolve(null), /**
24
- *
25
- */
26
- cancel: () => { }
21
+ done: Promise.resolve(null),
22
+ /**
23
+ *
24
+ */
25
+ cancel: () => { },
27
26
  }
28
27
 
29
28
  /** @type {(value: Uint8Array | null | Error) => void} */
@@ -58,11 +57,11 @@ export function registerChunkFetchWait(key, expectedHash, timeoutMs, options = {
58
57
  }
59
58
 
60
59
  /**
61
- * @param {Error | unknown} err 拒绝原因
60
+ * @param {Error | unknown} error 拒绝原因
62
61
  * @returns {void}
63
62
  */
64
- function rejectWait(err) {
65
- finish(err instanceof Error ? err : new Error(String(err)))
63
+ function rejectWait(error) {
64
+ finish(error instanceof Error ? error : new Error(String(error)))
66
65
  }
67
66
 
68
67
  pendingChunkFetches.set(key, {
@@ -74,9 +73,7 @@ export function registerChunkFetchWait(key, expectedHash, timeoutMs, options = {
74
73
 
75
74
  return {
76
75
  done,
77
- /**
78
- *
79
- */
76
+ /** 取消等待并以 null settle。 */
80
77
  cancel: () => {
81
78
  clearTimeout(timer)
82
79
  pendingChunkFetches.delete(key)
@@ -3,9 +3,7 @@ import { verifySignedPublicManifest } from '../files/public_manifest.mjs'
3
3
  /** @type {Map<string, { expectedKey: string, timer: ReturnType<typeof setTimeout>, resolve: (v: object | null) => void }>} */
4
4
  export const pendingManifestFetches = new Map()
5
5
 
6
- /**
7
- *
8
- */
6
+ /** 并发 pending manifest fetch 上限。 */
9
7
  export const MAX_PENDING_MANIFEST_FETCHES = 512
10
8
 
11
9
  /**