@steve02081504/fount-p2p 0.0.35 → 0.0.37

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 (67) hide show
  1. package/AGENTS.md +2 -2
  2. package/core/entity_id.mjs +6 -6
  3. package/core/entity_id_parse.mjs +7 -7
  4. package/core/hexIds.mjs +6 -18
  5. package/crypto/checkpoint_sign.mjs +4 -4
  6. package/dag/canonicalize_row.mjs +4 -7
  7. package/discovery/adverts.mjs +17 -10
  8. package/discovery/bt/index.mjs +11 -11
  9. package/discovery/bt/peer_hints.mjs +4 -7
  10. package/discovery/index.mjs +8 -12
  11. package/discovery/internal/signal_crypto.mjs +1 -2
  12. package/discovery/lan.mjs +7 -9
  13. package/discovery/lan_peer_hints.mjs +5 -8
  14. package/discovery/nostr/census.mjs +281 -0
  15. package/discovery/nostr/census_math.mjs +54 -0
  16. package/discovery/nostr/constants.mjs +43 -0
  17. package/discovery/nostr/index.mjs +612 -0
  18. package/discovery/nostr/relays.mjs +1071 -0
  19. package/discovery/nostr/selection.mjs +224 -0
  20. package/discovery/nostr/session.mjs +584 -0
  21. package/discovery/peer_clue.mjs +1 -4
  22. package/docs/nostr_relay_discovery.md +92 -0
  23. package/federation/entity_key_chain.mjs +10 -9
  24. package/federation/message_rate_limit.mjs +1 -3
  25. package/files/manifest/fetch.mjs +4 -6
  26. package/files/manifest/normalize.mjs +2 -2
  27. package/files/manifest/public.mjs +5 -5
  28. package/governance/branch.mjs +1 -2
  29. package/governance/join_pow.mjs +2 -4
  30. package/governance/owner_succession_ballot.mjs +5 -5
  31. package/index.mjs +11 -4
  32. package/infra/priority.mjs +1 -2
  33. package/link/handshake.mjs +153 -41
  34. package/link/pipe.mjs +1 -2
  35. package/link/providers/ble_gatt.mjs +2 -3
  36. package/link/providers/lan_tcp.mjs +2 -3
  37. package/link/providers/link_id_pipe.mjs +3 -3
  38. package/link/providers/{nostr.mjs → nostr/index.mjs} +22 -17
  39. package/mailbox/deliver_or_store.mjs +11 -12
  40. package/mailbox/prune.mjs +3 -4
  41. package/mailbox/rate.mjs +3 -2
  42. package/mailbox/store.mjs +6 -9
  43. package/node/denylist.mjs +25 -28
  44. package/node/feature_config.mjs +30 -0
  45. package/node/identity.mjs +6 -6
  46. package/node/instance.mjs +21 -0
  47. package/node/network.mjs +12 -12
  48. package/node/personal_block.mjs +4 -4
  49. package/node/reputation_store.mjs +13 -14
  50. package/node/reputation_sync.mjs +9 -10
  51. package/node/storage.mjs +23 -0
  52. package/package.json +10 -4
  53. package/pages/app.mjs +172 -0
  54. package/pages/index.html +36 -0
  55. package/reputation/engine.mjs +13 -16
  56. package/schemas/discovery.mjs +5 -6
  57. package/schemas/federation_pull.mjs +7 -7
  58. package/schemas/mailbox.mjs +3 -3
  59. package/schemas/part_query.mjs +5 -5
  60. package/timeline/verify_remote.mjs +7 -7
  61. package/transport/link_registry.mjs +16 -19
  62. package/transport/mesh_keepalive.mjs +8 -11
  63. package/transport/offer_answer.mjs +3 -3
  64. package/transport/peer_health.mjs +12 -15
  65. package/transport/runtime_bootstrap.mjs +39 -3
  66. package/trust_graph/send.mjs +3 -3
  67. package/discovery/nostr.mjs +0 -1074
