@steve02081504/fount-p2p 0.0.11 → 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 (102) hide show
  1. package/README.md +42 -11
  2. package/core/bytes_codec.mjs +65 -10
  3. package/core/composite_key.mjs +5 -5
  4. package/core/tcp_port.mjs +1 -1
  5. package/crypto/checkpoint_sign.mjs +2 -2
  6. package/crypto/crypto.mjs +3 -3
  7. package/crypto/key.mjs +12 -12
  8. package/dag/canonicalize_row.mjs +3 -3
  9. package/dag/index.mjs +2 -3
  10. package/dag/storage.mjs +36 -36
  11. package/discovery/advert_peer_hints.mjs +1 -1
  12. package/discovery/adverts.mjs +109 -0
  13. package/discovery/bt/index.mjs +154 -113
  14. package/discovery/bt/probe_child.mjs +2 -1
  15. package/discovery/bt/runtime.mjs +5 -12
  16. package/discovery/index.mjs +267 -62
  17. package/discovery/internal/signal_crypto.mjs +109 -0
  18. package/discovery/lan.mjs +228 -0
  19. package/discovery/nostr.mjs +430 -141
  20. package/federation/chunk_fetch_pending.mjs +14 -18
  21. package/federation/dag_order_cache.mjs +1 -1
  22. package/federation/entity_key_chain.mjs +3 -4
  23. package/federation/manifest_fetch_pending.mjs +1 -3
  24. package/federation/topo_order_memo.mjs +11 -4
  25. package/files/assemble.mjs +14 -14
  26. package/files/assemble_stream.mjs +6 -6
  27. package/files/chunk_fetch.mjs +2 -2
  28. package/files/chunk_responder.mjs +29 -20
  29. package/files/evfs.mjs +31 -30
  30. package/files/manifest_fetch.mjs +16 -3
  31. package/files/public_manifest.mjs +11 -11
  32. package/files/transfer_key_registry.mjs +2 -2
  33. package/index.mjs +86 -11
  34. package/infra/cli.mjs +62 -0
  35. package/infra/debug_log.mjs +56 -0
  36. package/infra/default_node_dir.mjs +22 -0
  37. package/infra/priority.mjs +56 -0
  38. package/infra/service.mjs +140 -0
  39. package/infra/tunables.json +5 -0
  40. package/link/channel_mux.mjs +10 -10
  41. package/link/frame.mjs +76 -131
  42. package/link/handshake.mjs +19 -20
  43. package/link/pipe.mjs +53 -79
  44. package/link/providers/ble_gatt.mjs +19 -31
  45. package/link/providers/lan_tcp.mjs +17 -32
  46. package/link/providers/link_id_pipe.mjs +46 -0
  47. package/link/providers/webrtc.mjs +42 -51
  48. package/link/rtc.mjs +24 -12
  49. package/mailbox/consumer_registry.mjs +2 -2
  50. package/mailbox/deliver_or_store.mjs +6 -6
  51. package/mailbox/wire.mjs +28 -24
  52. package/node/entity_store.mjs +6 -6
  53. package/node/identity.mjs +4 -4
  54. package/node/instance.mjs +46 -27
  55. package/node/local_data_revision.mjs +26 -0
  56. package/node/log.mjs +56 -0
  57. package/node/network.mjs +46 -14
  58. package/node/reputation_store.mjs +4 -4
  59. package/node/reputation_sync.mjs +318 -0
  60. package/node/routing_profile.mjs +21 -0
  61. package/node/signaling_config.mjs +30 -11
  62. package/overlay/index.mjs +35 -14
  63. package/package.json +13 -2
  64. package/rooms/scoped_link.mjs +17 -182
  65. package/schemas/discovery.mjs +1 -2
  66. package/schemas/federation_pull.mjs +2 -2
  67. package/schemas/part_query.mjs +1 -1
  68. package/schemas/remote_event.mjs +1 -1
  69. package/transport/advert_ingest.mjs +17 -0
  70. package/transport/group_link_set.mjs +165 -134
  71. package/transport/ice_servers.mjs +2 -2
  72. package/transport/link_registry.mjs +283 -124
  73. package/transport/mesh_keepalive.mjs +217 -0
  74. package/transport/node_scope.mjs +289 -0
  75. package/transport/offer_answer.mjs +48 -47
  76. package/transport/peer_pool.mjs +169 -78
  77. package/transport/remote_user_room.mjs +18 -23
  78. package/transport/rtc_connection_budget.mjs +18 -4
  79. package/transport/{rtc_mdns_filter.mjs → rtc_ice_local_hostname.mjs} +13 -13
  80. package/transport/runtime_bootstrap.mjs +172 -104
  81. package/transport/tunables.json +11 -0
  82. package/transport/tunables.mjs +18 -0
  83. package/transport/user_room.mjs +93 -161
  84. package/trust_graph/build.mjs +0 -2
  85. package/trust_graph/cache.mjs +12 -3
  86. package/trust_graph/registry.mjs +6 -6
  87. package/trust_graph/send.mjs +1 -4
  88. package/utils/async_mutex.mjs +4 -4
  89. package/utils/emit_safe.mjs +11 -0
  90. package/utils/json_io.mjs +12 -12
  91. package/utils/map_pool.mjs +5 -5
  92. package/utils/shuffle.mjs +13 -0
  93. package/utils/ttl_map.mjs +29 -4
  94. package/wire/ingress.mjs +1 -1
  95. package/wire/part_ingress.mjs +38 -36
  96. package/wire/part_invoke.mjs +4 -4
  97. package/wire/part_query.mjs +28 -23
  98. package/wire/part_query.tunables.json +6 -1
  99. package/wire/part_query_cache.mjs +1 -1
  100. package/wire/volatile_signature.mjs +1 -1
  101. package/discovery/mdns.mjs +0 -191
  102. package/transport/signal_crypto.mjs +0 -82
