@steve02081504/fount-p2p 0.0.12 → 0.0.14

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 (79) 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 +9 -3
  7. package/discovery/adverts.mjs +115 -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 +279 -0
  14. package/discovery/lan_interfaces.mjs +78 -0
  15. package/discovery/lan_peer_hints.mjs +22 -5
  16. package/discovery/nostr.mjs +474 -65
  17. package/federation/chunk_fetch_pending.mjs +10 -13
  18. package/federation/manifest_fetch_pending.mjs +1 -3
  19. package/files/assemble.mjs +14 -14
  20. package/files/assemble_stream.mjs +6 -6
  21. package/files/chunk_responder.mjs +29 -20
  22. package/files/evfs.mjs +29 -28
  23. package/files/manifest_fetch.mjs +16 -3
  24. package/files/public_manifest.mjs +11 -11
  25. package/files/transfer_key_registry.mjs +2 -2
  26. package/index.mjs +86 -11
  27. package/infra/cli.mjs +62 -0
  28. package/infra/debug_log.mjs +56 -0
  29. package/infra/default_node_dir.mjs +22 -0
  30. package/infra/priority.mjs +56 -0
  31. package/infra/service.mjs +140 -0
  32. package/infra/tunables.json +5 -0
  33. package/link/frame.mjs +9 -16
  34. package/link/handshake.mjs +25 -18
  35. package/link/pipe.mjs +8 -8
  36. package/link/providers/ble_gatt.mjs +6 -6
  37. package/link/providers/lan_tcp.mjs +62 -33
  38. package/link/providers/webrtc.mjs +3 -1
  39. package/link/rtc.mjs +3 -3
  40. package/mailbox/consumer_registry.mjs +2 -2
  41. package/mailbox/deliver_or_store.mjs +5 -5
  42. package/mailbox/wire.mjs +28 -24
  43. package/node/entity_store.mjs +6 -6
  44. package/node/instance.mjs +46 -27
  45. package/node/local_data_revision.mjs +26 -0
  46. package/node/log.mjs +56 -0
  47. package/node/network.mjs +37 -5
  48. package/node/reputation_store.mjs +4 -4
  49. package/node/reputation_sync.mjs +318 -0
  50. package/node/routing_profile.mjs +21 -0
  51. package/node/signaling_config.mjs +30 -11
  52. package/overlay/index.mjs +22 -1
  53. package/package.json +13 -2
  54. package/rooms/scoped_link.mjs +17 -166
  55. package/transport/advert_ingest.mjs +11 -14
  56. package/transport/group_link_set.mjs +149 -99
  57. package/transport/link_registry.mjs +211 -60
  58. package/transport/mesh_keepalive.mjs +217 -0
  59. package/transport/node_scope.mjs +289 -0
  60. package/transport/offer_answer.mjs +45 -48
  61. package/transport/peer_pool.mjs +116 -14
  62. package/transport/remote_user_room.mjs +6 -9
  63. package/transport/{rtc_mdns_filter.mjs → rtc_ice_local_hostname.mjs} +13 -13
  64. package/transport/runtime_bootstrap.mjs +170 -108
  65. package/transport/tunables.json +11 -0
  66. package/transport/tunables.mjs +18 -0
  67. package/transport/user_room.mjs +83 -158
  68. package/trust_graph/build.mjs +0 -2
  69. package/trust_graph/cache.mjs +12 -3
  70. package/trust_graph/registry.mjs +6 -6
  71. package/trust_graph/send.mjs +0 -3
  72. package/utils/async_mutex.mjs +4 -4
  73. package/utils/emit_safe.mjs +3 -3
  74. package/utils/json_io.mjs +12 -12
  75. package/utils/map_pool.mjs +5 -5
  76. package/wire/part_ingress.mjs +32 -28
  77. package/wire/part_query.mjs +26 -20
  78. package/discovery/mdns.mjs +0 -197
  79. package/transport/signal_crypto.mjs +0 -104
@@ -4,7 +4,18 @@ 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 { noteAdvertPeerHints } from './advert_peer_hints.mjs'
13
+ import {
14
+ encryptSignalPacket,
15
+ groupRendezvousKey,
16
+ networkRendezvousKey,
17
+ nodeRendezvousKey,
18
+ } from './internal/signal_crypto.mjs'
8
19
 
