@steve02081504/fount-p2p 0.0.36 → 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.
@@ -0,0 +1,584 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { isIP } from 'node:net'
3
+
4
+ import WebSocket from 'ws'
5
+
6
+ import { base64ToBytes } from '../../core/bytes_codec.mjs'
7
+ import { nodeDebug } from '../../node/log.mjs'
8
+
9
+ /** 单中继 WebSocket 首连超时(短超时 + 并行,避免串行 10s×N)。 */
10
+ export const NOSTR_CONNECT_TIMEOUT_MS = 2_000
11
+ /** 先 close,超时未 CLOSED 再 terminate(给对端优雅关闭的窗口)。 */
12
+ const NOSTR_CLOSE_GRACE_MS = 1_000
13
+ /** 单 relay 等待 EVENT OK 回执超时。 */
14
+ const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
15
+ /** 共享 relay 会话断线后重连间隔。 */
16
+ const NOSTR_RECONNECT_DELAY_MS = 500
17
+ /** 无 sub/publish 工作时共享 relay 空闲回收延迟(给连续 send 复用窗口)。 */
18
+ const NOSTR_IDLE_DROP_MS = 2_000
19
+
20
+ /**
21
+ * @param {string[] | undefined | null} urls 原始列表
22
+ * @returns {string[]} 去重后的列表
23
+ */
24
+ export function dedupeRelayUrls(urls) {
25
+ const seen = new Set()
26
+ return (urls || [])
27
+ .filter(url => url && !seen.has(url) && (seen.add(url), true))
28
+ }
29
+
30
+ /**
31
+ * 关掉 WebSocket:已连上则先 close,grace 内未 CLOSED 再 terminate。
32
+ * @param {import('ws').WebSocket} ws 连接
33
+ * @returns {void}
34
+ */
35
+ function dropWebSocket(ws) {
36
+ if (ws.readyState === WebSocket.CLOSED) return
37
+ if (ws.readyState === WebSocket.CONNECTING) {
38
+ try { ws.terminate() } catch { /* ignore */ }
39
+ return
40
+ }
41
+ const timer = setTimeout(() => {
42
+ if (ws.readyState !== WebSocket.CLOSED)
43
+ try { ws.terminate() } catch { /* ignore */ }
44
+ }, NOSTR_CLOSE_GRACE_MS)
45
+ timer.unref()
46
+ ws.once('close', () => clearTimeout(timer))
47
+ try {
48
+ ws.close()
49
+ }
50
+ catch {
51
+ clearTimeout(timer)
52
+ try { ws.terminate() } catch { /* ignore */ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 生成钉死地址集的 dns.lookup 形状函数:忽略请求的 hostname,始终返回给定地址,
58
+ * 供 `new WebSocket(url, { lookup })` / node:http(s) 使用,避免对同一 hostname 的第二次不受控解析。
59
+ * @param {string[]} addresses 已校验/受控解析的地址
60
+ * @returns {(hostname: string, options: object | ((err: Error | null, address?: string | object[], family?: number) => void), callback?: (err: Error | null, address?: string | object[], family?: number) => void) => void} lookup 函数
61
+ */
62
+ export function pinnedLookup(addresses) {
63
+ return (hostname, options, callback) => {
64
+ if (typeof options === 'function') {
65
+ callback = options
66
+ options = {}
67
+ }
68
+ const family = addresses.length ? isIP(addresses[0]) : 0
69
+ if (options?.all)
70
+ callback(null, addresses.map(address => ({ address, family: isIP(address) })), family)
71
+ else
72
+ callback(null, addresses[0], family)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * @param {string} relayUrl 中继 URL
78
+ * @param {number} [timeoutMs] 超时毫秒
79
+ * @param {AbortSignal} [signal] 取消信号
80
+ * @param {{ hostname: string, addresses: string[] } | ((url: string) => Promise<{ hostname: string, addresses: string[] } | null>) | null} [connectTarget] 已解析的连接目标(钉 IP 用)或解析函数
81
+ * @returns {Promise<import('ws').WebSocket>} 已打开的 WebSocket
82
+ */
83
+ export function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal, connectTarget) {
84
+ return new Promise((resolve, reject) => {
85
+ if (signal?.aborted) {
86
+ reject(new Error('nostr: aborted'))
87
+ return
88
+ }
89
+ let settled = false
90
+ /**
91
+ * @param {Error} error 失败原因
92
+ * @returns {void}
93
+ */
94
+ const fail = error => {
95
+ if (settled) return
96
+ settled = true
97
+ clearTimeout(timer)
98
+ signal?.removeEventListener('abort', onAbort)
99
+ if (ws) dropWebSocket(ws)
100
+ reject(error)
101
+ }
102
+ /**
103
+ * 收到 abort 信号时失败
104
+ * @returns {void}
105
+ */
106
+ const onAbort = () => fail(new Error('nostr: aborted'))
107
+ const timer = setTimeout(() => fail(new Error(`nostr: connect timeout for ${relayUrl}`)), timeoutMs)
108
+ timer.unref()
109
+ signal?.addEventListener('abort', onAbort, { once: true })
110
+ let ws = null
111
+ void (async () => {
112
+ let wsOptions
113
+ try {
114
+ const target = typeof connectTarget === 'function' ? await connectTarget(relayUrl) : connectTarget
115
+ if (connectTarget != null && (!target || !target.addresses?.length)) {
116
+ fail(new Error(`nostr: connect target resolution returned empty for ${relayUrl}`))
117
+ return
118
+ }
119
+ if (target) wsOptions = { lookup: pinnedLookup(target.addresses) }
120
+ }
121
+ catch (error) {
122
+ fail(error instanceof Error ? error : new Error(String(error)))
123
+ return
124
+ }
125
+ if (settled) return
126
+ ws = new WebSocket(relayUrl, wsOptions)
127
+ ws.once('open', () => {
128
+ if (settled) return
129
+ settled = true
130
+ clearTimeout(timer)
131
+ signal?.removeEventListener('abort', onAbort)
132
+ resolve(ws)
133
+ })
134
+ ws.once('error', () => {
135
+ fail(new Error(`nostr: websocket error for ${relayUrl}`))
136
+ })
137
+ })()
138
+ })
139
+ }
140
+
141
+ /**
142
+ * @param {import('ws').WebSocket} ws 已连接 relay
143
+ * @param {string} relayUrl 中继 URL
144
+ * @param {object} event 待发布事件
145
+ * @param {AbortSignal} [signal] 取消信号
146
+ * @param {() => boolean} isCurrent 该尝试是否仍是 publishRequest 的当前尝试(断线重发后过期)
147
+ * @returns {Promise<boolean>} relay 是否接受 EVENT
148
+ */
149
+ function publishEventOnRelay(ws, relayUrl, event, signal, isCurrent) {
150
+ return new Promise((resolve, reject) => {
151
+ if (signal?.aborted) {
152
+ reject(new Error('nostr: aborted'))
153
+ return
154
+ }
155
+ let settled = false
156
+ /**
157
+ * @param {boolean} ok relay 是否接受
158
+ * @param {Error | null} [error] 失败原因
159
+ * @returns {void}
160
+ */
161
+ const finish = (ok, error = null) => {
162
+ if (settled) return
163
+ settled = true
164
+ clearTimeout(timer)
165
+ signal?.removeEventListener('abort', onAbort)
166
+ ws.off('message', onMessage)
167
+ // 过期的 in-flight 尝试:只清理自身监听器/定时器,不结算,避免影响重发后的当前尝试。
168
+ if (!isCurrent()) return
169
+ if (error) reject(error)
170
+ else resolve(ok)
171
+ }
172
+ /**
173
+ * 收到 abort 信号时以失败结束
174
+ * @returns {void}
175
+ */
176
+ const onAbort = () => finish(false, new Error('nostr: aborted'))
177
+ /**
178
+ * @param {import('ws').RawData} data relay 消息
179
+ * @returns {void}
180
+ */
181
+ const onMessage = data => {
182
+ let parsed
183
+ try { parsed = JSON.parse(String(data)) } catch { return }
184
+ if (parsed?.[0] !== 'OK' || parsed[1] !== event.id) return
185
+ const accepted = parsed[2] === true
186
+ if (!accepted)
187
+ nodeDebug('p2p:nostr publish rejected', {
188
+ url: relayUrl,
189
+ reason: String(parsed[3] || 'rejected'),
190
+ })
191
+ finish(accepted)
192
+ }
193
+ const timer = setTimeout(
194
+ () => finish(false, new Error(`nostr: publish ok timeout for ${relayUrl}`)),
195
+ NOSTR_PUBLISH_OK_TIMEOUT_MS,
196
+ )
197
+ timer.unref()
198
+ signal?.addEventListener('abort', onAbort, { once: true })
199
+ ws.on('message', onMessage)
200
+ try {
201
+ ws.send(JSON.stringify(['EVENT', event]))
202
+ }
203
+ catch (error) {
204
+ finish(false, error instanceof Error ? error : new Error(String(error)))
205
+ }
206
+ })
207
+ }
208
+
209
+ /**
210
+ * 一次共享 relay publish 请求:入队(pending)与已派发(inflight)时共用同一结构。
211
+ * attempt 标识每次实际发送尝试,断线重发会递增;旧的 in-flight 尝试据此自检为过期后自我清理,
212
+ * 不再结算当前尝试。
213
+ * @typedef {{
214
+ * event: object,
215
+ * signal: AbortSignal | undefined,
216
+ * attempt: number,
217
+ * onAbort: (() => void) | null,
218
+ * removeAbort: (() => void) | null,
219
+ * resolve: (ok: boolean) => void,
220
+ * reject: (err: Error) => void,
221
+ * }} NostrPublishRequest
222
+ */
223
+
224
+ /**
225
+ * 共享 relay 会话:多 SUB / publish 复用同一 URL 的 WebSocket,避免 signal/presence/advert 各建一池、
226
+ * 以及每次 send 重开一条连接(内存泄漏)。
227
+ * 仍有活跃 sub 或待发 publish 时断线会自动重连并重发 REQ / 重发 EVENT。
228
+ * @typedef {{
229
+ * ws: import('ws').WebSocket | null,
230
+ * connecting: boolean,
231
+ * reconnectTimer: ReturnType<typeof setTimeout> | null,
232
+ * idleTimer: ReturnType<typeof setTimeout> | null,
233
+ * connectTarget: { hostname: string, addresses: string[] } | ((url: string) => Promise<{ hostname: string, addresses: string[] } | null>) | null,
234
+ * subs: Map<string, { filter: object, onEvent: (event: object, relayUrl: string) => void }>,
235
+ * pendingPublishes: Array<NostrPublishRequest>,
236
+ * inflightPublishes: Array<NostrPublishRequest>,
237
+ * }} SharedRelaySession
238
+ */
239
+
240
+ /** @type {Map<string, SharedRelaySession>} */
241
+ const sharedRelaySessions = new Map()
242
+
243
+ /**
244
+ * 会话是否仍有需要连接的工作(sub 或待发 publish)。
245
+ * @param {SharedRelaySession} session 会话
246
+ * @returns {boolean} 是否有活跃工作
247
+ */
248
+ function hasPendingWork(session) {
249
+ return session.subs.size || session.pendingPublishes.length || session.inflightPublishes.length
250
+ }
251
+
252
+ /**
253
+ * 取消会话的空闲回收定时器。
254
+ * @param {SharedRelaySession} session 会话
255
+ * @returns {void}
256
+ */
257
+ function clearIdleDrop(session) {
258
+ if (!session.idleTimer) return
259
+ clearTimeout(session.idleTimer)
260
+ session.idleTimer = null
261
+ }
262
+
263
+ /**
264
+ * 会话无 sub 时,待 publish 清空后延迟回收空闲 socket(给连续 send 复用窗口)。
265
+ * @param {string} relayUrl 中继 URL
266
+ * @param {SharedRelaySession} session 会话
267
+ * @returns {void}
268
+ */
269
+ function scheduleIdleDrop(relayUrl, session) {
270
+ if (session.subs.size || session.idleTimer) return
271
+ session.idleTimer = setTimeout(() => {
272
+ session.idleTimer = null
273
+ if (!isLiveSharedSession(relayUrl, session) || hasPendingWork(session)) return
274
+ sharedRelaySessions.delete(relayUrl)
275
+ clearSharedRelayReconnect(session)
276
+ if (session.ws) dropWebSocket(session.ws)
277
+ session.ws = null
278
+ }, NOSTR_IDLE_DROP_MS)
279
+ session.idleTimer.unref?.()
280
+ }
281
+
282
+ /**
283
+ * @param {string} relayUrl 中继 URL
284
+ * @param {SharedRelaySession} session 会话
285
+ * @returns {boolean} session 是否仍是该 URL 的活动会话
286
+ */
287
+ function isLiveSharedSession(relayUrl, session) {
288
+ return sharedRelaySessions.get(relayUrl) === session
289
+ }
290
+
291
+ /**
292
+ * @param {SharedRelaySession} session 会话
293
+ * @returns {void}
294
+ */
295
+ function clearSharedRelayReconnect(session) {
296
+ if (!session.reconnectTimer) return
297
+ clearTimeout(session.reconnectTimer)
298
+ session.reconnectTimer = null
299
+ }
300
+
301
+ /**
302
+ * @param {string} relayUrl 中继 URL
303
+ * @param {SharedRelaySession} session 会话
304
+ * @param {import('ws').WebSocket} ws 已打开连接
305
+ * @returns {void}
306
+ */
307
+ function attachSharedRelaySocket(relayUrl, session, ws) {
308
+ session.ws = ws
309
+ // 提升监听器上限:每个并发 publish 的临时 message 监听 + 会话自身监听,避免 MaxListenersExceededWarning。
310
+ ws.setMaxListeners(session.pendingPublishes.length + session.inflightPublishes.length + 8)
311
+ nodeDebug('p2p:nostr relay up', { url: relayUrl })
312
+ ws.on('message', data => {
313
+ let parsed
314
+ try { parsed = JSON.parse(String(data)) } catch { return }
315
+ if (parsed?.[0] !== 'EVENT') return
316
+ const subId = String(parsed[1] || '')
317
+ const nostrEvent = parsed[2]
318
+ const sub = session.subs.get(subId)
319
+ if (!sub || !nostrEvent) return
320
+ try { sub.onEvent(nostrEvent, relayUrl) } catch { /* ignore */ }
321
+ })
322
+ ws.once('close', () => {
323
+ if (!isLiveSharedSession(relayUrl, session)) return
324
+ session.ws = null
325
+ // 尚未确认的 inflight publish 重新入队,重连后由 flushPendingPublishes 重发。
326
+ if (session.inflightPublishes.length) {
327
+ session.pendingPublishes.push(...session.inflightPublishes)
328
+ for (const publishRequest of session.inflightPublishes)
329
+ attachQueuedAbort(session, publishRequest)
330
+ session.inflightPublishes = []
331
+ }
332
+ if (!hasPendingWork(session)) {
333
+ sharedRelaySessions.delete(relayUrl)
334
+ return
335
+ }
336
+ scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
337
+ })
338
+ for (const [subId, sub] of session.subs)
339
+ try { ws.send(JSON.stringify(['REQ', subId, sub.filter])) } catch { /* ignore */ }
340
+ flushPendingPublishes(relayUrl, session, ws)
341
+ if (!hasPendingWork(session)) scheduleIdleDrop(relayUrl, session)
342
+ }
343
+
344
+ /**
345
+ * @param {string} relayUrl 中继 URL
346
+ * @param {SharedRelaySession} session 会话
347
+ * @param {number} [delayMs=0] 延迟毫秒(断线重连用)
348
+ * @returns {void}
349
+ */
350
+ function scheduleSharedRelayConnect(relayUrl, session, delayMs = 0) {
351
+ if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
352
+ clearSharedRelayReconnect(session)
353
+ /** 启动/恢复共享会话连接 */
354
+ const start = () => {
355
+ session.reconnectTimer = null
356
+ if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
357
+ if (!hasPendingWork(session)) {
358
+ sharedRelaySessions.delete(relayUrl)
359
+ return
360
+ }
361
+ session.connecting = true
362
+ void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, undefined, session.connectTarget).then(ws => {
363
+ session.connecting = false
364
+ if (!isLiveSharedSession(relayUrl, session) || !hasPendingWork(session)) {
365
+ sharedRelaySessions.delete(relayUrl)
366
+ dropWebSocket(ws)
367
+ return
368
+ }
369
+ attachSharedRelaySocket(relayUrl, session, ws)
370
+ }).catch(error => {
371
+ session.connecting = false
372
+ if (!isLiveSharedSession(relayUrl, session)) return
373
+ nodeDebug('p2p:nostr relay fail', {
374
+ url: relayUrl,
375
+ err: String(error?.message || error),
376
+ })
377
+ if (!hasPendingWork(session)) {
378
+ sharedRelaySessions.delete(relayUrl)
379
+ return
380
+ }
381
+ scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
382
+ })
383
+ }
384
+ if (delayMs <= 0) {
385
+ // 让同一 tick 内的 registerSharedRelaySub 先写入 subs,再决定是否连接。
386
+ queueMicrotask(start)
387
+ return
388
+ }
389
+ nodeDebug('p2p:nostr relay reconnect', { url: relayUrl, delayMs })
390
+ session.reconnectTimer = setTimeout(start, delayMs)
391
+ session.reconnectTimer.unref?.()
392
+ }
393
+
394
+ /**
395
+ * @param {string} relayUrl 中继 URL
396
+ * @param {{ hostname: string, addresses: string[] } | ((url: string) => Promise<{ hostname: string, addresses: string[] } | null>) | null} [connectTarget] 钉 IP 用的连接目标(首个请求方提供;重连复用)
397
+ * @returns {SharedRelaySession} 共享会话
398
+ */
399
+ function acquireSharedRelay(relayUrl, connectTarget) {
400
+ const existing = sharedRelaySessions.get(relayUrl)
401
+ if (existing) return existing
402
+ /** @type {SharedRelaySession} */
403
+ const session = {
404
+ ws: null,
405
+ connecting: false,
406
+ reconnectTimer: null,
407
+ idleTimer: null,
408
+ connectTarget: connectTarget || null,
409
+ subs: new Map(),
410
+ pendingPublishes: [],
411
+ inflightPublishes: [],
412
+ }
413
+ sharedRelaySessions.set(relayUrl, session)
414
+ scheduleSharedRelayConnect(relayUrl, session)
415
+ return session
416
+ }
417
+
418
+ /**
419
+ * @param {string} relayUrl 中继 URL
420
+ * @param {string} subscriptionId 订阅 id
421
+ * @returns {void}
422
+ */
423
+ function releaseSharedRelaySub(relayUrl, subscriptionId) {
424
+ const session = sharedRelaySessions.get(relayUrl)
425
+ if (!session) return
426
+ session.subs.delete(subscriptionId)
427
+ const { ws } = session
428
+ if (ws?.readyState === WebSocket.OPEN)
429
+ try { ws.send(JSON.stringify(['CLOSE', subscriptionId])) } catch { /* ignore */ }
430
+ if (hasPendingWork(session)) return
431
+ sharedRelaySessions.delete(relayUrl)
432
+ clearSharedRelayReconnect(session)
433
+ clearIdleDrop(session)
434
+ if (ws) dropWebSocket(ws)
435
+ }
436
+
437
+ /**
438
+ * 在已打开的共享连接上登记 REQ;连接中则等 attach 时统一重放。
439
+ * @param {SharedRelaySession} session 会话
440
+ * @param {string} subscriptionId 订阅 id
441
+ * @param {object} filter Nostr filter
442
+ * @param {(event: object, relayUrl: string) => void} onEvent 事件回调
443
+ * @returns {void}
444
+ */
445
+ function registerSharedRelaySub(session, subscriptionId, filter, onEvent) {
446
+ session.subs.set(subscriptionId, { filter, onEvent })
447
+ clearIdleDrop(session)
448
+ const { ws } = session
449
+ if (ws?.readyState !== WebSocket.OPEN) return
450
+ try { ws.send(JSON.stringify(['REQ', subscriptionId, filter])) } catch { /* ignore */ }
451
+ }
452
+
453
+ /**
454
+ * 将已入队的 publish 逐个派发到已打开 socket 上;复用 publishEventOnRelay 等 OK 回执。
455
+ * 每个 publish 独立携带自己的 message/abort 监听,socket 共享安全。
456
+ * @param {string} relayUrl 中继 URL
457
+ * @param {SharedRelaySession} session 会话
458
+ * @param {import('ws').WebSocket} socket 已打开连接
459
+ * @returns {void}
460
+ */
461
+ function flushPendingPublishes(relayUrl, session, socket) {
462
+ const pending = session.pendingPublishes
463
+ session.pendingPublishes = []
464
+ for (const publishRequest of pending) {
465
+ publishRequest.removeAbort?.()
466
+ publishRequest.onAbort = null
467
+ publishRequest.removeAbort = null
468
+ const attempt = ++publishRequest.attempt
469
+ session.inflightPublishes.push(publishRequest)
470
+ void publishEventOnRelay(socket, relayUrl, publishRequest.event, publishRequest.signal, () => publishRequest.attempt === attempt)
471
+ .then(
472
+ ok => settleInflightPublish(relayUrl, session, publishRequest, attempt, () => publishRequest.resolve(ok)),
473
+ error => settleInflightPublish(relayUrl, session, publishRequest, attempt, () => publishRequest.reject(error)),
474
+ )
475
+ }
476
+ }
477
+
478
+ /**
479
+ * 为队列中的 publish 挂上 abort 处理:先移除监听器(释放 signal 对回调/event/session 的引用),
480
+ * 再从队列删除并 reject。
481
+ * @param {SharedRelaySession} session 会话
482
+ * @param {SharedRelaySession['pendingPublishes'][number]} publishRequest 待发请求
483
+ * @returns {void}
484
+ */
485
+ function attachQueuedAbort(session, publishRequest) {
486
+ const { signal, reject } = publishRequest
487
+ /**
488
+ * 处理 abort 事件
489
+ */
490
+ publishRequest.onAbort = () => {
491
+ publishRequest.removeAbort?.()
492
+ const pendingIndex = session.pendingPublishes.indexOf(publishRequest)
493
+ if (pendingIndex < 0) return
494
+ session.pendingPublishes.splice(pendingIndex, 1)
495
+ reject(new Error('nostr: aborted'))
496
+ }
497
+ /**
498
+ * 移除 abort 监听器
499
+ */
500
+ publishRequest.removeAbort = () => {
501
+ if (signal) signal.removeEventListener('abort', publishRequest.onAbort)
502
+ }
503
+ if (signal) signal.addEventListener('abort', publishRequest.onAbort, { once: true })
504
+ }
505
+
506
+ /**
507
+ * publish 在 socket 上得到 OK / 失败 / abort 后结算:从 inflight 移除并 settle。
508
+ * 仅结算仍与当前 attempt 匹配的请求:断线重发后 attempt 已递增,旧尝试的结算在此短路,
509
+ * 避免提前结算重发后的当前尝试。已被 requeue(断线重发)的请求不在 inflight,跳过结算。
510
+ * @param {string} relayUrl 中继 URL
511
+ * @param {SharedRelaySession} session 会话
512
+ * @param {SharedRelaySession['inflightPublishes'][number]} publishRequest 已发送请求
513
+ * @param {number} attempt 本次发送的 attempt 标识
514
+ * @param {() => void} settle 结算回调(resolve/reject)
515
+ * @returns {void}
516
+ */
517
+ function settleInflightPublish(relayUrl, session, publishRequest, attempt, settle) {
518
+ if (publishRequest.attempt !== attempt) return
519
+ const inflightIndex = session.inflightPublishes.indexOf(publishRequest)
520
+ if (inflightIndex < 0) return
521
+ session.inflightPublishes.splice(inflightIndex, 1)
522
+ settle()
523
+ if (!hasPendingWork(session)) scheduleIdleDrop(relayUrl, session)
524
+ }
525
+
526
+ /**
527
+ * 通过共享 relay 会话发布 EVENT:复用已打开 socket,避免每次 send 重开一条连接(内存泄漏)。
528
+ * 无现成连接时入队并触发连接,连上后由 flushPendingPublishes 统一派发。
529
+ * @param {string} relayUrl 中继 URL
530
+ * @param {object} event 待发布事件
531
+ * @param {AbortSignal} [signal] 取消信号
532
+ * @param {{ hostname: string, addresses: string[] } | ((url: string) => Promise<{ hostname: string, addresses: string[] } | null>) | null} [connectTarget] 钉 IP 用的连接目标
533
+ * @returns {Promise<boolean>} relay 是否接受 EVENT
534
+ */
535
+ export function publishViaSharedRelay(relayUrl, event, signal, connectTarget) {
536
+ return new Promise((resolve, reject) => {
537
+ if (signal?.aborted) {
538
+ reject(new Error('nostr: aborted'))
539
+ return
540
+ }
541
+ const session = acquireSharedRelay(relayUrl, connectTarget)
542
+ clearIdleDrop(session)
543
+ /** @type {SharedRelaySession['pendingPublishes'][number]} */
544
+ const publishRequest = { event, signal, attempt: 0, resolve, reject, onAbort: null, removeAbort: null }
545
+ attachQueuedAbort(session, publishRequest)
546
+ session.pendingPublishes.push(publishRequest)
547
+ if (session.ws?.readyState === WebSocket.OPEN)
548
+ flushPendingPublishes(relayUrl, session, session.ws)
549
+ else
550
+ scheduleSharedRelayConnect(relayUrl, session)
551
+ })
552
+ }
553
+
554
+ /**
555
+ * 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。多订阅共享每 URL 一条连接。
556
+ * @param {string[]} relayUrls 中继 URL 列表
557
+ * @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void>, addressable?: boolean, resolveConnectTarget?: (url: string) => Promise<{ hostname: string, addresses: string[] } | null> }} options 订阅选项
558
+ * @returns {() => void} 取消订阅
559
+ */
560
+ export function subscribeNostrKind(relayUrls, options) {
561
+ const { kind, rendezvousKey, tagX, onPayload, addressable = false, resolveConnectTarget } = options
562
+ const subscriptionId = randomBytes(8).toString('hex')
563
+ const filter = { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }
564
+ if (addressable) filter['#d'] = [rendezvousKey]
565
+ /**
566
+ * @param {object} nostrEvent Nostr EVENT
567
+ * @param {string} relayUrl 来源中继
568
+ * @returns {void}
569
+ */
570
+ const onEvent = (nostrEvent, relayUrl) => {
571
+ if (nostrEvent?.kind !== kind) return
572
+ try {
573
+ const result = onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent })
574
+ if (result?.then) void result.catch(() => { })
575
+ }
576
+ catch { /* ignore */ }
577
+ }
578
+ const urls = dedupeRelayUrls(relayUrls)
579
+ for (const relayUrl of urls)
580
+ registerSharedRelaySub(acquireSharedRelay(relayUrl, resolveConnectTarget), subscriptionId, filter, onEvent)
581
+ return () => {
582
+ for (const relayUrl of urls) releaseSharedRelaySub(relayUrl, subscriptionId)
583
+ }
584
+ }
@@ -0,0 +1,92 @@
1
+ # Nostr Relay Discovery & Routing
2
+
3
+ Nostr is the fount-network fallback transport (`link` level −∞, discovery `priority` 100). This document covers the **relay pool**, NIP-66 discovery, health scoring, advert relay-field signing, and handshake routing. Top-level surface: [signaling.md](signaling.md) · [transports.md](transports.md) · [mesh.md](mesh.md).
4
+
5
+ Source layout (`discovery/nostr/`):
6
+ - `index.mjs` — provider (presence / signal / advert subscriptions)
7
+ - `relays.mjs` — pool, health, NIP-66 discovery, normalization, persistence
8
+ - `selection.mjs` — handshake routing, backoff, fanout
9
+ - `session.mjs` — shared relay WebSocket sessions / subscribe primitives
10
+ - `census.mjs` — population census worker (kind 30789, HT estimate)
11
+ - `census_math.mjs` — census pure functions (inclusion-probability update, HT estimator)
12
+ - `constants.mjs` — hard limits
13
+
14
+ ## Three-tier sets
15
+
16
+ `relayPool` (all known) → `workingRelays` (top-`WORKING_RELAYS_COUNT` by health) → `listenRelays` (top-`LISTEN_RELAYS_COUNT` working subset used for publish/listen).
17
+
18
+ - `public`/`manual` entries are **pinned**: always in `working`/`listen` and never evicted by `clearStale` or pool-cap. Pin count may exceed the nominal caps.
19
+ - `nip66`/`peer` entries are disposable: evicted when stale (`PROBE_STALE_MS`) or when the pool exceeds `POOL_CAP`.
20
+ - Fresh nodes are seeded with `DEFAULT_RELAY_URLS` (`source: 'public'`) so `listenRelays` is never empty at cold start.
21
+ - Persistent connections stay bounded by `workingRelays` (`WORKING_RELAYS_COUNT`, max 32); pins influence selection, not simultaneous connections.
22
+
23
+ ## URL normalization (single ingress)
24
+
25
+ `normalizeNostrRelayUrl` (in `relays.mjs`) is the only entry for every inbound URL (NIP-66 `d` tag, manual config, peer advert):
26
+
27
+ - `wss://` always; `ws://` only for loopback/private hosts (local dev/tests).
28
+ - hostname lowercased, default port removed, trailing slashes removed, non-empty path kept.
29
+ - Invalid → `null` → dropped with an audit log (`nodeDebug('invalidRelayUrl', { url, reason })`), never silently cleaned.
30
+
31
+ ## Health score
32
+
33
+ ```text
34
+ failureRate = failureCount / (successCount + failureCount)
35
+ rtt = clamp(rttMs ?? DEFAULT_RTT_MS, 1, MAX_RTT_MS)
36
+ score = rtt * (1 + failureRate * FAILURE_WEIGHT) // FAILURE_WEIGHT = 4
37
+ score *= STALE_PENALTY // ×2 if lastProbe older than PROBE_STALE_MS
38
+ ```
39
+
40
+ Lower is better. `recordProbeSuccess` / `recordProbeFailure` / `recordPublishResult` share the same counters. Writes to `nodeDir/nostr/relays.json` are **throttled** (2s debounce).
41
+
42
+ ## Persistence
43
+
44
+ `nodeDir/nostr/relays.json`:
45
+
46
+ ```text
47
+ {
48
+ "updatedAt": 1234567890,
49
+ "nostrRelays": [{ "url", "rttMs", "successCount", "failureCount", "lastSuccess", "lastFailure", "lastProbe", "firstSeen", "lastSeen", "source", "nips", "clearnet", "monitorCount" }],
50
+ "peerRoutes": { "<nodeHash64>": { "listenRelays", "peerPool", "lastGoodNostrRelays", "lastSeen" } }
51
+ }
52
+ ```
53
+
54
+ `peerRoutes` is a local-only cache (never shared).
55
+
56
+ ## NIP-66 discovery
57
+
58
+ - Bootstrap: `NIP66_BOOTSTRAP_RELAYS` + all `public`/`manual` as fallback, parallel, 10s connect timeout; refreshed every `NIP66_REFRESH_MS` (6h).
59
+ - `REQ` kind 30166 (limit 500) + optional 10166; parse `d` (url), `n` (clearnet only), `N` (must include NIP-01 or be absent), `rtt-open/read/write`.
60
+ - **Trust layering**: same URL reported by ≥2 distinct pubkeys → `monitorCount ≥ 2`; single report is untrusted. All candidates are probed; a successful probe upserts the entry (`source: 'nip66'`, real `monitorCount`), a failed probe drops it. Probes per round are bounded (`MAX_NIP66_PROBES_PER_ROUND`).
61
+ - Discovery is non-blocking (first round deferred to the next macrotask) and cancellable (AbortSignal tears down sockets + NIP-11 fetches), so it never blocks `ensureRuntime` or shutdown.
62
+ - `setNostrRelayDiscoveryEnabledForTests(false)` / `setNip66BootstrapRelaysForTests(urls)` are test-only hooks.
63
+
64
+ ## Advert relay fields & signature
65
+
66
+ `link/handshake.mjs` extends the advert signature domain: after `lanHosts`, append `\0relays:<hex>` where `<hex>` = UTF-8→hex of `{ p: pool, l: listen }` (both sorted by url, `pool` items `{url, rtt}`).
67
+
68
+ - `sanitizeAdvertRelayFields` (verify/inbound path): pool ≤ `MAX_ADVERT_RELAY_POOL` (16), rtt ∈ [0, `MAX_RTT_MS`], deduped; listen ≤ `MAX_ADVERT_LISTEN_RELAYS` (32), deduped; dropped entries logged. `buildSignedAdvert` (outbound) uses a strict variant that throws on invalid locally-supplied relay fields instead of dropping them.
69
+ - `verifySignedAdvert` now returns `{ nodeHash, relayPool, listenRelays }` (sanitized). `ingest*Advert` passes these through; consumers must use the verified values, never the raw body.
70
+ - **Any tampering with relay fields invalidates the signature** → advert rejected.
71
+
72
+ ## Handshake routing (`selection.mjs`)
73
+
74
+ `handshakeTargets(nodeHash, attempt)`:
75
+ - **Round 0**: peer-claimed `listenRelays` top 4 by composite score (own health + peer rtt); else local `workingRelays` top 4; else pinned top 4.
76
+ - **Round ≥1**: backoff `min(2000 · 2^(attempt−1), 60000)`; base on `lastGoodNostrRelays` (expanded via `expandFromHistory` ≤ 16), or weighted-random sample of `workingRelays` (weight `1/score`); round-0 core always included; fanout capped at `MAX_ROUTING_FANOUT` (64).
77
+ - Retries ≤ `MAX_ROUTING_ATTEMPTS` (4).
78
+
79
+ `routePublishEvent(toNodeHash, event, signal)`: publishes to the current round's targets in parallel via shared sessions; any `OK` records `lastGoodNostrRelays` (last 16) + success; all-fail records failures and backs off. `sendNodeSignal` uses routing; an explicit relay override (test/user pin) publishes directly.
80
+
81
+ ## Census (population estimate)
82
+
83
+ `census.mjs` implements a population census over nostr (event kind 30789, `t=fount` / `x=census`, content = base64 of the signed packet). It is driven by the `features.census` switch (`setP2PFeatures`).
84
+
85
+ - Each node keeps an inclusion probability `p`; per window it publishes an Ed25519-signed packet `{ nodeHash, nodePubKey, ts, p, sig }` (message `fount-census\0ts\0nodeHash\0p`) when `rand < p`.
86
+ - The multiplier updates `p' = p·(T/E)` (T = `CENSUS_TARGET_EVENTS`, E = observed window events); E == 0 probes upward by `CENSUS_GROW_FACTOR`. `clampP` keeps `p` in `[CENSUS_MIN_P, 1]`.
87
+ - Readers estimate online nodes with the HT estimator `M̂ = Σ(1/p)` (`estimatePopulation` in `census_math.mjs`), subtract the self-event's weight (`−1/p_self`) and add exactly 1 for the self node.
88
+ - Ingress packets are untrusted: `verifyCensusPacket` canonicalizes, checks `ts` within `CENSUS_TTL_MS`, requires `p ∈ [CENSUS_MIN_P, 1]`, and verifies `nodeHash = sha256(nodePubKey)` plus the Ed25519 signature before `noteCensusEvent` stores the event (deduped by `nodeHash`).
89
+
90
+ ## Config
91
+
92
+ `channels.nostr.relay` (non-empty array) **overrides** the whole relay set — published/subscribed relays come from that list, not the pool. Without it, `resolveNostrRelayUrls()` returns `getListenRelays()`.
@@ -71,12 +71,10 @@ export async function fetchPublicManifest(context) {
71
71
  if (!shared) return hasLocalPublic ? local : null
72
72
 
73
73
  if (hasLocalPublic) {
74
- if (wantCache)
75
- void shared.then(async result => {
76
- if (result && shouldPreferIncomingPublicManifest(local, result))
77
- await cachePublicManifest(ownerEntityHash, logicalPath, result)
78
- })
79
-
74
+ if (wantCache) void shared.then(async result => {
75
+ if (result && shouldPreferIncomingPublicManifest(local, result))
76
+ await cachePublicManifest(ownerEntityHash, logicalPath, result)
77
+ })
80
78
  return local
81
79
  }
82
80