@@ -3,7 +3,18 @@ import { randomBytes } from 'node:crypto'
3
3
  import { schnorr } from '@noble/curves/secp256k1.js'
4
4
  import WebSocket from 'ws'
5
5
 
6
+ import { base64ToBytes, hexToBytes, bytesToBase64, bytesToHex } from '../core/bytes_codec.mjs'
7
+ import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
6
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'
7
18
 
8
19
  /** 默认 Nostr 中继 URL 列表。 */
9
20
  export const DEFAULT_RELAY_URLS = [
@@ -22,9 +33,119 @@ export const NOSTR_ADVERT_KIND = 27235
22
33
  /** Nostr signal 事件 kind。 */
23
34
  export const NOSTR_SIGNAL_KIND = 27236
24
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
+
25
147
  /**
26
148
  * 关掉 WebSocket:已连上则先 close,grace 内未 CLOSED 再 terminate。
27
- * 连接中直接 terminate(无握手可优雅收尾)。
28
149
  * @param {import('ws').WebSocket} ws 连接
29
150
  * @returns {void}
30
151
  */
@@ -38,7 +159,6 @@ function dropWebSocket(ws) {
38
159
  if (ws.readyState !== WebSocket.CLOSED)
39
160
  try { ws.terminate() } catch { /* ignore */ }
40
161
  }, NOSTR_CLOSE_GRACE_MS)
41
- // Grace is polite to the peer; must not pin the process after shutdown.
42
162
  timer.unref()
43
163
  ws.once('close', () => clearTimeout(timer))
