@steve02081504/fount-p2p 0.0.36 → 0.0.38

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 (38) hide show
  1. package/AGENTS.md +3 -3
  2. package/discovery/adverts.mjs +16 -8
  3. package/discovery/nostr/census.mjs +281 -0
  4. package/discovery/nostr/census_math.mjs +54 -0
  5. package/discovery/nostr/constants.mjs +43 -0
  6. package/discovery/nostr/index.mjs +612 -0
  7. package/discovery/nostr/relays.mjs +1071 -0
  8. package/discovery/nostr/selection.mjs +224 -0
  9. package/discovery/nostr/session.mjs +584 -0
  10. package/docs/evfs.md +15 -6
  11. package/docs/nostr_relay_discovery.md +92 -0
  12. package/files/chunk/responder.mjs +2 -2
  13. package/files/evfs.mjs +2 -2
  14. package/files/fed/fetch_shared.mjs +3 -1
  15. package/files/fed/responder.mjs +3 -2
  16. package/files/fetch_fanout.mjs +26 -5
  17. package/files/manifest/fetch.mjs +73 -38
  18. package/files/manifest/pending.mjs +21 -7
  19. package/files/manifest/servicer_registry.mjs +51 -0
  20. package/governance/branch.mjs +1 -1
  21. package/governance/join_pow.mjs +2 -2
  22. package/index.mjs +11 -4
  23. package/link/handshake.mjs +135 -23
  24. package/link/providers/link_id_pipe.mjs +1 -1
  25. package/link/providers/{nostr.mjs → nostr/index.mjs} +19 -14
  26. package/mailbox/deliver_or_store.mjs +3 -4
  27. package/mailbox/store.mjs +1 -1
  28. package/node/feature_config.mjs +30 -0
  29. package/node/instance.mjs +21 -0
  30. package/node/reputation_store.mjs +1 -2
  31. package/node/storage.mjs +23 -0
  32. package/package.json +10 -4
  33. package/pages/app.mjs +172 -0
  34. package/pages/index.html +36 -0
  35. package/reputation/engine.mjs +1 -2
  36. package/transport/runtime_bootstrap.mjs +39 -3
  37. package/utils/fetch_wait.mjs +12 -7
  38. package/discovery/nostr.mjs +0 -1073