9
20
  /** 默认 Nostr 中继 URL 列表。 */
10
21
  export const DEFAULT_RELAY_URLS = [
@@ -18,14 +29,128 @@ export const NOSTR_CONNECT_TIMEOUT_MS = 2_000
18
29
  /** 先 close,超时未 CLOSED 再 terminate(给对端优雅关闭的窗口)。 */
19
30
  export const NOSTR_CLOSE_GRACE_MS = 1_000
20
31
 
21
- /** Nostr advert 事件 kind。 */
22
- export const NOSTR_ADVERT_KIND = 27235
23
- /** Nostr signal 事件 kind。 */
24
- export const NOSTR_SIGNAL_KIND = 27236
32
+ /** relay 等待 EVENT OK 回执超时。 */
33
+ export const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
34
+
35
+ /** Nostr network advert 事件 kind(addressable,可存储)。 */
36
+ export const NOSTR_ADVERT_KIND = 30787
37
+ /** Nostr signal 事件 kind(ephemeral,实时转发)。 */
38
+ export const NOSTR_SIGNAL_KIND = 20787
39
+
40
+ const ADVERT_TTL_MS = 10 * 60_000
41
+
42
+ /** @type {Map<string, number>} 网络域 nodeHash → lastSeenAt */
43
+ const visibleByHash = new Map()
44
+ /** @type {Map<string, Map<string, number>>} roomSecret → (nodeHash → lastSeenAt) */
45
+ const visibleByGroup = new Map()
46
+
47
+ /**
48
+ * @param {Map<string, number>} pool 可见池
49
+ * @param {number} now 当前时间
50
+ * @param {number} ttlMs TTL
51
+ * @returns {string[]} 未过期 nodeHash
52
+ */
53
+ function listPoolHashes(pool, now, ttlMs) {
54
+ /** @type {string[]} */
55
+ const out = []
56
+ for (const [hash, seenAt] of pool)
57
+ if (now - seenAt <= ttlMs) out.push(hash)
58
+ else pool.delete(hash)
59
+
60
+ return out
61
+ }
62
+
63
+ /**
64
+ * 写入网络域可见池(非群)。
65
+ * @param {string} nodeHash 节点 hash
66
+ * @param {number} [now=Date.now()] 当前时间
67
+ * @returns {void}
68
+ */
69
+ export function noteNostrVisibleNode(nodeHash, now = Date.now()) {
70
+ const hash = normalizeHex64(nodeHash)
71
+ if (!isHex64(hash)) return
72
+ visibleByHash.set(hash, now)
73
+ }
74
+
75
+ /**
76
+ * 写入群域可见池(与网络域隔离)。
77
+ * @param {string} roomSecret 房间密钥
78
+ * @param {string} nodeHash 节点 hash
79
+ * @param {number} [now=Date.now()] 当前时间
80
+ * @returns {void}
81
+ */
82
+ export function noteNostrGroupVisibleNode(roomSecret, nodeHash, now = Date.now()) {
83
+ const key = String(roomSecret || '')
84
+ const hash = normalizeHex64(nodeHash)
85
+ if (!key || !isHex64(hash)) return
86
+ let pool = visibleByGroup.get(key)
87
+ if (!pool) {
88
+ pool = new Map()
89
+ visibleByGroup.set(key, pool)
90
+ }
91
+ pool.set(hash, now)
92
+ }
93
+
94
+ /**
95
+ * @param {number} [now=Date.now()] 当前时间
96
+ * @param {number} [ttlMs=ADVERT_TTL_MS] TTL
97
+ * @returns {string[]} 网络域可见 nodeHash
98
+ */
99
+ export function listNostrVisibleNodeHashes(now = Date.now(), ttlMs = ADVERT_TTL_MS) {
100
+ return listPoolHashes(visibleByHash, now, ttlMs)
101
+ }
102
+
103
+ /**
104
+ * @param {string} roomSecret 房间密钥
105
+ * @param {number} [now=Date.now()] 当前时间
106
+ * @param {number} [ttlMs=ADVERT_TTL_MS] TTL
107
+ * @returns {string[]} 该群可见 nodeHash
108
+ */
109
+ export function listNostrGroupVisibleNodeHashes(roomSecret, now = Date.now(), ttlMs = ADVERT_TTL_MS) {
110
+ const key = String(roomSecret || '')
111
+ const pool = visibleByGroup.get(key)
112
+ if (!pool) return []
113
+ const out = listPoolHashes(pool, now, ttlMs)
114
+ if (!pool.size) visibleByGroup.delete(key)
115
+ return out
116
+ }
117
+
118
+ /**
119
+ * 解密并验签后写入 Nostr 可见池;伪造 body.nodeHash 无效。
120
+ * @param {string} rendezvousKey rendezvous 键
121
+ * @param {Uint8Array} bytes 加密 advert
122
+ * @param {{ roomSecret?: string }} [options] 带 roomSecret 时写入群池
123
+ * @returns {Promise<string | null>} 验签通过的 nodeHash
124
+ */
125
+ export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
126
+ const ingested = await ingestEncryptedAdvert(rendezvousKey, bytes)
127
+ if (!ingested) return null
128
+ const hash = ingested.verifiedNodeHash
129
+ const roomSecret = options.roomSecret
130
+ let firstSeen = true
131
+ if (roomSecret) {
132
+ const key = String(roomSecret || '')
133
+ firstSeen = !visibleByGroup.get(key)?.has(hash)
134
+ noteNostrGroupVisibleNode(key, hash)
135
+ }
136
+ else {
137
+ firstSeen = !visibleByHash.has(hash)
138
+ noteNostrVisibleNode(hash)
139
+ }
140
+ if (firstSeen)
141
+ nodeDebug('p2p:nostr peer visible', { peer: shortHash(hash), group: !!roomSecret })
142
+ noteAdvertPeerHints(hash, ingested.body, options.meta || {})
143
+ return hash
144
+ }
145
+
146
+ /** @returns {void} 测试用 */
147
+ export function clearNostrVisibleNodes() {
148
+ visibleByHash.clear()
149
+ visibleByGroup.clear()
150
+ }
25
151
 