44
164
  try {
@@ -51,75 +171,26 @@ function dropWebSocket(ws) {
51
171
  }
52
172
 
53
173
  /**
54
- * 去重并清洗中继 URL 列表。
55
174
  * @param {string[] | undefined | null} urls 原始列表
56
175
  * @returns {string[]} 清洗后的列表
57
176
  */
58
177
  function dedupeRelayUrls(urls) {
59
178
  const seen = new Set()
60
- /** @type {string[]} */
61
- const out = []
62
- for (const url of Array.isArray(urls) ? urls : []) {
63
- const trimmed = String(url || '').trim()
64
- if (!trimmed || seen.has(trimmed)) continue
65
- seen.add(trimmed)
66
- out.push(trimmed)
67
- }
68
- return out
179
+ return (urls || [])
180
+ .map(url => url.trim())
181
+ .filter(trimmed => trimmed && !seen.has(trimmed) && (seen.add(trimmed), true))
69
182
  }
70
183
 
71
184
  /**
72
- * 合并默认与用户配置的中继 URL(去重)。
73
185
  * @param {string[] | undefined | null} userRelayUrls 用户自定义中继列表
74
186
  * @returns {string[]} 合并后的中继 URL 列表
75
187
  */
76
188
  export function mergeSignalingRelayUrls(userRelayUrls) {
77
- const merged = dedupeRelayUrls([...DEFAULT_RELAY_URLS, ...Array.isArray(userRelayUrls) ? userRelayUrls : []])
189
+ const merged = dedupeRelayUrls([...DEFAULT_RELAY_URLS, ...userRelayUrls || []])
78
190
  return merged.length ? merged : [...DEFAULT_RELAY_URLS]
79
191
  }
80
192
 
81
193
  /**
82
- * 字节数组转十六进制字符串。
83
- * @param {Uint8Array} bytes 输入字节
84
- * @returns {string} 小写 hex 字符串
85
- */
86
- function bytesToHex(bytes) {
87
- return [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join('')
88
- }
89
-
90
- /**
91
- * 十六进制字符串转字节数组。
92
- * @param {string} hex 输入 hex 字符串
93
- * @returns {Uint8Array} 解码后的字节
94
- */
95
- function hexToBytes(hex) {
96
- const normalized = String(hex || '').trim().toLowerCase()
97
- const out = new Uint8Array(Math.floor(normalized.length / 2))
98
- for (let index = 0; index < out.length; index++)
99
- out[index] = parseInt(normalized.slice(index * 2, index * 2 + 2), 16)
100
- return out
101
- }
102
-
103
- /**
104
- * 字节数组转 base64 字符串。
105
- * @param {Uint8Array} bytes 输入字节
106
- * @returns {string} base64 编码
107
- */
108
- function bytesToBase64(bytes) {
109
- return btoa(String.fromCharCode(...bytes))
110
- }
111
-
112
- /**
113
- * base64 字符串转字节数组。
114
- * @param {string} base64 输入 base64 字符串
115
- * @returns {Uint8Array} 解码后的字节
116
- */
117
- function base64ToBytes(base64) {
118
- return Uint8Array.from(atob(base64).split('').map(ch => ch.charCodeAt(0)))
119
- }
120
-
121
- /**
122
- * 签名 Nostr 事件。
123
194
  * @param {number} kind 事件 kind
124
195
  * @param {string[][]} tags 事件标签
125
196
  * @param {string} content 事件内容
@@ -136,7 +207,6 @@ async function signNostrEvent(kind, tags, content, secretKey) {
136
207
  }
137
208
 
138
209
  /**
139
- * 连接 Nostr 中继 WebSocket(`ws` 包,支持 terminate)。
140
210
  * @param {string} relayUrl 中继 URL
141
211
  * @param {number} [timeoutMs] 超时毫秒
142
212
  * @param {AbortSignal} [signal] 取消信号
@@ -183,13 +253,13 @@ function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
183
253
  }
184
254
 
185
255
  /**
186
- * 向多个中继并行发布 Nostr 事件(任一成功即可)。
187
256
  * @param {string[]} relayUrls 中继 URL 列表
188
257
  * @param {object} event 待发布事件
189
258
  * @param {AbortSignal} [signal] 取消信号
190
259
  * @returns {Promise<void>}
191
260
  */
192
261
  async function publishEvent(relayUrls, event, signal) {
262
+ if (!relayUrls.length) throw new Error('nostr: no relay')
193
263
  let published = false
194
264
  let lastError = null
195
265
  await Promise.allSettled(relayUrls.map(async relayUrl => {
@@ -212,7 +282,6 @@ async function publishEvent(relayUrls, event, signal) {
212
282
  }
213
283
 
214
284
  /**
215
- * 并行渐进连接中继:立刻返回,连上后回调;abort 后 stop 接入并 drop 已开 socket。
216
285
  * @param {string[]} relayUrls 中继列表
217
286
  * @param {(ws: import('ws').WebSocket, relayUrl: string) => void} onOpen 连上回调
218
287
  * @param {AbortSignal} signal 取消信号
@@ -227,129 +296,349 @@ function connectRelaysProgressive(relayUrls, onOpen, signal, sockets) {
227
296
  return
228
297
  }
229
298
  sockets.push(ws)
299
+ nodeDebug('p2p:nostr relay up', { url: relayUrl })
230
300
  onOpen(ws, relayUrl)
231
- }).catch(() => { /* 单路失败正常降级 */ })
301
+ }).catch(error => {
302
+ nodeDebug('p2p:nostr relay fail', {
303
+ url: relayUrl,
304
+ err: String(error?.message || error),
305
+ })
306
+ })
232
307
  }
233
308
 
234
309
  /**
235
- * 创建 Nostr discovery provider。
236
- * subscribe/onSignal/advertise 首调用不阻塞等公网中继;连上后渐进生效。
237
- * `options.relayUrls` 为最终列表(调用方 `mergeSignalingRelayUrls`,或直接传入 `relayOverride`)。
238
- * 省略 / `undefined` 默认公网中继;显式 `[]` 表示无中继(不再回填默认)。
239
- * @param {{ relayUrls?: string[] | null }} [options] 可选最终中继 URL 列表
240
- * @returns {import('./index.mjs').DiscoveryProvider} Nostr 发现提供者
310
+ * 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。
311
+ * @param {string[]} relayUrls 中继 URL 列表
312
+ * @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void> }} options 订阅选项
313
+ * @returns {() => void} 取消订阅
314
+ */
315
+ function subscribeNostrKind(relayUrls, options) {
316
+ const { kind, rendezvousKey, tagX, onPayload } = options
317
+ const abortController = new AbortController()
318
+ /** @type {import('ws').WebSocket[]} */
319
+ const sockets = []
320
+ 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)
337
+ return () => {
338
+ abortController.abort()
339
+ for (const ws of sockets) dropWebSocket(ws)
340
+ }
341
+ }
342
+
343
+ /**
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
241
347
  */