@@ -0,0 +1,612 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ import { schnorr } from '@noble/curves/secp256k1.js'
4
+
5
+ import { bytesToBase64, bytesToHex, hexToBytes } from '../../core/bytes_codec.mjs'
6
+ import { isHex64 } from '../../core/hexIds.mjs'
7
+ import { sha256Hex } from '../../crypto/crypto.mjs'
8
+ import { getNodeTransportSettings } from '../../node/identity.mjs'
9
+ import { getSignalingRuntimeConfig } from '../../node/instance.mjs'
10
+ import { nodeDebug, shortHash } from '../../node/log.mjs'
11
+ import { noteAdvertPeerHints } from '../advert_peer_hints.mjs'
12
+ import { ingestEncryptedAdvert } from '../adverts.mjs'
13
+ import {
14
+ encryptSignalPacket,
15
+ groupRendezvousKey,
16
+ networkRendezvousKey,
17
+ nodeRendezvousKey,
18
+ } from '../internal/signal_crypto.mjs'
19
+ import { noteDiscoveryPeerClue } from '../peer_clue.mjs'
20
+
21
+ import { createNostrCensus } from './census.mjs'
22
+ import {
23
+ DEFAULT_RELAY_URLS,
24
+ getListenRelays,
25
+ getPeerRoute,
26
+ getPoolByUrl,
27
+ isRelayDestinationAllowed,
28
+ probeRelay,
29
+ registerProviderTrustedRelayUrls,
30
+ resolveRelayConnectTarget,
31
+ setPeerRoute,
32
+ upsertRelay,
33
+ } from './relays.mjs'
34
+ import { routePublishEvent } from './selection.mjs'
35
+ import { dedupeRelayUrls, publishViaSharedRelay, subscribeNostrKind } from './session.mjs'
36
+
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
42
+
43
+ /** 打广告用的话题 tag(hashtag,NIP-01),公开可被搜索聚合。 */
44
+ const NOSTR_TOPIC_TAG = ['t', 'fount']
45
+
46
+ const ADVERT_TTL_MS = 10 * 60_000
47
+
48
+ /** @type {Map<string, number>} 网络域 nodeHash → lastSeenAt */
49
+ const visibleByHash = new Map()
50
+ /** @type {Map<string, Map<string, number>>} roomSecret → (nodeHash → lastSeenAt) */
51
+ const visibleByGroup = new Map()
52
+
53
+ /**
54
+ * @param {Map<string, number>} pool 可见池
55
+ * @param {number} now 当前时间
56
+ * @param {number} ttlMs TTL
57
+ * @returns {string[]} 未过期 nodeHash
58
+ */
59
+ function listPoolHashes(pool, now, ttlMs) {
60
+ /** @type {string[]} */
61
+ const out = []
62
+ for (const [hash, seenAt] of pool)
63
+ if (now - seenAt <= ttlMs) out.push(hash)
64
+ else pool.delete(hash)
65
+ return out
66
+ }
67
+
68
+ /**
69
+ * 写入网络域可见池(非群)。
70
+ * @param {string} nodeHash 节点 hash
71
+ * @param {number} [now=Date.now()] 当前时间
72
+ * @returns {void}
73
+ */
74
+ export function noteNostrVisibleNode(nodeHash, now = Date.now()) {
75
+ const hash = isHex64(nodeHash)
76
+ if (!hash) return
77
+ visibleByHash.set(hash, now)
78
+ }
79
+
80
+ /**
81
+ * 写入群域可见池(与网络域隔离)。
82
+ * @param {string} roomSecret 房间密钥
83
+ * @param {string} nodeHash 节点 hash
84
+ * @param {number} [now=Date.now()] 当前时间
85
+ * @returns {void}
86
+ */
87
+ export function noteNostrGroupVisibleNode(roomSecret, nodeHash, now = Date.now()) {
88
+ const hash = isHex64(nodeHash)
89
+ if (!roomSecret || !hash) return
90
+ let pool = visibleByGroup.get(roomSecret)
91
+ if (!pool) {
92
+ pool = new Map()
93
+ visibleByGroup.set(roomSecret, pool)
94
+ }
95
+ pool.set(hash, now)
96
+ }
97
+
98
+ /**
99
+ * @param {number} [now=Date.now()] 当前时间
100
+ * @param {number} [ttlMs=ADVERT_TTL_MS] TTL
101
+ * @returns {string[]} 网络域可见 nodeHash
102
+ */
103
+ export function listNostrVisibleNodeHashes(now = Date.now(), ttlMs = ADVERT_TTL_MS) {
104
+ return listPoolHashes(visibleByHash, now, ttlMs)
105
+ }
106
+
107
+ /**
108
+ * @param {string} roomSecret 房间密钥
109
+ * @param {number} [now=Date.now()] 当前时间
110
+ * @param {number} [ttlMs=ADVERT_TTL_MS] TTL
111
+ * @returns {string[]} 该群可见 nodeHash
112
+ */
113
+ export function listNostrGroupVisibleNodeHashes(roomSecret, now = Date.now(), ttlMs = ADVERT_TTL_MS) {
114
+ const pool = visibleByGroup.get(roomSecret)
115
+ if (!pool) return []
116
+ const out = listPoolHashes(pool, now, ttlMs)
117
+ if (!pool.size) visibleByGroup.delete(roomSecret)
118
+ return out
119
+ }
120
+
121
+ /**
122
+ * 捕获对端 advert 中的 relay 字段:listen 存 peerRoutes.listenRelays、pool 存 peerPool;
123
+ * 本机池中未见 url 以 source='peer' upsert 并后台 probe。
124
+ * 仅吸收/探测允许的公网地址(或本机显式配置/引导集中的中继),阻止远端 advert 驱动对 loopback/私网地址的 probe 与入池。
125
+ * @param {string} verifiedNodeHash 验签 nodeHash
126
+ * @param {string[]} listenRelays 规范化 listen
127
+ * @param {Array<{ url: string, rtt: number }>} relayPool 规范化 pool
128
+ * @returns {Promise<void>}
129
+ */
130
+ async function capturePeerRelayFields(verifiedNodeHash, listenRelays, relayPool) {
131
+ if (!isHex64(verifiedNodeHash)) return
132
+ setPeerRoute(verifiedNodeHash, {
133
+ listenRelays,
134
+ peerPool: relayPool,
135
+ })
136
+ for (const item of relayPool) {
137
+ if (getPoolByUrl().has(item.url)) continue
138
+ if (!await isRelayDestinationAllowed(item.url)) continue
139
+ upsertRelay({ url: item.url, rttMs: item.rtt, source: 'peer' })
140
+ void probeRelay(item.url).catch(() => { })
141
+ }
142
+ }
143
+
144
+ /**
145
+ * 解密并验签后写入 Nostr 可见池;捕获对端 relay 字段到 peerRoutes;伪造 body.nodeHash 无效。
146
+ * @param {string} rendezvousKey rendezvous 键
147
+ * @param {Uint8Array} bytes 加密 advert
148
+ * @param {{ roomSecret?: string, skipNodeHash?: string, meta?: object }} [options] 群池 / 本机回环过滤 / meta
149
+ * @returns {Promise<string | null>} 验签通过的 nodeHash
150
+ */
151
+ export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
152
+ const ingested = await ingestEncryptedAdvert(rendezvousKey, bytes)
153
+ if (!ingested) return null
154
+ const hash = ingested.verifiedNodeHash
155
+ if (isHex64(options.skipNodeHash) === hash) return hash
156
+ let firstSeen = true
157
+ if (options.roomSecret) {
158
+ firstSeen = !visibleByGroup.get(options.roomSecret)?.has(hash)
159
+ noteNostrGroupVisibleNode(options.roomSecret, hash)
160
+ }
161
+ else {
162
+ firstSeen = !visibleByHash.has(hash)
163
+ noteNostrVisibleNode(hash)
164
+ }
165
+ if (firstSeen) {
166
+ noteDiscoveryPeerClue(hash)
167
+ nodeDebug('p2p:nostr peer visible', { peer: shortHash(hash), group: !!options.roomSecret })
168
+ }
169
+ await capturePeerRelayFields(hash, ingested.listenRelays, ingested.relayPool)
170
+ noteAdvertPeerHints(hash, ingested.body, options.meta || {})
171
+ return hash
172
+ }
173
+
174
+ /** @returns {void} 测试用 */
175
+ export function clearNostrVisibleNodes() {
176
+ visibleByHash.clear()
177
+ visibleByGroup.clear()
178
+ }
179
+
180
+ /**
181
+ * 当前可用 nostr 中继:用户显式配置 `channels.nostr.relay` 优先;否则返回 listenRelays 工作子集。
182
+ * @returns {string[]} relay URL 列表
183
+ */
184
+ export function resolveNostrRelayUrls() {
185
+ const relay = getSignalingRuntimeConfig().channels?.nostr?.relay
186
+ if (Array.isArray(relay) && relay.length) return dedupeRelayUrls(relay)
187
+ const configRelay = getNodeTransportSettings().relayUrls
188
+ return configRelay.length ? dedupeRelayUrls(configRelay) : getListenRelays().map(entry => entry.url)
189
+ }
190
+
191
+ /**
192
+ * @param {number} kind 事件 kind
193
+ * @param {string[][]} tags 事件标签
194
+ * @param {string} content 事件内容
195
+ * @param {Uint8Array} secretKey Schnorr 私钥
196
+ * @returns {Promise<object>} 已签名的 Nostr 事件对象
197
+ */
198
+ async function signNostrEvent(kind, tags, content, secretKey) {
199
+ const pubkey = bytesToHex(schnorr.getPublicKey(secretKey))
200
+ const created_at = Math.floor(Date.now() / 1000)
201
+ const id = sha256Hex(JSON.stringify([0, pubkey, created_at, kind, tags, content]))
202
+ return { id, pubkey, created_at, kind, tags, content, sig: bytesToHex(await schnorr.sign(hexToBytes(id), secretKey)) }
203
+ }
204
+
205
+ /**
206
+ * 全量发布到给定 relay(任一成功即返回)。
207
+ * @param {string[]} relayUrls 中继 URL 列表
208
+ * @param {object} event 待发布事件
209
+ * @param {AbortSignal} [signal] 取消信号
210
+ * @returns {Promise<void>}
211
+ */
212
+ async function publishEvent(relayUrls, event, signal) {
213
+ const urls = dedupeRelayUrls(relayUrls)
214
+ if (!urls.length) throw new Error('nostr: no relay')
215
+ let published = false
216
+ let lastError = null
217
+ await Promise.allSettled(urls.map(async relayUrl => {
218
+ try {
219
+ const connectTarget = await resolveRelayConnectTarget(relayUrl)
220
+ if (!connectTarget) return
221
+ if (await publishViaSharedRelay(relayUrl, event, signal, connectTarget)) published = true
222
+ }
223
+ catch (error) {
224
+ lastError = error
225
+ }
226
+ }))
227
+ if (!published) throw lastError || new Error('nostr: no relay accepted publish')
228
+ }
229
+
230
+ /**
231
+ * 创建 Nostr discovery provider(list+connect;topic 仅内部)。
232
+ * @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined, localNodeHash?: string }} [options] 中继配置与本机 hash
233
+ * @returns {import('../index.mjs').DiscoveryProvider} Nostr discovery provider
234
+ */
235
+ export function createNostrDiscoveryProvider(options = {}) {
236
+ const hasExplicitRelay = options.relayUrls != null || !!options.getRelayUrls
237
+ const releaseProviderTrustedRelays = options.relayUrls != null
238
+ ? registerProviderTrustedRelayUrls(options.relayUrls)
239
+ : null
240
+ /** @returns {string[]} 去重后的中继 URL 列表 */
241
+ const resolveRelayUrls = () => {
242
+ if (options.getRelayUrls) return dedupeRelayUrls(options.getRelayUrls() ?? DEFAULT_RELAY_URLS)
243
+ return options.relayUrls == null ? resolveNostrRelayUrls() : dedupeRelayUrls(options.relayUrls)
244
+ }
245
+ const secretKey = randomBytes(32)
246
+ /** @type {string | null} */
247
+ let selfNodeHash = isHex64(options.localNodeHash)
248
+ const NETWORK_SUB_KEY = 'network'
249
+ /**
250
+ * @typedef {{ stop: () => void, held: boolean, listeners: Set<(bytes: Uint8Array, meta: object) => void> }} AdvertSubEntry
251
+ */
252
+ /** @type {Map<string, AdvertSubEntry>} */
253
+ const advertSubs = new Map()
254
+ /** @type {Map<string, () => void>} */
255
+ const nodeSignalSubs = new Map()
256
+
257
+ /**
258
+ * @param {string | undefined | null} nodeHash 本机 hash
259
+ * @returns {void}
260
+ */
261
+ function noteSelfNodeHash(nodeHash) {
262
+ const hash = isHex64(nodeHash)
263
+ if (hash) selfNodeHash = hash
264
+ }
265
+
266
+ /**
267
+ * @param {string} key 订阅键
268
+ * @param {AdvertSubEntry} entry 条目
269
+ * @returns {void}
270
+ */
271
+ function releaseAdvertEntryIfIdle(key, entry) {
272
+ if (entry.held || entry.listeners.size) return
273
+ try { entry.stop() } catch { /* ignore */ }
274
+ advertSubs.delete(key)
275
+ }
276
+
277
+ /**
278
+ * pool/connect 永久 hold;watch listener 归零且无 hold 时拆 REQ。
279
+ * @param {string} key 订阅键
280
+ * @param {{ rendezvousKey: string, roomSecret?: string }} bind advert 绑定
281
+ * @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外监听
282
+ * @returns {() => void} 取消 listener;无 listener 时 no-op(hold 至 dispose)
283
+ */
284
+ function ensureAdvertSubscription(key, bind, listener) {
285
+ let entry = advertSubs.get(key)
286
+ if (!entry) {
287
+ /** @type {AdvertSubEntry} */
288
+ const created = {
289
+ /**
290
+ * @returns {void}
291
+ */
292
+ stop: () => { }, held: false, listeners: new Set()
293
+ }
294
+ created.stop = subscribeNostrKind(resolveRelayUrls(), {
295
+ kind: NOSTR_ADVERT_KIND,
296
+ rendezvousKey: bind.rendezvousKey,
297
+ tagX: 'advert',
298
+ addressable: true,
299
+ resolveConnectTarget: resolveRelayConnectTarget,
300
+ /**
301
+ * @param {Uint8Array} bytes 加密 advert 载荷
302
+ * @param {object} meta relay 元数据
303
+ * @returns {Promise<void>}
304
+ */
305
+ async onPayload(bytes, meta) {
306
+ await acceptNostrAdvert(bind.rendezvousKey, bytes, {
307
+ roomSecret: bind.roomSecret,
308
+ skipNodeHash: selfNodeHash || undefined,
309
+ meta,
310
+ })
311
+ for (const fn of created.listeners)
312
+ try { fn(bytes, meta) } catch { /* ignore */ }
313
+ },
314
+ })
315
+ entry = created
316
+ advertSubs.set(key, entry)
317
+ }
318
+ if (!listener) {
319
+ entry.held = true
320
+ return () => { }
321
+ }
322
+ entry.listeners.add(listener)
323
+ return () => {
324
+ entry.listeners.delete(listener)
325
+ releaseAdvertEntryIfIdle(key, entry)
326
+ }
327
+ }
328
+
329
+ /**
330
+ * @returns {() => void} no-op(network hold 至 dispose)
331
+ */
332
+ function ensureNetworkAdvertSubscription() {
333
+ return ensureAdvertSubscription(NETWORK_SUB_KEY, {
334
+ rendezvousKey: networkRendezvousKey(),
335
+ })
336
+ }
337
+
338
+ /**
339
+ * @param {string} roomSecret 房间密钥
340
+ * @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
341
+ * @returns {() => void} 取消 listener
342
+ */
343
+ function ensureGroupSubscription(roomSecret, listener) {
344
+ return ensureAdvertSubscription('group:' + roomSecret, {
345
+ rendezvousKey: groupRendezvousKey(roomSecret),
346
+ roomSecret,
347
+ }, listener)
348
+ }
349
+
350
+ /**
351
+ * @param {string} nodeHash 目标
352
+ * @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
353
+ * @returns {() => void} 取消 listener
354
+ */
355
+ function ensureNodeAdvertSubscription(nodeHash, listener) {
356
+ const hash = isHex64(nodeHash)
357
+ if (!hash) return () => { }
358
+ return ensureAdvertSubscription('node:' + hash, {
359
+ rendezvousKey: nodeRendezvousKey(hash),
360
+ }, listener)
361
+ }
362
+
363
+ /** @type {Array<() => void>} */
364
+ const extraSubs = []
365
+
366
+ /**
367
+ * connectToNode 额外订阅对端历史/声称的中继以加速汇聚(adhoc,dispose 时释放)。
368
+ * 仅订阅允许连接的公网中继,拒绝解析到私网/本机未配置的目的地。
369
+ * @param {string} nodeHash 目标节点
370
+ * @returns {Promise<void>}
371
+ */
372
+ async function ensurePeerRelaySubscriptions(nodeHash) {
373
+ const hash = isHex64(nodeHash)
374
+ if (!hash) return
375
+ const route = getPeerRoute(hash)
376
+ const extra = dedupeRelayUrls([
377
+ ...route?.lastGoodNostrRelays || [],
378
+ ...route?.listenRelays || [],
379
+ ]).filter(url => !resolveRelayUrls().includes(url))
380
+ if (!extra.length) return
381
+ const entries = await Promise.all(extra.map(async url => ({ url, allowed: await isRelayDestinationAllowed(url) })))
382
+ const targets = entries.filter(entry => entry.allowed).map(entry => entry.url)
383
+ if (!targets.length) return
384
+ const rendezvousKey = nodeRendezvousKey(hash)
385
+ extraSubs.push(subscribeNostrKind(targets, {
386
+ kind: NOSTR_ADVERT_KIND,
387
+ rendezvousKey,
388
+ tagX: 'advert',
389
+ addressable: true,
390
+ resolveConnectTarget: resolveRelayConnectTarget,
391
+ /**
392
+ * 处理订阅收到的 advert 字节。
393
+ * @param {Uint8Array} bytes advert 字节
394
+ * @returns {Promise<string | null>} 接受后的节点 hash,拒绝时为 null
395
+ */
396
+ onPayload: bytes => acceptNostrAdvert(rendezvousKey, bytes, {
397
+ skipNodeHash: selfNodeHash || undefined,
398
+ }),
399
+ }))
400
+ }
401
+
402
+ return {
403
+ id: 'nostr',
404
+ priority: 100,
405
+ caps: { canDiscover: true, canSignal: true, canRelay: false },
406
+ /**
407
+ * @param {{ limit?: number, roomSecret?: string }} [options] 扫描选项
408
+ * @returns {Promise<string[]>} 可见 nodeHash;带 roomSecret 时仅返回该群池
409
+ */
410
+ async listVisibleNodeHashes(options = {}) {
411
+ const limit = Math.max(1, Number(options.limit) || 64)
412
+ if (options.roomSecret) {
413
+ ensureGroupSubscription(options.roomSecret)
414
+ return listNostrGroupVisibleNodeHashes(options.roomSecret)
415
+ .filter(hash => hash !== selfNodeHash)
416
+ .slice(0, limit)
417
+ }
418
+ ensureNetworkAdvertSubscription()
419
+ return listNostrVisibleNodeHashes()
420
+ .filter(hash => hash !== selfNodeHash)
421
+ .slice(0, limit)
422
+ },
423
+ /**
424
+ * 挂上对该节点的内部 advert 订阅(建链由 registry dialer / ensureLinkToNode 完成)。
425
+ * @param {string} nodeHash 目标
426
+ * @returns {Promise<boolean>} 是否已准备
427
+ */
428
+ async connectToNode(nodeHash) {
429
+ const hash = isHex64(nodeHash)
430
+ if (!hash) return false
431
+ ensureNodeAdvertSubscription(hash)
432
+ await ensurePeerRelaySubscriptions(hash)
433
+ return true
434
+ },
435
+ /**
436
+ * @param {() => Promise<object | null>} getBeacon 本机 advert body 工厂
437
+ * @returns {Promise<() => void>} 停止函数
438
+ */
439
+ async startPresence(getBeacon) {
440
+ const rendezvousKey = networkRendezvousKey()
441
+ const abortController = new AbortController()
442
+ ensureNetworkAdvertSubscription()
443
+ /**
444
+ * @returns {Promise<void>}
445
+ */
446
+ const publish = async () => {
447
+ if (abortController.signal.aborted) return
448
+ const beacon = await getBeacon?.()
449
+ if (!beacon?.nodeHash) return
450
+ noteSelfNodeHash(beacon.nodeHash)
451
+ const event = await signNostrEvent(
452
+ NOSTR_ADVERT_KIND,
453
+ [NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
454
+ bytesToBase64(encryptSignalPacket(rendezvousKey, { type: 'advert', body: beacon.advertBody || beacon.body || beacon })),
455
+ secretKey,
456
+ )
457
+ await publishEvent(resolveRelayUrls(), event, abortController.signal)
458
+ nodeDebug('p2p:nostr presence published', { self: shortHash(beacon.nodeHash) })
459
+ }
460
+ void publish().catch(error => {
461
+ nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
462
+ })
463
+ const timer = setInterval(() => {
464
+ void publish().catch(error => {
465
+ nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
466
+ })
467
+ }, 5 * 60_000)
468
+ timer.unref?.()
469
+ // census(人口统计):由 features.census 开关驱动,worker 内部每周期自检。
470
+ const census = createNostrCensus({
471
+ resolveRelayUrls,
472
+ publishEvent,
473
+ /**
474
+ * @param {number} kind 事件 kind
475
+ * @param {string[][]} tags 事件标签
476
+ * @param {string} content 事件内容
477
+ * @returns {Promise<object>} 已签名事件
478
+ */
479
+ signEvent: (kind, tags, content) => signNostrEvent(kind, tags, content, secretKey),
480
+ subscribeNostrKind,
481
+ })
482
+ census.start()
483
+ return () => {
484
+ abortController.abort()
485
+ clearInterval(timer)
486
+ census.stop()
487
+ }
488
+ },
489
+ /**
490
+ * @param {string} toNodeHash 目标 nodeHash
491
+ * @param {Uint8Array} bytes 加密信令
492
+ * @returns {Promise<void>}
493
+ */
494
+ async sendNodeSignal(toNodeHash, bytes) {
495
+ const hash = isHex64(toNodeHash)
496
+ if (!hash) throw new Error('nostr: invalid nodeHash')
497
+ const rendezvousKey = nodeRendezvousKey(hash)
498
+ const event = await signNostrEvent(
499
+ NOSTR_SIGNAL_KIND,
500
+ [NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'signal'], ['p', hash]],
501
+ bytesToBase64(bytes),
502
+ secretKey,
503
+ )
504
+ // 显式 relay 覆盖(测试/用户 pin)时直接全量发布;否则走握手路由。
505
+ if (hasExplicitRelay) {
506
+ await publishEvent(resolveRelayUrls(), event)
507
+ return
508
+ }
509
+ await routePublishEvent(hash, event)
510
+ },
511
+ /**
512
+ * @param {string} localNodeHash 本机 nodeHash
513
+ * @param {(bytes: Uint8Array) => void} onSignal 信令回调
514
+ * @returns {Promise<() => void>} 取消函数
515
+ */
516
+ async listenNodeSignals(localNodeHash, onSignal) {
517
+ const hash = isHex64(localNodeHash)
518
+ if (!hash) throw new Error('nostr: invalid nodeHash')
519
+ noteSelfNodeHash(hash)
520
+ const rendezvousKey = nodeRendezvousKey(hash)
521
+ const existing = nodeSignalSubs.get(hash)
522
+ if (existing) existing()
523
+ nodeDebug('p2p:nostr signal listen', { self: shortHash(hash), relays: resolveRelayUrls().length })
524
+ const stop = subscribeNostrKind(resolveRelayUrls(), {
525
+ kind: NOSTR_SIGNAL_KIND,
526
+ rendezvousKey,
527
+ tagX: 'signal',
528
+ resolveConnectTarget: resolveRelayConnectTarget,
529
+ onPayload: onSignal,
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 = isHex64(nodeHash)
544
+ if (!hash) throw new Error('nostr: invalid nodeHash')
545
+ return ensureNodeAdvertSubscription(hash, onAdvert)
546
+ },
547
+ /**
548
+ * @param {string} roomSecret 房间密钥
549
+ * @param {() => Promise<object | null>} getBeacon advert 工厂
550
+ * @returns {Promise<() => void>} 停止群 presence 广播
551
+ */
552
+ async startGroupPresence(roomSecret, getBeacon) {
553
+ const rendezvousKey = groupRendezvousKey(roomSecret)
554
+ const abortController = new AbortController()
555
+ ensureGroupSubscription(roomSecret)
556
+ /**
557
+ * @returns {Promise<void>}
558
+ */
559
+ const publish = async () => {
560
+ if (abortController.signal.aborted) return
561
+ const beacon = await getBeacon?.()
562
+ if (!beacon?.nodeHash) return
563
+ noteSelfNodeHash(beacon.nodeHash)
564
+ const event = await signNostrEvent(
565
+ NOSTR_ADVERT_KIND,
566
+ [NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
567
+ bytesToBase64(encryptSignalPacket(rendezvousKey, { type: 'advert', body: beacon.advertBody || beacon.body || beacon })),
568
+ secretKey,
569
+ )
570
+ await publishEvent(resolveRelayUrls(), event, abortController.signal)
571
+ }
572
+ void publish().catch(() => { })
573
+ const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
574
+ timer.unref?.()
575
+ return () => {
576
+ abortController.abort()
577
+ clearInterval(timer)
578
+ }
579
+ },
580
+ /**
581
+ * @param {string} roomSecret 房间密钥
582
+ * @param {(bytes: Uint8Array, meta: object) => void} onAdvert advert 回调
583
+ * @returns {Promise<() => void>} 取消群 advert 监听
584
+ */
585
+ async watchGroupAdverts(roomSecret, onAdvert) {
586
+ return ensureGroupSubscription(roomSecret, onAdvert)
587
+ },
588
+ /**
589
+ * 供 advert 解析路径写入可见 hash。
590
+ * @param {string} nodeHash 节点 hash
591
+ * @param {{ roomSecret?: string }} [options] 带 roomSecret 时写入群池
592
+ * @returns {void}
593
+ */
594
+ noteVisibleNode(nodeHash, options = {}) {
595
+ if (options.roomSecret) noteNostrGroupVisibleNode(options.roomSecret, nodeHash)
596
+ else noteNostrVisibleNode(nodeHash)
597
+ },
598
+ /** @returns {void} 停止全部内部订阅 */
599
+ dispose() {
600
+ releaseProviderTrustedRelays?.()
601
+ for (const entry of advertSubs.values())
602
+ try { entry.stop() } catch { /* ignore */ }
603
+ advertSubs.clear()
604
+ for (const stop of nodeSignalSubs.values())
605
+ try { stop() } catch { /* ignore */ }
606
+ nodeSignalSubs.clear()
607
+ for (const stop of extraSubs)
608
+ try { stop() } catch { /* ignore */ }
609
+ extraSubs.length = 0
610
+ },
611
+ }
612
+ }