26
152
  /**
27
153
  * 关掉 WebSocket:已连上则先 close,grace 内未 CLOSED 再 terminate。
28
- * 连接中直接 terminate(无握手可优雅收尾)。
29
154
  * @param {import('ws').WebSocket} ws 连接
30
155
  * @returns {void}
31
156
  */
@@ -39,7 +164,6 @@ function dropWebSocket(ws) {
39
164
  if (ws.readyState !== WebSocket.CLOSED)
40
165
  try { ws.terminate() } catch { /* ignore */ }
41
166
  }, NOSTR_CLOSE_GRACE_MS)
42
- // Grace is polite to the peer; must not pin the process after shutdown.
43
167
  timer.unref()
44
168
  ws.once('close', () => clearTimeout(timer))
45
169
  try {
@@ -52,7 +176,6 @@ function dropWebSocket(ws) {
52
176
  }
53
177
 
54
178
  /**
55
- * 去重并清洗中继 URL 列表。
56
179
  * @param {string[] | undefined | null} urls 原始列表
57
180
  * @returns {string[]} 清洗后的列表
58
181
  */
@@ -64,7 +187,6 @@ function dedupeRelayUrls(urls) {
64
187
  }
65
188
 
66
189
  /**
67
- * 合并默认与用户配置的中继 URL(去重)。
68
190
  * @param {string[] | undefined | null} userRelayUrls 用户自定义中继列表
69
191
  * @returns {string[]} 合并后的中继 URL 列表
70
192
  */
@@ -74,7 +196,6 @@ export function mergeSignalingRelayUrls(userRelayUrls) {
74
196
  }
75
197
 
76
198
  /**
77
- * 签名 Nostr 事件。
78
199
  * @param {number} kind 事件 kind
79
200
  * @param {string[][]} tags 事件标签
80
201
  * @param {string} content 事件内容
@@ -91,7 +212,6 @@ async function signNostrEvent(kind, tags, content, secretKey) {
91
212
  }
92
213
 
93
214
  /**
94
- * 连接 Nostr 中继 WebSocket(`ws` 包,支持 terminate)。
95
215
  * @param {string} relayUrl 中继 URL
96
216
  * @param {number} [timeoutMs] 超时毫秒
97
217
  * @param {AbortSignal} [signal] 取消信号
@@ -138,13 +258,77 @@ function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
138
258
  }
139
259
 
140
260
  /**
141
- * 向多个中继并行发布 Nostr 事件(任一成功即可)。
261
+ * @param {import('ws').WebSocket} ws 已连接 relay
262
+ * @param {string} relayUrl 中继 URL
263
+ * @param {object} event 待发布事件
264
+ * @param {AbortSignal} [signal] 取消信号
265
+ * @returns {Promise<boolean>} relay 是否接受 EVENT
266
+ */
267
+ function publishEventOnRelay(ws, relayUrl, event, signal) {
268
+ return new Promise((resolve, reject) => {
269
+ if (signal?.aborted) {
270
+ reject(new Error('nostr: aborted'))
271
+ return
272
+ }
273
+ let settled = false
274
+ /**
275
+ * @param {boolean} ok relay 是否接受
276
+ * @param {Error | null} [error] 失败原因
277
+ * @returns {void}
278
+ */
279
+ const finish = (ok, error = null) => {
280
+ if (settled) return
281
+ settled = true
282
+ clearTimeout(timer)
283
+ signal?.removeEventListener('abort', onAbort)
284
+ ws.off('message', onMessage)
285
+ if (error) reject(error)
286
+ else resolve(ok)
287
+ }
288
+ /**
289
+ * @returns {void}
290
+ */
291
+ const onAbort = () => finish(false, new Error('nostr: aborted'))
292
+ /**
293
+ * @param {import('ws').RawData} data relay 消息
294
+ * @returns {void}
295
+ */
296
+ const onMessage = data => {
297
+ let parsed
298
+ try { parsed = JSON.parse(String(data)) } catch { return }
299
+ if (parsed?.[0] !== 'OK' || parsed[1] !== event.id) return
300
+ const accepted = parsed[2] === true
301
+ if (!accepted)
302
+ nodeDebug('p2p:nostr publish rejected', {
303
+ url: relayUrl,
304
+ reason: String(parsed[3] || 'rejected'),
305
+ })
306
+ finish(accepted)
307
+ }
308
+ const timer = setTimeout(
309
+ () => finish(false, new Error(`nostr: publish ok timeout for ${relayUrl}`)),
310
+ NOSTR_PUBLISH_OK_TIMEOUT_MS,
311
+ )
312
+ timer.unref()
313
+ signal?.addEventListener('abort', onAbort, { once: true })
314
+ ws.on('message', onMessage)
315
+ try {
316
+ ws.send(JSON.stringify(['EVENT', event]))
317
+ }
318
+ catch (error) {
319
+ finish(false, error instanceof Error ? error : new Error(String(error)))
320
+ }
321
+ })
322
+ }
323
+
324
+ /**
142
325
  * @param {string[]} relayUrls 中继 URL 列表
143
326
  * @param {object} event 待发布事件
144
327
  * @param {AbortSignal} [signal] 取消信号
145
328
  * @returns {Promise<void>}
146
329
  */
147
330
  async function publishEvent(relayUrls, event, signal) {
331
+ if (!relayUrls.length) throw new Error('nostr: no relay')
148
332
  let published = false
149
333
  let lastError = null
150
334
  await Promise.allSettled(relayUrls.map(async relayUrl => {
@@ -152,8 +336,8 @@ async function publishEvent(relayUrls, event, signal) {
152
336
  const ws = await connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal)
153
337
  try {
154
338
  if (signal?.aborted) throw new Error('nostr: aborted')
155
- ws.send(JSON.stringify(['EVENT', event]))
156
- published = true
339
+ const ok = await publishEventOnRelay(ws, relayUrl, event, signal)
340
+ if (ok) published = true
157
341
  }
158
342
  catch (error) {
159
343
  lastError = error
@@ -167,7 +351,6 @@ async function publishEvent(relayUrls, event, signal) {
167
351
  }
168
352
 
169
353
  /**
170
- * 并行渐进连接中继:立刻返回,连上后回调;abort 后 stop 接入并 drop 已开 socket。
171
354
  * @param {string[]} relayUrls 中继列表
172
355
  * @param {(ws: import('ws').WebSocket, relayUrl: string) => void} onOpen 连上回调
173
356
  * @param {AbortSignal} signal 取消信号
@@ -182,18 +365,24 @@ function connectRelaysProgressive(relayUrls, onOpen, signal, sockets) {
182
365
  return
183
366
  }
184
367
  sockets.push(ws)
368
+ nodeDebug('p2p:nostr relay up', { url: relayUrl })
185
369
  onOpen(ws, relayUrl)
186
- }).catch(() => { /* 单路失败正常降级 */ })
370
+ }).catch(error => {
371
+ nodeDebug('p2p:nostr relay fail', {
372
+ url: relayUrl,
373
+ err: String(error?.message || error),
374
+ })
375
+ })
187
376
  }
188
377
 
189
378
  /**
190
- * 订阅指定 kind Nostr 事件(渐进连中继,立即返回 cleanup)。
379
+ * 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。
191
380
  * @param {string[]} relayUrls 中继 URL 列表
192
- * @param {{ kind: number, topic: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void }} options 订阅选项
381
+ * @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void> }} options 订阅选项
193
382
  * @returns {() => void} 取消订阅
194
383
  */
195
384
  function subscribeNostrKind(relayUrls, options) {
196
- const { kind, topic, tagX, onPayload } = options
385
+ const { kind, rendezvousKey, tagX, onPayload, addressable = false } = options
197
386
  const abortController = new AbortController()
198
387
  /** @type {import('ws').WebSocket[]} */
199
388
  const sockets = []
@@ -206,10 +395,15 @@ function subscribeNostrKind(relayUrls, options) {
206
395
  if (parsed?.[0] !== 'EVENT') return
207
396
  const nostrEvent = parsed[2]
208
397
  if (nostrEvent?.kind !== kind) return
209
- try { onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent }) }
398
+ try {
399
+ const result = onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent })
400
+ if (result?.then) void result.catch(() => { })
401
+ }
210
402
  catch { /* ignore */ }