242
348
  export function createNostrDiscoveryProvider(options = {}) {
243
- const relayUrls = options.relayUrls != null
244
- ? dedupeRelayUrls(options.relayUrls)
245
- : [...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
+ }
246
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
+
247
413
  return {
248
414
  id: 'nostr',
249
415
  priority: 100,
250
416
  caps: { canDiscover: true, canSignal: true, canRelay: false },
251
417
  /**
252
- * 周期性向中继发布 advert 事件(首发后台,不阻塞 ensureRuntime)。
253
- * @param {string} topic advert 主题
254
- * @param {Uint8Array} bytes advert 载荷
255
- * @returns {Promise<() => void>} 取消广播函数
418
+ * @param {{ limit?: number, roomSecret?: string }} [options] 扫描选项
419
+ * @returns {Promise<string[]>} 可见 nodeHash;带 roomSecret 时仅返回该群池
256
420
  */
257
- async advertise(topic, bytes) {
258
- const ac = new AbortController()
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>} 是否已准备
434
+ */
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()
461
+ const abortController = new AbortController()
462
+ ensureNetworkAdvertSubscription()
259
463
  /**
260
- * 向中继发布当前 advert。
261
464
  * @returns {Promise<void>}
262
465
  */
263
466
  const publish = async () => {
264
- if (ac.signal.aborted) return
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 })
265
473
  const event = await signNostrEvent(
266
474
  NOSTR_ADVERT_KIND,
267
- [['t', topic], ['x', 'advert']],
475
+ [['t', rendezvousKey], ['x', 'advert']],
268
476
  bytesToBase64(bytes),
269
477
  secretKey,
270
478
  )
271
- await publishEvent(relayUrls, event, ac.signal)
272
- }
273
- void publish().catch(() => { })
274
- const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
275
- return () => {
276
- ac.abort()
277
- clearInterval(timer)
479
+ await publishEvent(resolveRelayUrls(), event, abortController.signal)
480
+ nodeDebug('p2p:nostr presence published', { self: shortHash(beacon.nodeHash) })
278
481
  }