@@ -1,1073 +0,0 @@
1
- import { randomBytes } from 'node:crypto'
2
-
3
- import { schnorr } from '@noble/curves/secp256k1.js'
4
- import WebSocket from 'ws'
5
-
6
- import { base64ToBytes, hexToBytes, bytesToBase64, bytesToHex } from '../core/bytes_codec.mjs'
7
- import { isHex64 } from '../core/hexIds.mjs'
8
- import { sha256Hex } from '../crypto/crypto.mjs'
9
- import { getNodeTransportSettings } from '../node/identity.mjs'
10
- import { getSignalingRuntimeConfig } from '../node/instance.mjs'
11
- import { nodeDebug, shortHash } from '../node/log.mjs'
12
-
13
- import { noteAdvertPeerHints } from './advert_peer_hints.mjs'
14
- import { ingestEncryptedAdvert } from './adverts.mjs'
15
- import {
16
- encryptSignalPacket,
17
- groupRendezvousKey,
18
- networkRendezvousKey,
19
- nodeRendezvousKey,
20
- } from './internal/signal_crypto.mjs'
21
- import { noteDiscoveryPeerClue } from './peer_clue.mjs'
22
-
23
- /** 默认 Nostr 中继 URL 列表。 */
24
- export const DEFAULT_RELAY_URLS = [
25
- 'wss://relay.damus.io',
26
- 'wss://nos.lol',
27
- 'wss://relay.nostr.band',
28
- 'wss://relay.nostr.com',
29
- 'wss://nostr.bitcoiner.social',
30
- 'wss://nostr.mom',
31
- 'wss://relay.snort.social',
32
- ]
33
-
34
- /** 单中继 WebSocket 首连超时(短超时 + 并行,避免串行 10s×N)。 */
35
- export const NOSTR_CONNECT_TIMEOUT_MS = 2_000
36
- /** 先 close,超时未 CLOSED 再 terminate(给对端优雅关闭的窗口)。 */
37
- export const NOSTR_CLOSE_GRACE_MS = 1_000
38
-
39
- /** 单 relay 等待 EVENT OK 回执超时。 */
40
- export const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
41
- /** 共享 relay 会话断线后重连间隔。 */
42
- export const NOSTR_RECONNECT_DELAY_MS = 500
43
- /** 无 sub/publish 工作时共享 relay 空闲回收延迟(给连续 send 复用窗口)。 */
44
- export const NOSTR_IDLE_DROP_MS = 2_000
45
-
46
- /** Nostr network advert 事件 kind(addressable,可存储)。 */
47
- export const NOSTR_ADVERT_KIND = 30787
48
- /** Nostr signal 事件 kind(ephemeral,实时转发)。 */
49
- export const NOSTR_SIGNAL_KIND = 20787
50
-
51
- /** 打广告用的话题 tag(hashtag,NIP-01),公开可被搜索聚合。 */
52
- const NOSTR_TOPIC_TAG = ['t', 'fount']
53
-
54
- const ADVERT_TTL_MS = 10 * 60_000
55
-
56
- /** @type {Map<string, number>} 网络域 nodeHash → lastSeenAt */
57
- const visibleByHash = new Map()
58
- /** @type {Map<string, Map<string, number>>} roomSecret → (nodeHash → lastSeenAt) */
59
- const visibleByGroup = new Map()
60
-
61
- /**
62
- * @param {Map<string, number>} pool 可见池
63
- * @param {number} now 当前时间
64
- * @param {number} ttlMs TTL
65
- * @returns {string[]} 未过期 nodeHash
66
- */
67
- function listPoolHashes(pool, now, ttlMs) {
68
- /** @type {string[]} */
69
- const out = []
70
- for (const [hash, seenAt] of pool)
71
- if (now - seenAt <= ttlMs) out.push(hash)
72
- else pool.delete(hash)
73
- return out
74
- }
75
-
76
- /**
77
- * 写入网络域可见池(非群)。
78
- * @param {string} nodeHash 节点 hash
79
- * @param {number} [now=Date.now()] 当前时间
80
- * @returns {void}
81
- */
82
- export function noteNostrVisibleNode(nodeHash, now = Date.now()) {
83
- const hash = isHex64(nodeHash)
84
- if (!hash) return
85
- visibleByHash.set(hash, now)
86
- }
87
-
88
- /**
89
- * 写入群域可见池(与网络域隔离)。
90
- * @param {string} roomSecret 房间密钥
91
- * @param {string} nodeHash 节点 hash
92
- * @param {number} [now=Date.now()] 当前时间
93
- * @returns {void}
94
- */
95
- export function noteNostrGroupVisibleNode(roomSecret, nodeHash, now = Date.now()) {
96
- const hash = isHex64(nodeHash)
97
- if (!roomSecret || !hash) return
98
- let pool = visibleByGroup.get(roomSecret)
99
- if (!pool) {
100
- pool = new Map()
101
- visibleByGroup.set(roomSecret, pool)
102
- }
103
- pool.set(hash, now)
104
- }
105
-
106
- /**
107
- * @param {number} [now=Date.now()] 当前时间
108
- * @param {number} [ttlMs=ADVERT_TTL_MS] TTL
109
- * @returns {string[]} 网络域可见 nodeHash
110
- */
111
- export function listNostrVisibleNodeHashes(now = Date.now(), ttlMs = ADVERT_TTL_MS) {
112
- return listPoolHashes(visibleByHash, now, ttlMs)
113
- }
114
-
115
- /**
116
- * @param {string} roomSecret 房间密钥
117
- * @param {number} [now=Date.now()] 当前时间
118
- * @param {number} [ttlMs=ADVERT_TTL_MS] TTL
119
- * @returns {string[]} 该群可见 nodeHash
120
- */
121
- export function listNostrGroupVisibleNodeHashes(roomSecret, now = Date.now(), ttlMs = ADVERT_TTL_MS) {
122
- const pool = visibleByGroup.get(roomSecret)
123
- if (!pool) return []
124
- const out = listPoolHashes(pool, now, ttlMs)
125
- if (!pool.size) visibleByGroup.delete(roomSecret)
126
- return out
127
- }
128
-
129
- /**
130
- * 解密并验签后写入 Nostr 可见池;伪造 body.nodeHash 无效。
131
- * @param {string} rendezvousKey rendezvous 键
132
- * @param {Uint8Array} bytes 加密 advert
133
- * @param {{ roomSecret?: string, skipNodeHash?: string, meta?: object }} [options] 群池 / 本机回环过滤 / meta
134
- * @returns {Promise<string | null>} 验签通过的 nodeHash
135
- */
136
- export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
137
- const ingested = await ingestEncryptedAdvert(rendezvousKey, bytes)
138
- if (!ingested) return null
139
- const hash = ingested.verifiedNodeHash
140
- const skipHash = isHex64(options.skipNodeHash)
141
- if (skipHash && hash === skipHash) return hash
142
- const { roomSecret } = options
143
- let firstSeen = true
144
- if (roomSecret) {
145
- firstSeen = !visibleByGroup.get(roomSecret)?.has(hash)
146
- noteNostrGroupVisibleNode(roomSecret, hash)
147
- }
148
- else {
149
- firstSeen = !visibleByHash.has(hash)
150
- noteNostrVisibleNode(hash)
151
- }
152
- if (firstSeen) {
153
- noteDiscoveryPeerClue(hash)
154
- nodeDebug('p2p:nostr peer visible', { peer: shortHash(hash), group: !!roomSecret })
155
- }
156
- noteAdvertPeerHints(hash, ingested.body, options.meta || {})
157
- return hash
158
- }
159
-
160
- /** @returns {void} 测试用 */
161
- export function clearNostrVisibleNodes() {
162
- visibleByHash.clear()
163
- visibleByGroup.clear()
164
- }
165
-
166
- /**
167
- * 关掉 WebSocket:已连上则先 close,grace 内未 CLOSED 再 terminate。
168
- * @param {import('ws').WebSocket} ws 连接
169
- * @returns {void}
170
- */
171
- function dropWebSocket(ws) {
172
- if (ws.readyState === WebSocket.CLOSED) return
173
- if (ws.readyState === WebSocket.CONNECTING) {
174
- try { ws.terminate() } catch { /* ignore */ }
175
- return
176
- }
177
- const timer = setTimeout(() => {
178
- if (ws.readyState !== WebSocket.CLOSED)
179
- try { ws.terminate() } catch { /* ignore */ }
180
- }, NOSTR_CLOSE_GRACE_MS)
181
- timer.unref()
182
- ws.once('close', () => clearTimeout(timer))
183
- try {
184
- ws.close()
185
- }
186
- catch {
187
- clearTimeout(timer)
188
- try { ws.terminate() } catch { /* ignore */ }
189
- }
190
- }
191
-
192
- /**
193
- * @param {string[] | undefined | null} urls 原始列表
194
- * @returns {string[]} 清洗后的列表
195
- */
196
- function dedupeRelayUrls(urls) {
197
- const seen = new Set()
198
- return (urls || [])
199
- .map(url => String(url || ''))
200
- .filter(url => url && !seen.has(url) && (seen.add(url), true))
201
- }
202
-
203
- /**
204
- * @param {string[] | undefined | null} userRelayUrls 用户自定义中继列表
205
- * @returns {string[]} 合并后的中继 URL 列表
206
- */
207
- export function mergeSignalingRelayUrls(userRelayUrls) {
208
- const merged = dedupeRelayUrls([...DEFAULT_RELAY_URLS, ...userRelayUrls || []])
209
- return merged.length ? merged : [...DEFAULT_RELAY_URLS]
210
- }
211
-
212
- /**
213
- * 当前可用 nostr 中继:nostr 通道配置 relay(替换默认),否则节点默认 + 公共中继。
214
- * @returns {string[]} relay URL 列表
215
- */
216
- export function resolveNostrRelayUrls() {
217
- const relay = getSignalingRuntimeConfig().channels?.nostr?.relay
218
- return relay?.length ? relay : mergeSignalingRelayUrls(getNodeTransportSettings().relayUrls)
219
- }
220
-
221
- /**
222
- * @param {number} kind 事件 kind
223
- * @param {string[][]} tags 事件标签
224
- * @param {string} content 事件内容
225
- * @param {Uint8Array} secretKey Schnorr 私钥
226
- * @returns {Promise<object>} 已签名的 Nostr 事件对象
227
- */
228
- async function signNostrEvent(kind, tags, content, secretKey) {
229
- const pubkey = bytesToHex(schnorr.getPublicKey(secretKey))
230
- const created_at = Math.floor(Date.now() / 1000)
231
- const serialized = JSON.stringify([0, pubkey, created_at, kind, tags, content])
232
- const id = sha256Hex(serialized)
233
- const sig = bytesToHex(await schnorr.sign(hexToBytes(id), secretKey))
234
- return { id, pubkey, created_at, kind, tags, content, sig }
235
- }
236
-
237
- /**
238
- * @param {string} relayUrl 中继 URL
239
- * @param {number} [timeoutMs] 超时毫秒
240
- * @param {AbortSignal} [signal] 取消信号
241
- * @returns {Promise<import('ws').WebSocket>} 已打开的 WebSocket
242
- */
243
- function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
244
- return new Promise((resolve, reject) => {
245
- if (signal?.aborted) {
246
- reject(new Error('nostr: aborted'))
247
- return
248
- }
249
- const ws = new WebSocket(relayUrl)
250
- let settled = false
251
- /**
252
- * @param {Error} error 失败原因
253
- * @returns {void}
254
- */
255
- const fail = error => {
256
- if (settled) return
257
- settled = true
258
- clearTimeout(timer)
259
- signal?.removeEventListener('abort', onAbort)
260
- dropWebSocket(ws)
261
- reject(error)
262
- }
263
- /**
264
- * 收到 abort 信号时失败
265
- * @returns {void}
266
- */
267
- const onAbort = () => fail(new Error('nostr: aborted'))
268
- const timer = setTimeout(() => fail(new Error(`nostr: connect timeout for ${relayUrl}`)), timeoutMs)
269
- timer.unref()
270
- signal?.addEventListener('abort', onAbort, { once: true })
271
- ws.once('open', () => {
272
- if (settled) return
273
- settled = true
274
- clearTimeout(timer)
275
- signal?.removeEventListener('abort', onAbort)
276
- resolve(ws)
277
- })
278
- ws.once('error', () => {
279
- fail(new Error(`nostr: websocket error for ${relayUrl}`))
280
- })
281
- })
282
- }
283
-
284
- /**
285
- * @param {import('ws').WebSocket} ws 已连接 relay
286
- * @param {string} relayUrl 中继 URL
287
- * @param {object} event 待发布事件
288
- * @param {AbortSignal} [signal] 取消信号
289
- * @param {() => boolean} [isCurrent] 该尝试是否仍是 publishRequest 的当前尝试(断线重发后过期)
290
- * @returns {Promise<boolean>} relay 是否接受 EVENT
291
- */
292
- function publishEventOnRelay(ws, relayUrl, event, signal, isCurrent) {
293
- return new Promise((resolve, reject) => {
294
- if (signal?.aborted) {
295
- reject(new Error('nostr: aborted'))
296
- return
297
- }
298
- let settled = false
299
- /**
300
- * @param {boolean} ok relay 是否接受
301
- * @param {Error | null} [error] 失败原因
302
- * @returns {void}
303
- */
304
- const finish = (ok, error = null) => {
305
- if (settled) return
306
- settled = true
307
- clearTimeout(timer)
308
- signal?.removeEventListener('abort', onAbort)
309
- ws.off('message', onMessage)
310
- // 过期的 in-flight 尝试:只清理自身监听器/定时器,不结算,避免影响重发后的当前尝试。
311
- if (!isCurrent()) return
312
- if (error) reject(error)
313
- else resolve(ok)
314
- }
315
- /**
316
- * 收到 abort 信号时以失败结束
317
- * @returns {void}
318
- */
319
- const onAbort = () => finish(false, new Error('nostr: aborted'))
320
- /**
321
- * @param {import('ws').RawData} data relay 消息
322
- * @returns {void}
323
- */
324
- const onMessage = data => {
325
- let parsed
326
- try { parsed = JSON.parse(String(data)) } catch { return }
327
- if (parsed?.[0] !== 'OK' || parsed[1] !== event.id) return
328
- const accepted = parsed[2] === true
329
- if (!accepted)
330
- nodeDebug('p2p:nostr publish rejected', {
331
- url: relayUrl,
332
- reason: String(parsed[3] || 'rejected'),
333
- })
334
- finish(accepted)
335
- }
336
- const timer = setTimeout(
337
- () => finish(false, new Error(`nostr: publish ok timeout for ${relayUrl}`)),
338
- NOSTR_PUBLISH_OK_TIMEOUT_MS,
339
- )
340
- timer.unref()
341
- signal?.addEventListener('abort', onAbort, { once: true })
342
- ws.on('message', onMessage)
343
- try {
344
- ws.send(JSON.stringify(['EVENT', event]))
345
- }
346
- catch (error) {
347
- finish(false, error instanceof Error ? error : new Error(String(error)))
348
- }
349
- })
350
- }
351
-
352
- /**
353
- * @param {string[]} relayUrls 中继 URL 列表
354
- * @param {object} event 待发布事件
355
- * @param {AbortSignal} [signal] 取消信号
356
- * @returns {Promise<void>}
357
- */
358
- async function publishEvent(relayUrls, event, signal) {
359
- const urls = dedupeRelayUrls(relayUrls)
360
- if (!urls.length) throw new Error('nostr: no relay')
361
- let published = false
362
- let lastError = null
363
- await Promise.allSettled(urls.map(async relayUrl => {
364
- try {
365
- if (await publishViaSharedRelay(relayUrl, event, signal)) published = true
366
- }
367
- catch (error) {
368
- lastError = error
369
- }
370
- }))
371
- if (!published) throw lastError || new Error('nostr: no relay accepted publish')
372
- }
373
-
374
- /**
375
- * 一次共享 relay publish 请求:入队(pending)与已派发(inflight)时共用同一结构。
376
- * attempt 标识每次实际发送尝试,断线重发会递增;旧的 in-flight 尝试据此自检为过期后自我清理,
377
- * 不再结算当前尝试。
378
- * @typedef {{
379
- * event: object,
380
- * signal: AbortSignal | undefined,
381
- * attempt: number,
382
- * onAbort: (() => void) | null,
383
- * removeAbort: (() => void) | null,
384
- * resolve: (ok: boolean) => void,
385
- * reject: (err: Error) => void,
386
- * }} NostrPublishRequest
387
- */
388
-
389
- /**
390
- * 共享 relay 会话:多 SUB / publish 复用同一 URL 的 WebSocket,避免 signal/presence/advert 各建一池、
391
- * 以及每次 send 重开一条连接(内存泄漏)。
392
- * 仍有活跃 sub 或待发 publish 时断线会自动重连并重发 REQ / 重发 EVENT。
393
- * @typedef {{
394
- * ws: import('ws').WebSocket | null,
395
- * connecting: boolean,
396
- * reconnectTimer: ReturnType<typeof setTimeout> | null,
397
- * idleTimer: ReturnType<typeof setTimeout> | null,
398
- * subs: Map<string, { filter: object, onEvent: (event: object, relayUrl: string) => void }>,
399
- * pendingPublishes: Array<NostrPublishRequest>,
400
- * inflightPublishes: Array<NostrPublishRequest>,
401
- * }} SharedRelaySession
402
- */
403
-
404
- /** @type {Map<string, SharedRelaySession>} */
405
- const sharedRelaySessions = new Map()
406
-
407
- /**
408
- * 会话是否仍有需要连接的工作(sub 或待发 publish)。
409
- * @param {SharedRelaySession} session 会话
410
- * @returns {boolean} 是否有活跃工作
411
- */
412
- function hasPendingWork(session) {
413
- return session.subs.size || session.pendingPublishes.length || session.inflightPublishes.length
414
- }
415
-
416
- /**
417
- * 取消会话的空闲回收定时器。
418
- * @param {SharedRelaySession} session 会话
419
- * @returns {void}
420
- */
421
- function clearIdleDrop(session) {
422
- if (!session.idleTimer) return
423
- clearTimeout(session.idleTimer)
424
- session.idleTimer = null
425
- }
426
-
427
- /**
428
- * 会话无 sub 时,待 publish 清空后延迟回收空闲 socket(给连续 send 复用窗口)。
429
- * @param {string} relayUrl 中继 URL
430
- * @param {SharedRelaySession} session 会话
431
- * @returns {void}
432
- */
433
- function scheduleIdleDrop(relayUrl, session) {
434
- if (session.subs.size || session.idleTimer) return
435
- session.idleTimer = setTimeout(() => {
436
- session.idleTimer = null
437
- if (!isLiveSharedSession(relayUrl, session) || hasPendingWork(session)) return
438
- sharedRelaySessions.delete(relayUrl)
439
- clearSharedRelayReconnect(session)
440
- if (session.ws) dropWebSocket(session.ws)
441
- session.ws = null
442
- }, NOSTR_IDLE_DROP_MS)
443
- session.idleTimer.unref?.()
444
- }
445
-
446
- /**
447
- * @param {string} relayUrl 中继 URL
448
- * @param {SharedRelaySession} session 会话
449
- * @returns {boolean} session 是否仍是该 URL 的活动会话
450
- */
451
- function isLiveSharedSession(relayUrl, session) {
452
- return sharedRelaySessions.get(relayUrl) === session
453
- }
454
-
455
- /**
456
- * @param {SharedRelaySession} session 会话
457
- * @returns {void}
458
- */
459
- function clearSharedRelayReconnect(session) {
460
- if (!session.reconnectTimer) return
461
- clearTimeout(session.reconnectTimer)
462
- session.reconnectTimer = null
463
- }
464
-
465
- /**
466
- * @param {string} relayUrl 中继 URL
467
- * @param {SharedRelaySession} session 会话
468
- * @param {import('ws').WebSocket} ws 已打开连接
469
- * @returns {void}
470
- */
471
- function attachSharedRelaySocket(relayUrl, session, ws) {
472
- session.ws = ws
473
- nodeDebug('p2p:nostr relay up', { url: relayUrl })
474
- ws.on('message', data => {
475
- let parsed
476
- try { parsed = JSON.parse(String(data)) } catch { return }
477
- if (parsed?.[0] !== 'EVENT') return
478
- const subId = String(parsed[1] || '')
479
- const nostrEvent = parsed[2]
480
- const sub = session.subs.get(subId)
481
- if (!sub || !nostrEvent) return
482
- try { sub.onEvent(nostrEvent, relayUrl) } catch { /* ignore */ }
483
- })
484
- ws.once('close', () => {
485
- if (!isLiveSharedSession(relayUrl, session)) return
486
- session.ws = null
487
- // 尚未确认的 inflight publish 重新入队,重连后由 flushPendingPublishes 重发。
488
- if (session.inflightPublishes.length) {
489
- session.pendingPublishes.push(...session.inflightPublishes)
490
- for (const publishRequest of session.inflightPublishes)
491
- attachQueuedAbort(session, publishRequest)
492
- session.inflightPublishes = []
493
- }
494
- if (!hasPendingWork(session)) {
495
- sharedRelaySessions.delete(relayUrl)
496
- return
497
- }
498
- scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
499
- })
500
- for (const [subId, sub] of session.subs)
501
- try { ws.send(JSON.stringify(['REQ', subId, sub.filter])) } catch { /* ignore */ }
502
- flushPendingPublishes(relayUrl, session, ws)
503
- if (!hasPendingWork(session)) scheduleIdleDrop(relayUrl, session)
504
- }
505
-
506
- /**
507
- * @param {string} relayUrl 中继 URL
508
- * @param {SharedRelaySession} session 会话
509
- * @param {number} [delayMs=0] 延迟毫秒(断线重连用)
510
- * @returns {void}
511
- */
512
- function scheduleSharedRelayConnect(relayUrl, session, delayMs = 0) {
513
- if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
514
- clearSharedRelayReconnect(session)
515
- /** 启动/恢复共享会话连接 */
516
- const start = () => {
517
- session.reconnectTimer = null
518
- if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
519
- if (!hasPendingWork(session)) {
520
- sharedRelaySessions.delete(relayUrl)
521
- return
522
- }
523
- session.connecting = true
524
- void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS).then(ws => {
525
- session.connecting = false
526
- if (!isLiveSharedSession(relayUrl, session) || !hasPendingWork(session)) {
527
- sharedRelaySessions.delete(relayUrl)
528
- dropWebSocket(ws)
529
- return
530
- }
531
- attachSharedRelaySocket(relayUrl, session, ws)
532
- }).catch(error => {
533
- session.connecting = false
534
- if (!isLiveSharedSession(relayUrl, session)) return
535
- nodeDebug('p2p:nostr relay fail', {
536
- url: relayUrl,
537
- err: String(error?.message || error),
538
- })
539
- if (!hasPendingWork(session)) {
540
- sharedRelaySessions.delete(relayUrl)
541
- return
542
- }
543
- scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
544
- })
545
- }
546
- if (delayMs <= 0) {
547
- // 让同一 tick 内的 registerSharedRelaySub 先写入 subs,再决定是否连接。
548
- queueMicrotask(start)
549
- return
550
- }
551
- nodeDebug('p2p:nostr relay reconnect', { url: relayUrl, delayMs })
552
- session.reconnectTimer = setTimeout(start, delayMs)
553
- session.reconnectTimer.unref?.()
554
- }
555
-
556
- /**
557
- * @param {string} relayUrl 中继 URL
558
- * @returns {SharedRelaySession} 共享会话
559
- */
560
- function acquireSharedRelay(relayUrl) {
561
- const existing = sharedRelaySessions.get(relayUrl)
562
- if (existing) return existing
563
- /** @type {SharedRelaySession} */
564
- const session = {
565
- ws: null,
566
- connecting: false,
567
- reconnectTimer: null,
568
- idleTimer: null,
569
- subs: new Map(),
570
- pendingPublishes: [],
571
- inflightPublishes: [],
572
- }
573
- sharedRelaySessions.set(relayUrl, session)
574
- scheduleSharedRelayConnect(relayUrl, session)
575
- return session
576
- }
577
-
578
- /**
579
- * @param {string} relayUrl 中继 URL
580
- * @param {string} subscriptionId 订阅 id
581
- * @returns {void}
582
- */
583
- function releaseSharedRelaySub(relayUrl, subscriptionId) {
584
- const session = sharedRelaySessions.get(relayUrl)
585
- if (!session) return
586
- session.subs.delete(subscriptionId)
587
- const { ws } = session
588
- if (ws?.readyState === WebSocket.OPEN)
589
- try { ws.send(JSON.stringify(['CLOSE', subscriptionId])) } catch { /* ignore */ }
590
- if (hasPendingWork(session)) return
591
- sharedRelaySessions.delete(relayUrl)
592
- clearSharedRelayReconnect(session)
593
- clearIdleDrop(session)
594
- if (ws) dropWebSocket(ws)
595
- }
596
-
597
- /**
598
- * 在已打开的共享连接上登记 REQ;连接中则等 attach 时统一重放。
599
- * @param {SharedRelaySession} session 会话
600
- * @param {string} subscriptionId 订阅 id
601
- * @param {object} filter Nostr filter
602
- * @param {(event: object, relayUrl: string) => void} onEvent 事件回调
603
- * @returns {void}
604
- */
605
- function registerSharedRelaySub(session, subscriptionId, filter, onEvent) {
606
- session.subs.set(subscriptionId, { filter, onEvent })
607
- clearIdleDrop(session)
608
- const { ws } = session
609
- if (ws?.readyState !== WebSocket.OPEN) return
610
- try { ws.send(JSON.stringify(['REQ', subscriptionId, filter])) } catch { /* ignore */ }
611
- }
612
-
613
- /**
614
- * 将已入队的 publish 逐个派发到已打开 socket 上;复用 publishEventOnRelay 等 OK 回执。
615
- * 每个 publish 独立携带自己的 message/abort 监听,socket 共享安全。
616
- * @param {string} relayUrl 中继 URL
617
- * @param {SharedRelaySession} session 会话
618
- * @param {import('ws').WebSocket} socket 已打开连接
619
- * @returns {void}
620
- */
621
- function flushPendingPublishes(relayUrl, session, socket) {
622
- const pending = session.pendingPublishes
623
- session.pendingPublishes = []
624
- for (const publishRequest of pending) {
625
- publishRequest.removeAbort?.()
626
- publishRequest.onAbort = null
627
- publishRequest.removeAbort = null
628
- const attempt = ++publishRequest.attempt
629
- session.inflightPublishes.push(publishRequest)
630
- void publishEventOnRelay(socket, relayUrl, publishRequest.event, publishRequest.signal, () => publishRequest.attempt === attempt)
631
- .then(
632
- ok => settleInflightPublish(relayUrl, session, publishRequest, attempt, () => publishRequest.resolve(ok)),
633
- error => settleInflightPublish(relayUrl, session, publishRequest, attempt, () => publishRequest.reject(error)),
634
- )
635
- }
636
- }
637
-
638
- /**
639
- * 为队列中的 publish 挂上 abort 处理:先移除监听器(释放 signal 对回调/event/session 的引用),
640
- * 再从队列删除并 reject。
641
- * @param {SharedRelaySession} session 会话
642
- * @param {SharedRelaySession['pendingPublishes'][number]} publishRequest 待发请求
643
- * @returns {void}
644
- */
645
- function attachQueuedAbort(session, publishRequest) {
646
- const { signal, reject } = publishRequest
647
- /**
648
- * 处理 abort 事件
649
- */
650
- publishRequest.onAbort = () => {
651
- publishRequest.removeAbort?.()
652
- const pendingIndex = session.pendingPublishes.indexOf(publishRequest)
653
- if (pendingIndex < 0) return
654
- session.pendingPublishes.splice(pendingIndex, 1)
655
- reject(new Error('nostr: aborted'))
656
- }
657
- /**
658
- * 移除 abort 监听器
659
- */
660
- publishRequest.removeAbort = () => {
661
- if (signal) signal.removeEventListener('abort', publishRequest.onAbort)
662
- }
663
- if (signal) signal.addEventListener('abort', publishRequest.onAbort, { once: true })
664
- }
665
-
666
- /**
667
- * publish 在 socket 上得到 OK / 失败 / abort 后结算:从 inflight 移除并 settle。
668
- * 仅结算仍与当前 attempt 匹配的请求:断线重发后 attempt 已递增,旧尝试的结算在此短路,
669
- * 避免提前结算重发后的当前尝试。已被 requeue(断线重发)的请求不在 inflight,跳过结算。
670
- * @param {string} relayUrl 中继 URL
671
- * @param {SharedRelaySession} session 会话
672
- * @param {SharedRelaySession['inflightPublishes'][number]} publishRequest 已发送请求
673
- * @param {number} attempt 本次发送的 attempt 标识
674
- * @param {() => void} settle 结算回调(resolve/reject)
675
- * @returns {void}
676
- */
677
- function settleInflightPublish(relayUrl, session, publishRequest, attempt, settle) {
678
- if (publishRequest.attempt !== attempt) return
679
- const inflightIndex = session.inflightPublishes.indexOf(publishRequest)
680
- if (inflightIndex < 0) return
681
- session.inflightPublishes.splice(inflightIndex, 1)
682
- settle()
683
- if (!hasPendingWork(session)) scheduleIdleDrop(relayUrl, session)
684
- }
685
-
686
- /**
687
- * 通过共享 relay 会话发布 EVENT:复用已打开 socket,避免每次 send 重开一条连接(内存泄漏)。
688
- * 无现成连接时入队并触发连接,连上后由 flushPendingPublishes 统一派发。
689
- * @param {string} relayUrl 中继 URL
690
- * @param {object} event 待发布事件
691
- * @param {AbortSignal} [signal] 取消信号
692
- * @returns {Promise<boolean>} relay 是否接受 EVENT
693
- */
694
- function publishViaSharedRelay(relayUrl, event, signal) {
695
- return new Promise((resolve, reject) => {
696
- if (signal?.aborted) {
697
- reject(new Error('nostr: aborted'))
698
- return
699
- }
700
- const session = acquireSharedRelay(relayUrl)
701
- clearIdleDrop(session)
702
- /** @type {SharedRelaySession['pendingPublishes'][number]} */
703
- const publishRequest = { event, signal, attempt: 0, resolve, reject, onAbort: null, removeAbort: null }
704
- attachQueuedAbort(session, publishRequest)
705
- session.pendingPublishes.push(publishRequest)
706
- if (session.ws?.readyState === WebSocket.OPEN)
707
- flushPendingPublishes(relayUrl, session, session.ws)
708
- else
709
- scheduleSharedRelayConnect(relayUrl, session)
710
- })
711
- }
712
-
713
- /**
714
- * 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。多订阅共享每 URL 一条连接。
715
- * @param {string[]} relayUrls 中继 URL 列表
716
- * @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void>, addressable?: boolean }} options 订阅选项
717
- * @returns {() => void} 取消订阅
718
- */
719
- function subscribeNostrKind(relayUrls, options) {
720
- const { kind, rendezvousKey, tagX, onPayload, addressable = false } = options
721
- const subscriptionId = randomBytes(8).toString('hex')
722
- const filter = { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }
723
- if (addressable) filter['#d'] = [rendezvousKey]
724
- /**
725
- * @param {object} nostrEvent Nostr EVENT
726
- * @param {string} relayUrl 来源中继
727
- * @returns {void}
728
- */
729
- const onEvent = (nostrEvent, relayUrl) => {
730
- if (nostrEvent?.kind !== kind) return
731
- try {
732
- const result = onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent })
733
- if (result?.then) void result.catch(() => { })
734
- }
735
- catch { /* ignore */ }
736
- }
737
- const urls = dedupeRelayUrls(relayUrls)
738
- for (const relayUrl of urls)
739
- registerSharedRelaySub(acquireSharedRelay(relayUrl), subscriptionId, filter, onEvent)
740
- return () => {
741
- for (const relayUrl of urls) releaseSharedRelaySub(relayUrl, subscriptionId)
742
- }
743
- }
744
-
745
- /**
746
- * 创建 Nostr discovery provider(list+connect;topic 仅内部)。
747
- * @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined, localNodeHash?: string }} [options] 中继配置与本机 hash
748
- * @returns {import('./index.mjs').DiscoveryProvider} Nostr discovery provider
749
- */
750
- export function createNostrDiscoveryProvider(options = {}) {
751
- /**
752
- * @returns {string[]} 去重后的中继 URL 列表
753
- */
754
- const resolveRelayUrls = () => {
755
- if (options.getRelayUrls) {
756
- const urls = options.getRelayUrls()
757
- return dedupeRelayUrls(urls == null ? DEFAULT_RELAY_URLS : urls)
758
- }
759
- if (options.relayUrls === undefined || options.relayUrls === null)
760
- return dedupeRelayUrls(DEFAULT_RELAY_URLS)
761
- return dedupeRelayUrls(options.relayUrls)
762
- }
763
- const secretKey = randomBytes(32)
764
- /** @type {string | null} */
765
- let selfNodeHash = isHex64(options.localNodeHash)
766
- const NETWORK_SUB_KEY = 'network'
767
- /**
768
- * @typedef {{ stop: () => void, held: boolean, listeners: Set<(bytes: Uint8Array, meta: object) => void> }} AdvertSubEntry
769
- */
770
- /** @type {Map<string, AdvertSubEntry>} */
771
- const advertSubs = new Map()
772
- /** @type {Map<string, () => void>} */
773
- const nodeSignalSubs = new Map()
774
-
775
- /**
776
- * @param {string | undefined | null} nodeHash 本机 hash
777
- * @returns {void}
778
- */
779
- function noteSelfNodeHash(nodeHash) {
780
- const hash = isHex64(nodeHash)
781
- if (hash) selfNodeHash = hash
782
- }
783
-
784
- /**
785
- * @param {string} key 订阅键
786
- * @param {AdvertSubEntry} entry 条目
787
- * @returns {void}
788
- */
789
- function releaseAdvertEntryIfIdle(key, entry) {
790
- if (entry.held || entry.listeners.size) return
791
- try { entry.stop() } catch { /* ignore */ }
792
- advertSubs.delete(key)
793
- }
794
-
795
- /**
796
- * pool/connect 永久 hold;watch listener 归零且无 hold 时拆 REQ。
797
- * @param {string} key 订阅键
798
- * @param {{ rendezvousKey: string, roomSecret?: string }} bind advert 绑定
799
- * @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外监听
800
- * @returns {() => void} 取消 listener;无 listener 时 no-op(hold 至 dispose)
801
- */
802
- function ensureAdvertSubscription(key, bind, listener) {
803
- if (!key) return () => { }
804
- let entry = advertSubs.get(key)
805
- if (!entry) {
806
- /** @type {AdvertSubEntry} */
807
- const created = {
808
- /**
809
- * @returns {void}
810
- */
811
- stop: () => { }, held: false, listeners: new Set()
812
- }
813
- created.stop = subscribeNostrKind(resolveRelayUrls(), {
814
- kind: NOSTR_ADVERT_KIND,
815
- rendezvousKey: bind.rendezvousKey,
816
- tagX: 'advert',
817
- addressable: true,
818
- /**
819
- * @param {Uint8Array} bytes 加密 advert 载荷
820
- * @param {object} meta relay 元数据
821
- * @returns {Promise<void>}
822
- */
823
- async onPayload(bytes, meta) {
824
- await acceptNostrAdvert(bind.rendezvousKey, bytes, {
825
- roomSecret: bind.roomSecret,
826
- skipNodeHash: selfNodeHash || undefined,
827
- meta,
828
- })
829
- for (const fn of created.listeners)
830
- try { fn(bytes, meta) } catch { /* ignore */ }
831
- },
832
- })
833
- entry = created
834
- advertSubs.set(key, entry)
835
- }
836
- if (!listener) {
837
- entry.held = true
838
- return () => { }
839
- }
840
- entry.listeners.add(listener)
841
- return () => {
842
- entry.listeners.delete(listener)
843
- releaseAdvertEntryIfIdle(key, entry)
844
- }
845
- }
846
-
847
- /**
848
- * @returns {() => void} no-op(network hold 至 dispose)
849
- */
850
- function ensureNetworkAdvertSubscription() {
851
- return ensureAdvertSubscription(NETWORK_SUB_KEY, {
852
- rendezvousKey: networkRendezvousKey(),
853
- })
854
- }
855
-
856
- /**
857
- * @param {string} roomSecret 房间密钥
858
- * @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
859
- * @returns {() => void} 取消 listener
860
- */
861
- function ensureGroupSubscription(roomSecret, listener) {
862
- if (!roomSecret) return () => { }
863
- return ensureAdvertSubscription('group:' + roomSecret, {
864
- rendezvousKey: groupRendezvousKey(roomSecret),
865
- roomSecret,
866
- }, listener)
867
- }
868
-
869
- /**
870
- * @param {string} nodeHash 目标
871
- * @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
872
- * @returns {() => void} 取消 listener
873
- */
874
- function ensureNodeAdvertSubscription(nodeHash, listener) {
875
- const hash = isHex64(nodeHash)
876
- if (!hash) return () => { }
877
- return ensureAdvertSubscription('node:' + hash, {
878
- rendezvousKey: nodeRendezvousKey(hash),
879
- }, listener)
880
- }
881
-
882
- return {
883
- id: 'nostr',
884
- priority: 100,
885
- caps: { canDiscover: true, canSignal: true, canRelay: false },
886
- /**
887
- * @param {{ limit?: number, roomSecret?: string }} [options] 扫描选项
888
- * @returns {Promise<string[]>} 可见 nodeHash;带 roomSecret 时仅返回该群池
889
- */
890
- async listVisibleNodeHashes(options = {}) {
891
- const limit = Math.max(1, Number(options.limit) || 64)
892
- if (options.roomSecret) {
893
- ensureGroupSubscription(options.roomSecret)
894
- return listNostrGroupVisibleNodeHashes(options.roomSecret)
895
- .filter(hash => hash !== selfNodeHash)
896
- .slice(0, limit)
897
- }
898
- ensureNetworkAdvertSubscription()
899
- return listNostrVisibleNodeHashes()
900
- .filter(hash => hash !== selfNodeHash)
901
- .slice(0, limit)
902
- },
903
- /**
904
- * 挂上对该节点的内部 advert 订阅(建链由 registry dialer / ensureLinkToNode 完成)。
905
- * @param {string} nodeHash 目标
906
- * @returns {Promise<boolean>} 是否已准备
907
- */
908
- async connectToNode(nodeHash) {
909
- const hash = isHex64(nodeHash)
910
- if (!hash) return false
911
- ensureNodeAdvertSubscription(hash)
912
- return true
913
- },
914
- /**
915
- * @param {() => Promise<object | null>} getBeacon 本机 advert body 工厂
916
- * @returns {Promise<() => void>} 停止函数
917
- */
918
- async startPresence(getBeacon) {
919
- const rendezvousKey = networkRendezvousKey()
920
- const abortController = new AbortController()
921
- ensureNetworkAdvertSubscription()
922
- /**
923
- * @returns {Promise<void>}
924
- */
925
- const publish = async () => {
926
- if (abortController.signal.aborted) return
927
- const beacon = await getBeacon?.()
928
- if (!beacon?.nodeHash) return
929
- noteSelfNodeHash(beacon.nodeHash)
930
- const advertBody = beacon.advertBody || beacon.body || beacon
931
- const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
932
- const event = await signNostrEvent(
933
- NOSTR_ADVERT_KIND,
934
- [NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
935
- bytesToBase64(bytes),
936
- secretKey,
937
- )
938
- await publishEvent(resolveRelayUrls(), event, abortController.signal)
939
- nodeDebug('p2p:nostr presence published', { self: shortHash(beacon.nodeHash) })
940
- }
941
- void publish().catch(error => {
942
- nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
943
- })
944
- const timer = setInterval(() => {
945
- void publish().catch(error => {
946
- nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
947
- })
948
- }, 5 * 60_000)
949
- timer.unref?.()
950
- return () => {
951
- abortController.abort()
952
- clearInterval(timer)
953
- }
954
- },
955
- /**
956
- * @param {string} toNodeHash 目标 nodeHash
957
- * @param {Uint8Array} bytes 加密信令
958
- * @returns {Promise<void>}
959
- */
960
- async sendNodeSignal(toNodeHash, bytes) {
961
- const hash = isHex64(toNodeHash)
962
- if (!hash) throw new Error('nostr: invalid nodeHash')
963
- const rendezvousKey = nodeRendezvousKey(hash)
964
- const event = await signNostrEvent(
965
- NOSTR_SIGNAL_KIND,
966
- [NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'signal'], ['p', hash]],
967
- bytesToBase64(bytes),
968
- secretKey,
969
- )
970
- await publishEvent(resolveRelayUrls(), event)
971
- },
972
- /**
973
- * @param {string} localNodeHash 本机 nodeHash
974
- * @param {(bytes: Uint8Array) => void} onSignal 信令回调
975
- * @returns {Promise<() => void>} 取消函数
976
- */
977
- async listenNodeSignals(localNodeHash, onSignal) {
978
- const hash = isHex64(localNodeHash)
979
- if (!hash) throw new Error('nostr: invalid nodeHash')
980
- noteSelfNodeHash(hash)
981
- const rendezvousKey = nodeRendezvousKey(hash)
982
- const existing = nodeSignalSubs.get(hash)
983
- if (existing) existing()
984
- nodeDebug('p2p:nostr signal listen', { self: shortHash(hash), relays: resolveRelayUrls().length })
985
- const stop = subscribeNostrKind(resolveRelayUrls(), {
986
- kind: NOSTR_SIGNAL_KIND,
987
- rendezvousKey,
988
- tagX: 'signal',
989
- onPayload: onSignal,
990
- })
991
- nodeSignalSubs.set(hash, stop)
992
- return () => {
993
- stop()
994
- nodeSignalSubs.delete(hash)
995
- }
996
- },
997
- /**
998
- * @param {string} nodeHash 目标 nodeHash
999
- * @param {(bytes: Uint8Array, meta: object) => void} onAdvert advert 回调
1000
- * @returns {Promise<() => void>} 取消函数
1001
- */
1002
- async watchNodeAdvert(nodeHash, onAdvert) {
1003
- const hash = isHex64(nodeHash)
1004
- if (!hash) throw new Error('nostr: invalid nodeHash')
1005
- return ensureNodeAdvertSubscription(hash, onAdvert)
1006
- },
1007
- /**
1008
- * @param {string} roomSecret 房间密钥
1009
- * @param {() => Promise<object | null>} getBeacon advert 工厂
1010
- * @returns {Promise<() => void>} 停止群 presence 广播
1011
- */
1012
- async startGroupPresence(roomSecret, getBeacon) {
1013
- const rendezvousKey = groupRendezvousKey(roomSecret)
1014
- const abortController = new AbortController()
1015
- ensureGroupSubscription(roomSecret)
1016
- /**
1017
- * @returns {Promise<void>}
1018
- */
1019
- const publish = async () => {
1020
- if (abortController.signal.aborted) return
1021
- const beacon = await getBeacon?.()
1022
- if (!beacon?.nodeHash) return
1023
- noteSelfNodeHash(beacon.nodeHash)
1024
- const advertBody = beacon.advertBody || beacon.body || beacon
1025
- const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
1026
- const event = await signNostrEvent(
1027
- NOSTR_ADVERT_KIND,
1028
- [NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
1029
- bytesToBase64(bytes),
1030
- secretKey,
1031
- )
1032
- await publishEvent(resolveRelayUrls(), event, abortController.signal)
1033
- }
1034
- void publish().catch(() => { })
1035
- const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
1036
- timer.unref?.()
1037
- return () => {
1038
- abortController.abort()
1039
- clearInterval(timer)
1040
- }
1041
- },
1042
- /**
1043
- * @param {string} roomSecret 房间密钥
1044
- * @param {(bytes: Uint8Array, meta: object) => void} onAdvert 回调
1045
- * @returns {Promise<() => void>} 取消群 advert 监听
1046
- */
1047
- async watchGroupAdverts(roomSecret, onAdvert) {
1048
- return ensureGroupSubscription(roomSecret, onAdvert)
1049
- },
1050
- /**
1051
- * 供 advert 解析路径写入可见 hash。
1052
- * @param {string} nodeHash 节点 hash
1053
- * @param {{ roomSecret?: string }} [options] 带 roomSecret 时写入群池
1054
- * @returns {void}
1055
- */
1056
- noteVisibleNode(nodeHash, options = {}) {
1057
- if (options.roomSecret) noteNostrGroupVisibleNode(options.roomSecret, nodeHash)
1058
- else noteNostrVisibleNode(nodeHash)
1059
- },
1060
- /**
1061
- * 停止全部内部订阅(reload / unregister 时调用)。
1062
- * @returns {void}
1063
- */
1064
- dispose() {
1065
- for (const entry of advertSubs.values())
1066
- try { entry.stop() } catch { /* ignore */ }
1067
- advertSubs.clear()
1068
- for (const stop of nodeSignalSubs.values())
1069
- try { stop() } catch { /* ignore */ }
1070
- nodeSignalSubs.clear()
1071
- },
1072
- }
1073
- }