211
403
  })
212
- ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [kind], '#t': [topic], '#x': [tagX] }]))
404
+ const filter = { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }
405
+ if (addressable) filter['#d'] = [rendezvousKey]
406
+ ws.send(JSON.stringify(['REQ', subscriptionId, filter]))
213
407
  }, abortController.signal, sockets)
214
408
  return () => {
215
409
  abortController.abort()
@@ -218,92 +412,307 @@ function subscribeNostrKind(relayUrls, options) {
218
412
  }
219
413
 
220
414
  /**
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 发现提供者
415
+ * 创建 Nostr discovery provider(list+connect;topic 仅内部)。
416
+ * @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined }} [options] 中继配置
417
+ * @returns {import('./index.mjs').DiscoveryProvider} Nostr discovery provider
227
418
  */
228
419
  export function createNostrDiscoveryProvider(options = {}) {
229
- const relayUrls = dedupeRelayUrls(options.relayUrls || DEFAULT_RELAY_URLS)
420
+ /**
421
+ * @returns {string[]} 去重后的中继 URL 列表
422
+ */
423
+ const resolveRelayUrls = () => {
424
+ if (typeof options.getRelayUrls === 'function') {
425
+ const urls = options.getRelayUrls()
426
+ return dedupeRelayUrls(urls == null ? DEFAULT_RELAY_URLS : urls)
427
+ }
428
+ if (options.relayUrls === undefined || options.relayUrls === null)
429
+ return dedupeRelayUrls(DEFAULT_RELAY_URLS)
430
+ return dedupeRelayUrls(options.relayUrls)
431
+ }
230
432
  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()
439
+ /** @type {Map<string, () => void>} */
440
+ const nodeSignalSubs = new Map()
441
+
442
+ /**
443
+ * @returns {void}
444
+ */
445
+ 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
+ },
460
+ })
461
+ }
462
+
463
+ /**
464
+ * @param {string} roomSecret 房间密钥
465
+ * @returns {void}
466
+ */
467
+ function ensureGroupSubscription(roomSecret) {
468
+ 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
+ }))
484
+ }
485
+
231
486
  return {
232
487
  id: 'nostr',
233
488
  priority: 100,
234
489
  caps: { canDiscover: true, canSignal: true, canRelay: false },
235
490
  /**
236
- * 周期性向中继发布 advert 事件(首发后台,不阻塞 ensureRuntime)。
237
- * @param {string} topic advert 主题
238
- * @param {Uint8Array} bytes advert 载荷
239
- * @returns {Promise<() => void>} 取消广播函数
491
+ * @param {{ limit?: number, roomSecret?: string }} [options] 扫描选项
492
+ * @returns {Promise<string[]>} 可见 nodeHash;带 roomSecret 时仅返回该群池
493
+ */
494
+ async listVisibleNodeHashes(options = {}) {
495
+ const limit = Math.max(1, Number(options.limit) || 64)
496
+ if (options.roomSecret) {
497
+ ensureGroupSubscription(options.roomSecret)
498
+ return listNostrGroupVisibleNodeHashes(options.roomSecret).slice(0, limit)
499
+ }
500
+ ensureNetworkAdvertSubscription()
501
+ return listNostrVisibleNodeHashes().slice(0, limit)
502
+ },
503
+ /**
504
+ * 挂上对该节点的内部 advert 订阅(建链由 registry dialer / ensureLinkToNode 完成)。
505
+ * @param {string} nodeHash 目标
506
+ * @returns {Promise<boolean>} 是否已准备
507
+ */
508
+ async connectToNode(nodeHash) {
509
+ const hash = normalizeHex64(nodeHash)
510
+ 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
+ }
527
+ return true
528
+ },
529
+ /**
530
+ * @param {() => Promise<object | null>} getBeacon 本机 advert body 工厂
531
+ * @returns {Promise<() => void>} 停止函数
240
532
  */