279
- },
280
- /**
281
- * 订阅中继上的 advert 事件(并行渐进连中继,立即返回 cleanup)。
282
- * @param {string} topic advert 主题
283
- * @param {Function} onAdvert advert 回调
284
- * @returns {Promise<() => void>} 取消订阅函数
285
- */
286
- async subscribe(topic, onAdvert) {
287
- const ac = new AbortController()
288
- /** @type {import('ws').WebSocket[]} */
289
- const sockets = []
290
- const subscriptionId = randomBytes(8).toString('hex')
291
- connectRelaysProgressive(relayUrls, (ws, relayUrl) => {
292
- ws.on('message', data => {
293
- if (ac.signal.aborted) return
294
- let parsed
295
- try { parsed = JSON.parse(String(data)) } catch { return }
296
- if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return
297
- const nostrEvent = parsed[2]
298
- if (nostrEvent?.kind !== NOSTR_ADVERT_KIND) return
299
- try { onAdvert(base64ToBytes(String(nostrEvent.content || '')), { relayUrl, event: nostrEvent }) }
300
- catch { /* ignore */ }
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) })
301
488
  })
302
- ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [NOSTR_ADVERT_KIND], '#t': [topic], '#x': ['advert'] }]))
303
- }, ac.signal, sockets)
489
+ }, 5 * 60_000)
490
+ timer.unref?.()
304
491
  return () => {
305
- ac.abort()
306
- for (const ws of sockets) dropWebSocket(ws)
492
+ abortController.abort()
493
+ clearInterval(timer)
307
494
  }
308
495
  },
309
496
  /**
310
- * 向中继发布 signal 事件(按需发送,仍等待至少一路成功)。
311
- * @param {string} topic 信令 topic
312
- * @param {string} to 目标节点标识
313
- * @param {Uint8Array} bytes 信令载荷
497
+ * @param {string} toNodeHash 目标 nodeHash
498
+ * @param {Uint8Array} bytes 加密信令
314
499
  * @returns {Promise<void>}
315
500
  */
316
- 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)
317
505
  const event = await signNostrEvent(
318
506
  NOSTR_SIGNAL_KIND,
319
- [['t', topic], ['x', 'signal'], ['p', String(to || '')]],
507
+ [['t', rendezvousKey], ['x', 'signal'], ['p', hash]],
320
508
  bytesToBase64(bytes),
321
509
  secretKey,
322
510
  )
323
- await publishEvent(relayUrls, event)
511
+ await publishEvent(resolveRelayUrls(), event)
324
512
  },
325
513
  /**
326
- * 订阅中继上的 signal 事件(并行渐进连中继,立即返回 cleanup)。
327
- * @param {string} topic 信令 topic
328
- * @param {Function} onSignal 信令回调
329
- * @returns {Promise<() => void>} 取消订阅函数
514
+ * @param {string} localNodeHash 本机 nodeHash
515
+ * @param {(bytes: Uint8Array) => void} onSignal 信令回调
516
+ * @returns {Promise<() => void>} 取消函数
330
517
  */
331
- async onSignal(topic, onSignal) {
332
- const ac = new AbortController()
333
- /** @type {import('ws').WebSocket[]} */
334
- const sockets = []
335
- const subscriptionId = randomBytes(8).toString('hex')
336
- connectRelaysProgressive(relayUrls, (ws, relayUrl) => {
337
- ws.on('message', data => {
338
- if (ac.signal.aborted) return
339
- let parsed
340
- try { parsed = JSON.parse(String(data)) } catch { return }
341
- if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return
342
- const nostrEvent = parsed[2]
343
- if (nostrEvent?.kind !== NOSTR_SIGNAL_KIND) return
344
- try { onSignal(base64ToBytes(String(nostrEvent.content || '')), { relayUrl, event: nostrEvent }) }
345
- catch { /* ignore */ }
346
- })
347
- ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [NOSTR_SIGNAL_KIND], '#t': [topic], '#x': ['signal'] }]))
348
- }, ac.signal, sockets)
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(), {
526
+ kind: NOSTR_SIGNAL_KIND,
527
+ rendezvousKey,
528
+ tagX: 'signal',
529
+ onPayload: onSignal,
530
+ })
531
+ nodeSignalSubs.set(hash, stop)
349
532
  return () => {
350
- ac.abort()
351
- for (const ws of sockets) dropWebSocket(ws)
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)
352
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()
353
642
  },
354
643
  }
355
644
  }