241
- async advertise(topic, bytes) {
533
+ async startPresence(getBeacon) {
534
+ const rendezvousKey = networkRendezvousKey()
242
535
  const abortController = new AbortController()
536
+ ensureNetworkAdvertSubscription()
243
537
  /**
244
- * 向中继发布当前 advert。
245
538
  * @returns {Promise<void>}
246
539
  */
247
540
  const publish = async () => {
248
541
  if (abortController.signal.aborted) return
542
+ const beacon = await getBeacon?.()
543
+ if (!beacon?.nodeHash) return
544
+ const advertBody = beacon.advertBody || beacon.body || beacon
545
+ const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
249
546
  const event = await signNostrEvent(
250
547
  NOSTR_ADVERT_KIND,
251
- [['t', topic], ['x', 'advert']],
548
+ [['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
252
549
  bytesToBase64(bytes),
253
550
  secretKey,
254
551
  )
255
- await publishEvent(relayUrls, event, abortController.signal)
552
+ await publishEvent(resolveRelayUrls(), event, abortController.signal)
553
+ nodeDebug('p2p:nostr presence published', { self: shortHash(beacon.nodeHash) })
256
554
  }
257
- void publish().catch(() => { })
258
- const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
555
+ void publish().catch(error => {
556
+ nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
557
+ })
558
+ const timer = setInterval(() => {
559
+ void publish().catch(error => {
560
+ nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
561
+ })
562
+ }, 5 * 60_000)
563
+ timer.unref?.()
259
564
  return () => {
260
565
  abortController.abort()
261
566
  clearInterval(timer)
262
567
  }
263
568
  },
264
569
  /**
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 信令载荷
570
+ * @param {string} toNodeHash 目标 nodeHash
571
+ * @param {Uint8Array} bytes 加密信令
283
572
  * @returns {Promise<void>}
284
573
  */
285
- async sendSignal(topic, to, bytes) {
574
+ async sendNodeSignal(toNodeHash, bytes) {
575
+ const hash = normalizeHex64(toNodeHash)
576
+ if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
577
+ const rendezvousKey = nodeRendezvousKey(hash)
286
578
  const event = await signNostrEvent(
287
579
  NOSTR_SIGNAL_KIND,
288
- [['t', topic], ['x', 'signal'], ['p', String(to || '')]],
580
+ [['t', rendezvousKey], ['x', 'signal'], ['p', hash]],
289
581
  bytesToBase64(bytes),
290
582
  secretKey,
291
583
  )
292
- await publishEvent(relayUrls, event)
584
+ await publishEvent(resolveRelayUrls(), event)
293
585
  },
294
586
  /**
295
- * 订阅中继上的 signal 事件(并行渐进连中继,立即返回 cleanup)。
296
- * @param {string} topic 信令 topic
297
- * @param {Function} onSignal 信令回调
298
- * @returns {Promise<() => void>} 取消订阅函数
587
+ * @param {string} localNodeHash 本机 nodeHash
588
+ * @param {(bytes: Uint8Array) => void} onSignal 信令回调
589
+ * @returns {Promise<() => void>} 取消函数
299
590
  */
300
- async onSignal(topic, onSignal) {
301
- return subscribeNostrKind(relayUrls, {
591
+ async listenNodeSignals(localNodeHash, onSignal) {
592
+ const hash = normalizeHex64(localNodeHash)
593
+ if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
594
+ const rendezvousKey = nodeRendezvousKey(hash)
595
+ const existing = nodeSignalSubs.get(hash)
596
+ if (existing) existing()
597
+ nodeDebug('p2p:nostr signal listen', { self: shortHash(hash), relays: resolveRelayUrls().length })
598
+ const stop = subscribeNostrKind(resolveRelayUrls(), {
302
599
  kind: NOSTR_SIGNAL_KIND,
303
- topic,
600
+ rendezvousKey,
304
601
  tagX: 'signal',
305
602
  onPayload: onSignal,
306
603
  })
604
+ nodeSignalSubs.set(hash, stop)
605
+ return () => {
606
+ stop()
607
+ nodeSignalSubs.delete(hash)
608
+ }
609
+ },
610
+ /**
611
+ * @param {string} nodeHash 目标 nodeHash
612
+ * @param {(bytes: Uint8Array, meta: object) => void} onAdvert advert 回调
613
+ * @returns {Promise<() => void>} 取消函数
614
+ */
615
+ async watchNodeAdvert(nodeHash, onAdvert) {
616
+ const hash = normalizeHex64(nodeHash)
617
+ 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
632
+ },
633
+ /**
634
+ * @param {string} roomSecret 房间密钥
635
+ * @param {() => Promise<object | null>} getBeacon advert 工厂
636
+ * @returns {Promise<() => void>} 停止群 presence 广播
637
+ */
638
+ async startGroupPresence(roomSecret, getBeacon) {
639
+ const key = String(roomSecret || '')
640
+ const rendezvousKey = groupRendezvousKey(key)
641
+ const abortController = new AbortController()
642
+ ensureGroupSubscription(key)
643
+ /**
644
+ * @returns {Promise<void>}
645
+ */
646
+ const publish = async () => {
647
+ if (abortController.signal.aborted) return
648
+ const beacon = await getBeacon?.()
649
+ if (!beacon?.nodeHash) return
650
+ const advertBody = beacon.advertBody || beacon.body || beacon
651
+ const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
652
+ const event = await signNostrEvent(
653
+ NOSTR_ADVERT_KIND,
654
+ [['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
655
+ bytesToBase64(bytes),
656
+ secretKey,
657
+ )
658
+ await publishEvent(resolveRelayUrls(), event, abortController.signal)
659
+ }
660
+ void publish().catch(() => { })
661
+ const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
662
+ timer.unref?.()
663
+ return () => {
664
+ abortController.abort()
665
+ clearInterval(timer)
666
+ }
667
+ },
668
+ /**
669
+ * @param {string} roomSecret 房间密钥
670
+ * @param {(bytes: Uint8Array, meta: object) => void} onAdvert 回调
671
+ * @returns {Promise<() => void>} 取消群 advert 监听
672
+ */
673
+ 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
+ })
689
+ },
690
+ /**
691
+ * 供 advert 解析路径写入可见 hash。
692
+ * @param {string} nodeHash 节点 hash
693
+ * @param {{ roomSecret?: string }} [options] 带 roomSecret 时写入群池
694
+ * @returns {void}
695
+ */
696
+ noteVisibleNode(nodeHash, options = {}) {
697
+ if (options.roomSecret) noteNostrGroupVisibleNode(options.roomSecret, nodeHash)
698
+ else noteNostrVisibleNode(nodeHash)
699
+ },
700
+ /**
701
+ * 停止全部内部订阅(reload / unregister 时调用)。
702
+ * @returns {void}
703
+ */
704
+ 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()
713
+ for (const stop of nodeSignalSubs.values())
714
+ try { stop() } catch { /* ignore */ }
715
+ nodeSignalSubs.clear()
307
716
  },
308
717
  }
309
718
  }