@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,1071 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { lookup } from 'node:dns/promises'
3
+ import { request as httpRequest } from 'node:http'
4
+ import { request as httpsRequest } from 'node:https'
5
+ import { isIP } from 'node:net'
6
+
7
+ import WebSocket from 'ws'
8
+
9
+ import { getNodeTransportSettings } from '../../node/identity.mjs'
10
+ import { getSignalingRuntimeConfig } from '../../node/instance.mjs'
11
+ import { nodeDebug } from '../../node/log.mjs'
12
+ import {
13
+ readNostrRelaysJsonSync,
14
+ writeNostrRelaysJsonSync,
15
+ } from '../../node/storage.mjs'
16
+
17
+ import {
18
+ DEFAULT_RTT_MS,
19
+ FAILURE_WEIGHT,
20
+ LAST_GOOD_RELAYS_MAX,
21
+ LISTEN_RELAYS_COUNT,
22
+ MAX_RTT_MS,
23
+ NIP66_BOOTSTRAP_RELAYS,
24
+ NIP66_REFRESH_MS,
25
+ POOL_CAP,
26
+ PROBE_STALE_MS,
27
+ STALE_PENALTY,
28
+ WORKING_RELAYS_COUNT,
29
+ } from './constants.mjs'
30
+ import { connectRelay, dedupeRelayUrls, pinnedLookup } from './session.mjs'
31
+
32
+ /** 默认公共中继(source='public',永不淘汰)。 */
33
+ export const DEFAULT_RELAY_URLS = [
34
+ 'wss://relay.damus.io',
35
+ 'wss://nos.lol',
36
+ 'wss://relay.primal.net',
37
+ ]
38
+
39
+ /** 持久化写盘节流延迟。 */
40
+ const FLUSH_DELAY_MS = 2_000
41
+ /** probe 单次连接超时。 */
42
+ const PROBE_TIMEOUT_MS = 10_000
43
+ /** NIP-66 发现 REQ limit。 */
44
+ const NIP66_REQ_LIMIT = 500
45
+ /** 每轮 NIP-66 候选 probe 上限(有界,避免海量候选拖垮节点)。 */
46
+ const MAX_NIP66_PROBES_PER_ROUND = 48
47
+ /** NIP-66 候选 probe 并发批大小(有界,避免同轮瞬时打满连接)。 */
48
+ const NIP66_PROBE_BATCH_SIZE = 8
49
+
50
+ /** @typedef {'nip66' | 'public' | 'manual' | 'peer'} RelaySource */
51
+
52
+ /**
53
+ * @typedef {{
54
+ * url: string,
55
+ * rttMs: number | null,
56
+ * successCount: number,
57
+ * failureCount: number,
58
+ * lastSuccess: number,
59
+ * lastFailure: number,
60
+ * lastProbe: number,
61
+ * firstSeen: number,
62
+ * lastSeen: number,
63
+ * source: RelaySource,
64
+ * nips: string[],
65
+ * clearnet: boolean,
66
+ * monitorCount: number,
67
+ * }} RelayPoolEntry
68
+ */
69
+
70
+ /**
71
+ * @typedef {{
72
+ * listenRelays: string[],
73
+ * peerPool: Array<{ url: string, rttMs: number | null }>,
74
+ * lastGoodNostrRelays: string[],
75
+ * lastSeen: number,
76
+ * }} PeerRoute
77
+ */
78
+
79
+ /** @type {Map<string, RelayPoolEntry>} */
80
+ let poolEntries = new Map()
81
+ /** @type {Map<string, PeerRoute>} */
82
+ let peerRoutes = new Map()
83
+ /** @type {Map<string, number>} 提供方显式配置的 relay(本机配置来源,受信)URL → 引用计数。 */
84
+ const providerTrustedRelayUrls = new Map()
85
+ /** @type {boolean} */
86
+ let dirty = false
87
+ /** @type {ReturnType<typeof setTimeout> | null} */
88
+ let flushTimer = null
89
+
90
+ /** 测试可注入的存储 IO;默认走 nodeDir/nostr/relays.json。 */
91
+ let storageIO = {
92
+ /**
93
+ * 读取 relay 持久化数据。
94
+ * @returns {object | null} 已读取的 relay 数据
95
+ */
96
+ read: () => readNostrRelaysJsonSync(),
97
+ /**
98
+ * 写入 relay 持久化数据。
99
+ * @param {object} data relay 数据
100
+ * @returns {void}
101
+ */
102
+ write: data => writeNostrRelaysJsonSync(data),
103
+ }
104
+
105
+ /** NIP-66 引导集覆盖(测试注入)。 */
106
+ let bootstrapRelaysOverride = []
107
+ /** 发现开关(测试可禁用,避免单测触发公网)。 */
108
+ let discoveryEnabled = true
109
+
110
+ /**
111
+ * 规范化 Nostr relay URL(唯一入站入口)。
112
+ * - 仅允许 wss://;`ws://` 仅限回环/私有地址(本地测试/开发)。
113
+ * - hostname 小写,去除默认端口,去除尾部斜杠,保留非空 path。
114
+ * @param {unknown} raw 原始 URL
115
+ * @returns {string | null} 规范化字符串,无效返回 null
116
+ */
117
+ export function normalizeNostrRelayUrl(raw) {
118
+ const value = String(raw || '').trim()
119
+ if (!value) return null
120
+ let url
121
+ try { url = new URL(value) } catch { return null }
122
+ if (url.protocol === 'ws:') {
123
+ const host = url.hostname.toLowerCase().replace(/^\[|]$/g, '')
124
+ const isLoopback = host === 'localhost' || host === '::1' || /^127\./.test(host)
125
+ if (!isLoopback) return null
126
+ }
127
+ else if (url.protocol !== 'wss:') return null
128
+
129
+ if (!url.hostname) return null
130
+ url.hostname = url.hostname.toLowerCase()
131
+ if (url.port && ((url.protocol === 'wss:' && url.port === '443') || (url.protocol === 'ws:' && url.port === '80')))
132
+ url.port = ''
133
+ const path = url.pathname.replace(/\/+$/, '') || '/'
134
+ url.pathname = path
135
+ return url.toString().replace(/\/$/, '')
136
+ }
137
+
138
+ /**
139
+ * 判定规范化 relay URL 是否为公网可达(loopback/私网/链路本地/保留/特殊用途/尾点域名视为非公网)。
140
+ * @param {string} normalizedUrl 已 normalize 的 relay URL
141
+ * @returns {boolean} 公网为 true
142
+ */
143
+ function isPublicRelayUrl(normalizedUrl) {
144
+ let hostname
145
+ try { hostname = new URL(normalizedUrl).hostname.toLowerCase().replace(/^\[|]$/g, '') } catch { return false }
146
+ if (!hostname) return false
147
+ if (hostname.endsWith('.')) return false
148
+ if (['localhost', '::1', '::', '0.0.0.0'].includes(hostname)) return false
149
+ if (/\.(local|internal|lan|home|corp)$/.test(hostname)) return false
150
+ if (/^(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/.test(hostname)) return false
151
+ if (/^(192\.0\.0\.|192\.0\.2\.|198\.(18|19)\.|203\.0\.113\.|198\.51\.100\.|224\.|225\.|226\.|227\.|228\.|229\.|230\.|231\.|232\.|233\.|234\.|235\.|236\.|237\.|238\.|239\.|240\.|241\.|242\.|243\.|244\.|245\.|246\.|247\.|248\.|249\.|250\.|251\.|252\.|253\.|254\.|255\.)/.test(hostname)) return false
152
+ if (/^fe[89ab][\da-f]:/i.test(hostname) || /^f[cd][\da-f]:/i.test(hostname) || /^fec0:/i.test(hostname)) return false
153
+ if (/^2001:db8:/i.test(hostname) || /^ff[\da-f]{2}:/i.test(hostname)) return false
154
+ if (/^::ffff:[\d.a-f]+$/i.test(hostname)) {
155
+ const ipv4 = hostname.replace(/^::ffff:/i, '')
156
+ if (/^(?:\d+\.){3}\d+$/.test(ipv4)) return isPublicRelayUrl(`ws://${ipv4}`)
157
+ return false
158
+ }
159
+ if (/^:{2}(?:\d+\.){3}\d+$/i.test(hostname)) return false
160
+ return true
161
+ }
162
+
163
+ /**
164
+ * 本机显式配置的 relay 集(signaling 配置优先;无节点时按空处理),已 normalize。
165
+ * @returns {string[]} 显式配置的 relay URL
166
+ */
167
+ function locallyAllowedRelayUrls() {
168
+ const configRelay = getSignalingRuntimeConfig().channels?.nostr?.relay
169
+ if (Array.isArray(configRelay) && configRelay.length)
170
+ return dedupeRelayUrls(configRelay.map(normalizeNostrRelayUrl).filter(Boolean))
171
+ try { return dedupeRelayUrls(getNodeTransportSettings().relayUrls.map(normalizeNostrRelayUrl).filter(Boolean)) } catch { return [] }
172
+ }
173
+
174
+ /**
175
+ * 解析 relay host 的全部地址(IP 字面量直接返回自身;hostname 经受控 lookup 解析)。
176
+ * @param {string} relayUrl relay URL(ws/wss/http/https 均可)
177
+ * @returns {Promise<{ hostname: string, addresses: string[] } | null>} 解析结果;解析失败为 null
178
+ */
179
+ export async function lookupRelayHost(relayUrl) {
180
+ let hostname
181
+ try { hostname = new URL(relayUrl).hostname.toLowerCase().replace(/^\[|]$/g, '') } catch { return null }
182
+ if (!hostname || hostname.endsWith('.')) return null
183
+ if (isIP(hostname)) return { hostname, addresses: [hostname] }
184
+ try {
185
+ const records = await lookup(hostname, { all: true })
186
+ if (!records.length) return null
187
+ return { hostname, addresses: records.map(record => record.address) }
188
+ }
189
+ catch { return null }
190
+ }
191
+
192
+ /**
193
+ * 解析 relay host 的 DNS(全部记录须为公网地址)。IP 字面量直接判定;hostname 解析失败视为非公网。
194
+ * @param {string} relayUrl relay URL
195
+ * @returns {Promise<boolean>} 解析到公网地址为 true
196
+ */
197
+ async function relayUrlResolvesPublic(relayUrl) {
198
+ const resolved = await lookupRelayHost(relayUrl)
199
+ if (!resolved) return false
200
+ return resolved.addresses.every(address => isPublicRelayUrl(`ws://${address}`))
201
+ }
202
+
203
+ /**
204
+ * 解析 relay 连接目标:受信集(本机显式配置/引导集/固定)解析任意地址供连接钉 IP;
205
+ * 其余须为公网 hostname 且全部 DNS 记录解析为公网地址。返回的 addresses 用于连接时
206
+ * 钉住已验证的 IP,避免 ws/HTTP 对同一 hostname 做不受控的第二次解析(DNS 重绑定)。
207
+ * @param {string} relayUrl 规范化 relay URL
208
+ * @returns {Promise<{ hostname: string, addresses: string[] } | null>} 连接目标;不允许为 null
209
+ */
210
+ export async function resolveRelayConnectTarget(relayUrl) {
211
+ if (!relayUrl) return null
212
+ const trusted = currentTrustedRelayUrls().has(relayUrl)
213
+ if (!trusted && !isPublicRelayUrl(relayUrl)) return null
214
+ const resolved = await lookupRelayHost(relayUrl)
215
+ if (!resolved) return null
216
+ if (!trusted && !resolved.addresses.every(address => isPublicRelayUrl(`ws://${address}`))) return null
217
+ return resolved
218
+ }
219
+
220
+ /**
221
+ * 注册提供方显式配置的 relay(本机配置来源,视为受信),对规范化 URL 维护引用计数。
222
+ * 仅注册静态 relayUrls;getRelayUrls() 输出的动态集(含 getListenRelays nip66)不入受信集,仍走公网校验。
223
+ * @param {string[]} urls relay URL 列表
224
+ * @returns {() => void} 释放句柄:递减引用计数,仅在无其他提供方引用时从受信集移除
225
+ */
226
+ export function registerProviderTrustedRelayUrls(urls) {
227
+ /** @type {string[]} */
228
+ const normalized = []
229
+ for (const url of dedupeRelayUrls(urls)) {
230
+ const n = normalizeNostrRelayUrl(url)
231
+ if (n && !normalized.includes(n)) normalized.push(n)
232
+ }
233
+ for (const url of normalized)
234
+ providerTrustedRelayUrls.set(url, (providerTrustedRelayUrls.get(url) || 0) + 1)
235
+ return () => {
236
+ for (const url of normalized) {
237
+ const count = (providerTrustedRelayUrls.get(url) || 0) - 1
238
+ if (count <= 0) providerTrustedRelayUrls.delete(url)
239
+ else providerTrustedRelayUrls.set(url, count)
240
+ }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * 当前信任集:本机显式配置 ∪ 提供方显式 relay ∪ NIP-66 引导集(含 pinned)。
246
+ * @returns {Set<string>} 信任的规范化 relay URL
247
+ */
248
+ function currentTrustedRelayUrls() {
249
+ return new Set([
250
+ ...locallyAllowedRelayUrls(),
251
+ ...providerTrustedRelayUrls.keys(),
252
+ ...bootstrapRelaysOverride.length ? bootstrapRelaysOverride : NIP66_BOOTSTRAP_RELAYS,
253
+ ...getPinnedRelays(),
254
+ ])
255
+ }
256
+
257
+ /**
258
+ * 判定该 relay 是否允许作为连接目的地(probe/发布/订阅)。
259
+ * 本机显式配置或 NIP-66 引导集保留信任例外;其余须为公网 hostname 且 DNS 全部解析为公网地址。
260
+ * @param {string} relayUrl 规范化 relay URL
261
+ * @returns {Promise<boolean>} 允许连接为 true
262
+ */
263
+ export async function isRelayDestinationAllowed(relayUrl) {
264
+ if (!relayUrl) return false
265
+ if (currentTrustedRelayUrls().has(relayUrl)) return true
266
+ if (!isPublicRelayUrl(relayUrl)) return false
267
+ return relayUrlResolvesPublic(relayUrl)
268
+ }
269
+
270
+ /**
271
+ * 计算 relay 健康分(越低越优)。
272
+ * @param {Partial<RelayPoolEntry>} entry 条目
273
+ * @returns {number} 健康分
274
+ */
275
+ export function computeRelayHealth(entry) {
276
+ const { rttMs, successCount, failureCount, lastProbe } = entry
277
+ const total = (successCount || 0) + (failureCount || 0)
278
+ const failureRate = total > 0 ? (failureCount || 0) / total : 0
279
+ let rtt = rttMs !== undefined && rttMs !== null ? Number(rttMs) : DEFAULT_RTT_MS
280
+ if (!Number.isFinite(rtt)) rtt = DEFAULT_RTT_MS
281
+ rtt = Math.max(1, Math.min(MAX_RTT_MS, rtt))
282
+ let score = rtt * (1 + failureRate * FAILURE_WEIGHT)
283
+ if (Date.now() - (lastProbe || 0) > PROBE_STALE_MS) score *= STALE_PENALTY
284
+ return score
285
+ }
286
+
287
+ /**
288
+ * @param {RelayPoolEntry} entry 条目
289
+ * @returns {boolean} 是否永不淘汰的条目
290
+ */
291
+ function isPinned(entry) {
292
+ return entry.source === 'public' || entry.source === 'manual'
293
+ }
294
+
295
+ /** @type {Record<string, number>} source 优先级(值大者优先保留)。 */
296
+ const SOURCE_PRIORITY = { manual: 3, public: 2, nip66: 1, peer: 0 }
297
+
298
+ /**
299
+ * @param {RelayPoolEntry[]} entries 条目
300
+ * @returns {RelayPoolEntry[]} 按健康分排序的条目
301
+ */
302
+ function sortByHealth(entries) {
303
+ return [...entries].sort((a, b) => computeRelayHealth(a) - computeRelayHealth(b))
304
+ }
305
+
306
+ /**
307
+ * @param {Iterable<RelayPoolEntry>} entries 条目
308
+ * @returns {RelayPoolEntry[]} 按 URL 去重后的条目
309
+ */
310
+ function dedupeByUrl(entries) {
311
+ const seen = new Set()
312
+ /** @type {RelayPoolEntry[]} */
313
+ const out = []
314
+ for (const entry of entries) {
315
+ if (seen.has(entry.url)) continue
316
+ seen.add(entry.url)
317
+ out.push(entry)
318
+ }
319
+ return out
320
+ }
321
+
322
+ /** 标记内存变更并安排节流写盘。 */
323
+ function markDirty() {
324
+ dirty = true
325
+ if (flushTimer) return
326
+ flushTimer = setTimeout(() => {
327
+ flushTimer = null
328
+ if (dirty) flushRelayState()
329
+ }, FLUSH_DELAY_MS)
330
+ flushTimer.unref?.()
331
+ }
332
+
333
+ /** 立即把池与 peerRoutes 序列化写盘。 */
334
+ function flushRelayState() {
335
+ if (!dirty) return
336
+ dirty = false
337
+ try {
338
+ storageIO.write({
339
+ updatedAt: Date.now(),
340
+ nostrRelays: sortByHealth([...poolEntries.values()]).map(entry => ({ ...entry })),
341
+ peerRoutes: Object.fromEntries(
342
+ [...peerRoutes.entries()].map(([hash, route]) => [hash, { ...route }]),
343
+ ),
344
+ })
345
+ }
346
+ catch { /* ignore persist failure */ }
347
+ }
348
+
349
+ /**
350
+ * 从磁盘加载池与 peerRoutes;文件缺失/畸形返回空并播种公共默认。
351
+ * 返回按健康分升序的数组。
352
+ * @returns {RelayPoolEntry[]} 池条目(健康分升序)
353
+ */
354
+ export function loadRelayPool() {
355
+ let data = null
356
+ try { data = storageIO.read() } catch { data = null }
357
+ const parsed = data && typeof data === 'object' ? data : null
358
+ poolEntries = new Map()
359
+ peerRoutes = new Map()
360
+ if (Array.isArray(parsed?.nostrRelays))
361
+ for (const raw of parsed.nostrRelays) {
362
+ const url = normalizeNostrRelayUrl(raw?.url)
363
+ if (!url) continue
364
+ const entry = toRelayEntry(raw, url)
365
+ if (entry) poolEntries.set(url, entry)
366
+ }
367
+
368
+ if (parsed?.peerRoutes && typeof parsed.peerRoutes === 'object')
369
+ for (const [hash, raw] of Object.entries(parsed.peerRoutes)) {
370
+ const route = normalizePeerRoute(raw)
371
+ if (route) peerRoutes.set(hash, route)
372
+ }
373
+
374
+ seedPublicDefaults()
375
+ return sortByHealth([...poolEntries.values()])
376
+ }
377
+
378
+ /**
379
+ * @param {unknown} raw 原始条目
380
+ * @param {string} url 规范化 URL
381
+ * @returns {RelayPoolEntry | null} 规范化条目或 null
382
+ */
383
+ function toRelayEntry(raw, url) {
384
+ const rttMs = Number(raw?.rttMs)
385
+ return {
386
+ url,
387
+ rttMs: Number.isFinite(rttMs) ? Math.round(rttMs) : null,
388
+ successCount: Math.max(0, Math.floor(Number(raw?.successCount) || 0)),
389
+ failureCount: Math.max(0, Math.floor(Number(raw?.failureCount) || 0)),
390
+ lastSuccess: Number(raw?.lastSuccess) || 0,
391
+ lastFailure: Number(raw?.lastFailure) || 0,
392
+ lastProbe: Number(raw?.lastProbe) || 0,
393
+ firstSeen: Number(raw?.firstSeen) || Date.now(),
394
+ lastSeen: Number(raw?.lastSeen) || Date.now(),
395
+ source: SOURCE_PRIORITY[String(raw?.source || '')] !== undefined ? raw.source : 'nip66',
396
+ nips: Array.isArray(raw?.nips) ? raw.nips.map(String).filter(Boolean) : [],
397
+ clearnet: !!raw?.clearnet,
398
+ monitorCount: Math.max(0, Math.floor(Number(raw?.monitorCount) || 0)),
399
+ }
400
+ }
401
+
402
+ /**
403
+ * 规范化 peer route 中显式出现的字段(缺失字段不出现在结果中)。
404
+ * @param {unknown} raw 原始 peer route
405
+ * @returns {Partial<PeerRoute> | null} 规范化字段或 null
406
+ */
407
+ function normalizePeerRouteFields(raw) {
408
+ if (!raw || typeof raw !== 'object') return null
409
+ /** @type {Partial<PeerRoute>} */
410
+ const normalizedFields = {}
411
+ if (Array.isArray(raw.listenRelays))
412
+ normalizedFields.listenRelays = dedupeRelayUrls(raw.listenRelays.map(normalizeNostrRelayUrl).filter(Boolean))
413
+ if (Array.isArray(raw.peerPool))
414
+ normalizedFields.peerPool = dedupeByUrl(raw.peerPool.map(item => {
415
+ const url = normalizeNostrRelayUrl(item?.url)
416
+ if (!url) return null
417
+ const rtt = Number(item?.rttMs)
418
+ return { url, rttMs: Number.isFinite(rtt) ? Math.round(rtt) : null }
419
+ }).filter(Boolean))
420
+ if (Array.isArray(raw.lastGoodNostrRelays))
421
+ normalizedFields.lastGoodNostrRelays = dedupeRelayUrls(raw.lastGoodNostrRelays.map(normalizeNostrRelayUrl).filter(Boolean)).slice(0, LAST_GOOD_RELAYS_MAX)
422
+ if (raw.lastSeen !== undefined) normalizedFields.lastSeen = Number(raw.lastSeen) || Date.now()
423
+ return normalizedFields
424
+ }
425
+
426
+ /**
427
+ * @param {unknown} raw 原始 peer route
428
+ * @returns {PeerRoute | null} 规范化 route(缺失字段补空默认)或 null
429
+ */
430
+ function normalizePeerRoute(raw) {
431
+ const fields = normalizePeerRouteFields(raw)
432
+ if (!fields) return null
433
+ return {
434
+ listenRelays: fields.listenRelays ?? [],
435
+ peerPool: fields.peerPool ?? [],
436
+ lastGoodNostrRelays: fields.lastGoodNostrRelays ?? [],
437
+ lastSeen: fields.lastSeen ?? Date.now(),
438
+ }
439
+ }
440
+
441
+ /** 首次为空时把公共默认以 source='public' 播种(保证冷启动非空)。 */
442
+ function seedPublicDefaults() {
443
+ for (const url of DEFAULT_RELAY_URLS) {
444
+ const normalized = normalizeNostrRelayUrl(url)
445
+ if (!normalized || poolEntries.has(normalized)) continue
446
+ const now = Date.now()
447
+ poolEntries.set(normalized, {
448
+ url: normalized,
449
+ rttMs: null,
450
+ successCount: 0,
451
+ failureCount: 0,
452
+ lastSuccess: 0,
453
+ lastFailure: 0,
454
+ lastProbe: 0,
455
+ firstSeen: now,
456
+ lastSeen: now,
457
+ source: 'public',
458
+ nips: [],
459
+ clearnet: true,
460
+ monitorCount: 0,
461
+ })
462
+ }
463
+ markDirty()
464
+ }
465
+
466
+ /**
467
+ * 按 url 去重 upsert;合并统计字段。
468
+ * @param {Partial<RelayPoolEntry> & { url: string }} input 新条目
469
+ * @returns {void}
470
+ */
471
+ export function upsertRelay(input) {
472
+ const url = normalizeNostrRelayUrl(input.url)
473
+ if (!url) return
474
+ const existing = poolEntries.get(url)
475
+ if (!existing)
476
+ poolEntries.set(url, toRelayEntry({ ...input, url, firstSeen: Date.now() }, url))
477
+ else {
478
+ const incoming = toRelayEntry({ ...input, url }, url)
479
+ existing.successCount += incoming.successCount
480
+ existing.failureCount += incoming.failureCount
481
+ existing.lastSeen = Date.now()
482
+ if (incoming.lastSuccess) existing.lastSuccess = incoming.lastSuccess
483
+ if (incoming.lastFailure) existing.lastFailure = incoming.lastFailure
484
+ if (incoming.lastProbe) existing.lastProbe = incoming.lastProbe
485
+ if (incoming.rttMs != null) existing.rttMs = incoming.rttMs
486
+ if (incoming.nips.length) existing.nips = incoming.nips
487
+ existing.clearnet = existing.clearnet || incoming.clearnet
488
+ existing.monitorCount = Math.max(existing.monitorCount, incoming.monitorCount)
489
+ if (SOURCE_PRIORITY[incoming.source] > SOURCE_PRIORITY[existing.source])
490
+ existing.source = incoming.source
491
+ }
492
+ enforcePoolCap()
493
+ markDirty()
494
+ }
495
+
496
+ /** 池容量超限时按健康分淘汰最差且非 pinned 的项。 */
497
+ function enforcePoolCap() {
498
+ if (poolEntries.size <= POOL_CAP) return
499
+ const sorted = sortByHealth([...poolEntries.values()])
500
+ let toRemove = poolEntries.size - POOL_CAP
501
+ for (const entry of sorted) {
502
+ if (toRemove <= 0) break
503
+ if (isPinned(entry)) continue
504
+ poolEntries.delete(entry.url)
505
+ nodeDebug('invalidRelayUrl', { url: entry.url, reason: 'pool-cap-evicted' })
506
+ toRemove--
507
+ }
508
+ }
509
+
510
+ /**
511
+ * @param {string} url relay URL
512
+ * @param {number} rttMs 成功 RTT
513
+ * @returns {void}
514
+ */
515
+ export function recordProbeSuccess(url, rttMs) {
516
+ const normalized = normalizeNostrRelayUrl(url)
517
+ if (!normalized) return
518
+ const entry = ensureEntry(normalized)
519
+ if (rttMs != null && Number.isFinite(Number(rttMs))) entry.rttMs = Math.max(0, Math.min(MAX_RTT_MS, Math.round(Number(rttMs))))
520
+ entry.successCount++
521
+ entry.lastSuccess = entry.lastProbe = entry.lastSeen = Date.now()
522
+ markDirty()
523
+ }
524
+
525
+ /**
526
+ * @param {string} url relay URL
527
+ * @returns {void}
528
+ */
529
+ export function recordProbeFailure(url) {
530
+ const normalized = normalizeNostrRelayUrl(url)
531
+ if (!normalized) return
532
+ const entry = ensureEntry(normalized)
533
+ entry.failureCount++
534
+ entry.lastFailure = entry.lastProbe = entry.lastSeen = Date.now()
535
+ markDirty()
536
+ }
537
+
538
+ /**
539
+ * 记录一次发布结果(与探测共用统计)。
540
+ * @param {string} url relay URL
541
+ * @param {boolean} ok 是否成功
542
+ * @returns {void}
543
+ */
544
+ export function recordPublishResult(url, ok) {
545
+ if (ok) recordProbeSuccess(url, null)
546
+ else recordProbeFailure(url)
547
+ }
548
+
549
+ /**
550
+ * 获取(若不存在则创建)条目的变更引用。
551
+ * @param {string} url 规范化 URL
552
+ * @returns {RelayPoolEntry} 条目
553
+ */
554
+ function ensureEntry(url) {
555
+ let entry = poolEntries.get(url)
556
+ if (entry) return entry
557
+ const now = Date.now()
558
+ entry = {
559
+ url,
560
+ rttMs: null,
561
+ successCount: 0,
562
+ failureCount: 0,
563
+ lastSuccess: 0,
564
+ lastFailure: 0,
565
+ lastProbe: 0,
566
+ firstSeen: now,
567
+ lastSeen: now,
568
+ source: 'nip66',
569
+ nips: [],
570
+ clearnet: true,
571
+ monitorCount: 0,
572
+ }
573
+ poolEntries.set(url, entry)
574
+ return entry
575
+ }
576
+
577
+ /** 移除 lastSeen 超过 24h 且非 public/manual 的项。 */
578
+ export function clearStale() {
579
+ const cutoff = Date.now() - PROBE_STALE_MS
580
+ let changed = false
581
+ for (const [url, entry] of [...poolEntries.entries()]) {
582
+ if (isPinned(entry)) continue
583
+ if (entry.lastSeen < cutoff) {
584
+ poolEntries.delete(url)
585
+ changed = true
586
+ }
587
+ }
588
+ if (changed) markDirty()
589
+ }
590
+
591
+ /**
592
+ * @returns {RelayPoolEntry[]} 工作集(健康分升序,含全部 pinned)
593
+ */
594
+ export function getWorkingRelays() {
595
+ const sorted = sortByHealth([...poolEntries.values()])
596
+ const pinned = sorted.filter(isPinned)
597
+ const rest = sorted.filter(entry => !isPinned(entry))
598
+ return dedupeByUrl([...pinned, ...rest.slice(0, Math.max(0, WORKING_RELAYS_COUNT - pinned.length))])
599
+ }
600
+
601
+ /**
602
+ * @returns {RelayPoolEntry[]} 监听/发布子集(含全部 public/manual)
603
+ */
604
+ export function getListenRelays() {
605
+ const working = getWorkingRelays()
606
+ const pinned = working.filter(isPinned)
607
+ const rest = working.filter(entry => !isPinned(entry))
608
+ return dedupeByUrl([...pinned, ...rest.slice(0, Math.max(0, LISTEN_RELAYS_COUNT - pinned.length))])
609
+ }
610
+
611
+ /**
612
+ * @returns {Map<string, RelayPoolEntry>} url → entry
613
+ */
614
+ export function getPoolByUrl() {
615
+ return new Map(poolEntries)
616
+ }
617
+
618
+ /**
619
+ * @param {number} k 数量
620
+ * @param {string[]} [excludeUrls] 排除的 URL
621
+ * @returns {string[]} 健康分最优前 k 个(排除指定 url)
622
+ */
623
+ export function pickTopRelays(k, excludeUrls = []) {
624
+ const excluded = new Set(excludeUrls.map(normalizeNostrRelayUrl).filter(Boolean))
625
+ return sortByHealth([...poolEntries.values()])
626
+ .filter(entry => !excluded.has(entry.url))
627
+ .slice(0, k)
628
+ .map(entry => entry.url)
629
+ }
630
+
631
+ /**
632
+ * @returns {string[]} 所有 source='public'/'manual' 的 URL
633
+ */
634
+ export function getPinnedRelays() {
635
+ return [...poolEntries.values()]
636
+ .filter(isPinned)
637
+ .map(entry => entry.url)
638
+ }
639
+
640
+ /**
641
+ * @param {string} nodeHash 节点 hash
642
+ * @returns {PeerRoute | null} route 或 null
643
+ */
644
+ export function getPeerRoute(nodeHash) {
645
+ const route = peerRoutes.get(nodeHash)
646
+ return route ? { ...route, peerPool: [...route.peerPool], listenRelays: [...route.listenRelays], lastGoodNostrRelays: [...route.lastGoodNostrRelays] } : null
647
+ }
648
+
649
+ /**
650
+ * 更新 peer route(局部 patch),并刷新 lastSeen。
651
+ * 仅规范化并合并 patch 中显式存在的字段,保留已有 listenRelays / peerPool / lastGoodNostrRelays。
652
+ * @param {string} nodeHash 节点 hash
653
+ * @param {Partial<PeerRoute>} patch 补丁
654
+ * @returns {void}
655
+ */
656
+ export function setPeerRoute(nodeHash, patch) {
657
+ if (!nodeHash) return
658
+ const existing = peerRoutes.get(nodeHash)
659
+ const normalized = normalizePeerRouteFields(patch)
660
+ if (!normalized) return
661
+ const route = existing
662
+ ? { ...existing, ...normalized }
663
+ : {
664
+ listenRelays: normalized.listenRelays ?? [],
665
+ peerPool: normalized.peerPool ?? [],
666
+ lastGoodNostrRelays: normalized.lastGoodNostrRelays ?? [],
667
+ lastSeen: normalized.lastSeen ?? Date.now(),
668
+ }
669
+ route.lastSeen = Date.now()
670
+ peerRoutes.set(nodeHash, route)
671
+ markDirty()
672
+ }
673
+
674
+ /**
675
+ * probe 一个 relay:建立连接测 open RTT,读取 NIP-11 信息。成功更新统计。
676
+ * @param {string} url relay URL
677
+ * @param {AbortSignal} [signal] 外部取消信号
678
+ * @returns {Promise<{ url: string, rttMs: number, nips: string[] } | null>} 成功信息或 null
679
+ */
680
+ export async function probeRelay(url, signal) {
681
+ const normalized = normalizeNostrRelayUrl(url)
682
+ if (!normalized) return null
683
+ const connectTarget = await resolveRelayConnectTarget(normalized)
684
+ if (!connectTarget) return null
685
+ const startedAt = Date.now()
686
+ let ws = null
687
+ try {
688
+ ws = await connectRelay(normalized, PROBE_TIMEOUT_MS, signal, connectTarget)
689
+ const rttMs = Date.now() - startedAt
690
+ const info = await queryRelayInfo(normalized, signal)
691
+ recordProbeSuccess(normalized, rttMs)
692
+ return { url: normalized, rttMs, nips: info.nips }
693
+ }
694
+ catch {
695
+ recordProbeFailure(normalized)
696
+ return null
697
+ }
698
+ finally {
699
+ if (ws && ws.readyState !== WebSocket.CLOSED)
700
+ try { ws.terminate() } catch { /* ignore */ }
701
+ }
702
+ }
703
+
704
+ /**
705
+ * 以钉死的地址发起 NIP-11 HTTP(S) GET(避免对已校验 hostname 做不受控的第二次解析)。
706
+ * @param {string} url HTTP(S) 目标
707
+ * @param {string[]} addresses 已校验/受控解析的地址
708
+ * @param {AbortSignal} signal 取消信号
709
+ * @returns {Promise<{ status: number, location: string, body: string }>} 响应概要
710
+ */
711
+ function relayInfoRequest(url, addresses, signal) {
712
+ return new Promise((resolve, reject) => {
713
+ let parsed
714
+ try { parsed = new URL(url) } catch { reject(new Error('nostr: invalid relay info url')); return }
715
+ const isSecure = parsed.protocol === 'https:'
716
+ /**
717
+ * @param {import('node:http').IncomingMessage} response 响应
718
+ * @returns {void}
719
+ */
720
+ const onResponse = response => {
721
+ const chunks = []
722
+ let total = 0
723
+ response.on('data', chunk => {
724
+ total += chunk.length
725
+ if (total > 64 * 1024) {
726
+ response.destroy()
727
+ reject(new Error('nostr: relay info response too large'))
728
+ return
729
+ }
730
+ chunks.push(chunk)
731
+ })
732
+ response.on('error', reject)
733
+ response.on('end', () => resolve({
734
+ status: response.statusCode || 0,
735
+ location: String(response.headers.location || ''),
736
+ body: Buffer.concat(chunks).toString('utf8'),
737
+ }))
738
+ }
739
+ const request = (isSecure ? httpsRequest : httpRequest)({
740
+ hostname: parsed.hostname,
741
+ port: parsed.port || (isSecure ? 443 : 80),
742
+ path: parsed.pathname + parsed.search,
743
+ method: 'GET',
744
+ headers: { Accept: 'application/nostr+json', Host: parsed.host },
745
+ lookup: pinnedLookup(addresses),
746
+ signal,
747
+ }, onResponse)
748
+ request.on('error', reject)
749
+ request.end()
750
+ })
751
+ }
752
+
753
+ /**
754
+ * 拉取单个 relay 的 NIP-11 relay info(max_message_length 已探入池,supported_nips 读取)。
755
+ * @param {string} relayUrl relay URL
756
+ * @param {AbortSignal} [signal] 外部取消信号
757
+ * @returns {Promise<{ nips: string[] }>} NIP-11 信息
758
+ */
759
+ async function queryRelayInfo(relayUrl, signal) {
760
+ const httpUrl = relayUrl.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:')
761
+ const controller = new AbortController()
762
+ /**
763
+ * @returns {void}
764
+ */
765
+ const onAbort = () => controller.abort()
766
+ const timer = setTimeout(() => controller.abort(), 4_000)
767
+ timer.unref?.()
768
+ signal?.addEventListener('abort', onAbort, { once: true })
769
+ if (signal?.aborted) controller.abort()
770
+ const maxRedirects = 5
771
+ try {
772
+ let url = httpUrl
773
+ for (let redirects = 0; ; redirects++) {
774
+ if (redirects > maxRedirects) return { nips: [] }
775
+ const resolved = await lookupRelayHost(url)
776
+ if (!resolved) return { nips: [] }
777
+ const { status, location, body } = await relayInfoRequest(url, resolved.addresses, controller.signal)
778
+ if (status >= 300 && status < 400) {
779
+ if (!location) return { nips: [] }
780
+ let target
781
+ try {
782
+ const targetUrl = new URL(location, url)
783
+ if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') return { nips: [] }
784
+ target = targetUrl.toString()
785
+ }
786
+ catch { return { nips: [] } }
787
+ if (!await relayUrlResolvesPublic(target)) return { nips: [] }
788
+ url = target
789
+ continue
790
+ }
791
+ let info
792
+ try { info = JSON.parse(body) } catch { return { nips: [] } }
793
+ return { nips: Array.isArray(info?.supported_nips) ? info.supported_nips.map(String) : [] }
794
+ }
795
+ }
796
+ catch {
797
+ return { nips: [] }
798
+ }
799
+ finally {
800
+ clearTimeout(timer)
801
+ signal?.removeEventListener('abort', onAbort)
802
+ }
803
+ }
804
+
805
+ /**
806
+ * 解析单个 NIP-66 kind 30166 事件。
807
+ * @param {object} event Nostr 事件
808
+ * @returns {{ url: string, clearnet: boolean, rttMs: number | null, pubkey: string } | null} 解析结果或 null
809
+ */
810
+ export function parseNip66Event(event) {
811
+ const tags = Array.isArray(event?.tags) ? event.tags : []
812
+ const d = (tags.find(tag => tag?.[0] === 'd') || [])[1]
813
+ const url = normalizeNostrRelayUrl(d)
814
+ if (!url) return null
815
+ const n = String((tags.find(tag => tag?.[0] === 'n') || [])[1] || '').toLowerCase()
816
+ const clearnet = n === 'clearnet' || (n === '' && /^wss:\/\//.test(url))
817
+ if (!clearnet) return null
818
+ const N = String((tags.find(tag => tag?.[0] === 'N') || [])[1] || '').trim()
819
+ // N tag 声明时需含 NIP-01;未声明默认接受。
820
+ if (N !== '' && !N.split(',').some(value => value.trim() === '1')) return null
821
+ const rttTag = tags.find(tag => /^rtt-(open|read|write)$/.test(String(tag?.[0] || '')))
822
+ const rttMs = rttTag ? Number(rttTag[1]) : null
823
+ return {
824
+ url,
825
+ clearnet,
826
+ rttMs: Number.isFinite(rttMs) && rttMs != null ? Math.max(0, Math.min(MAX_RTT_MS, Math.round(rttMs))) : null,
827
+ pubkey: String(event?.pubkey || ''),
828
+ }
829
+ }
830
+
831
+ /**
832
+ * 执行一轮 NIP-66 中继发现(可取消)。
833
+ * 连接引导集 + public/manual 兜底,REQ kind 30166 收集候选;所有候选统一 probe,成功才入池。
834
+ * @param {{ signal?: AbortSignal }} [options] 选项
835
+ * @returns {Promise<number>} 新入库的中继数量
836
+ */
837
+ export async function discoverNostrRelays(options = {}) {
838
+ const { signal } = options
839
+ const bootstrap = dedupeRelayUrls([
840
+ ...bootstrapRelaysOverride.length ? bootstrapRelaysOverride : NIP66_BOOTSTRAP_RELAYS,
841
+ ...getPinnedRelays(),
842
+ ])
843
+ if (!bootstrap.length) return 0
844
+
845
+ /** @type {Map<string, { url: string, count: number, rttMs: number | null, pubkey: string }>} */
846
+ const candidates = new Map()
847
+
848
+ await Promise.allSettled(bootstrap.map(async relayUrl => {
849
+ try {
850
+ await collectNip66Events(relayUrl, signal, candidate => {
851
+ const existing = candidates.get(candidate.url)
852
+ if (existing) {
853
+ existing.count++
854
+ if (candidate.pubkey) existing.pubkey = existing.pubkey || candidate.pubkey
855
+ }
856
+ else candidates.set(candidate.url, { url: candidate.url, count: 1, rttMs: candidate.rttMs, pubkey: candidate.pubkey })
857
+ })
858
+ }
859
+ catch { /* skip bad bootstrap */ }
860
+ }))
861
+
862
+ let added = 0
863
+ let probedCount = 0
864
+ const pending = []
865
+ for (const candidate of candidates.values())
866
+ if (await isRelayDestinationAllowed(candidate.url)) pending.push(candidate)
867
+ while (pending.length && probedCount < MAX_NIP66_PROBES_PER_ROUND && !signal?.aborted) {
868
+ const batch = pending.splice(0, Math.min(NIP66_PROBE_BATCH_SIZE, MAX_NIP66_PROBES_PER_ROUND - probedCount))
869
+ probedCount += batch.length
870
+ const results = await Promise.allSettled(batch.map(candidate => probeRelay(candidate.url, signal)))
871
+ for (let index = 0; index < batch.length; index++) {
872
+ const result = results[index]
873
+ if (result.status !== 'fulfilled' || !result.value) continue
874
+ const probed = result.value
875
+ const candidate = batch[index]
876
+ upsertRelay({
877
+ url: probed.url,
878
+ rttMs: probed.rttMs,
879
+ source: 'nip66',
880
+ monitorCount: candidate.count,
881
+ nips: probed.nips,
882
+ clearnet: true,
883
+ lastProbe: Date.now(),
884
+ lastSuccess: Date.now(),
885
+ })
886
+ added++
887
+ }
888
+ }
889
+ return added
890
+ }
891
+
892
+ /**
893
+ * 从单个 NIP-66 中继拉取 kind 30166 事件并回调解析结果(可取消,超时兜底)。
894
+ * @param {string} relayUrl 中继 URL
895
+ * @param {AbortSignal | undefined} signal 取消信号
896
+ * @param {(candidate: { url: string, rttMs: number | null, pubkey: string }) => void} onCandidate 候选回调
897
+ * @returns {Promise<void>}
898
+ */
899
+ async function collectNip66Events(relayUrl, signal, onCandidate) {
900
+ const connectTarget = await resolveRelayConnectTarget(relayUrl)
901
+ if (!connectTarget) return
902
+ const ws = await connectRelay(relayUrl, PROBE_TIMEOUT_MS, signal, connectTarget)
903
+ try {
904
+ const subId = 'nip66-' + Math.random().toString(36).slice(2)
905
+ const filters = [
906
+ { kinds: [30166], limit: NIP66_REQ_LIMIT },
907
+ { kinds: [10166], limit: NIP66_REQ_LIMIT },
908
+ ]
909
+ await new Promise((resolve, reject) => {
910
+ let settled = false
911
+ /**
912
+ * @param {Error | null} [err] 失败原因
913
+ * @returns {void}
914
+ */
915
+ const finish = err => {
916
+ if (settled) return
917
+ settled = true
918
+ clearTimeout(timer)
919
+ signal?.removeEventListener('abort', onAbort)
920
+ ws.off('message', onMessage)
921
+ ws.off('close', onClose)
922
+ ws.off('error', onError)
923
+ if (err) reject(err)
924
+ else resolve()
925
+ }
926
+ /**
927
+ * 处理 Nostr WebSocket 消息。
928
+ * @param {Buffer | ArrayBuffer | Uint8Array | string} data 消息数据
929
+ * @returns {void}
930
+ */
931
+ const onMessage = data => {
932
+ let parsed
933
+ try { parsed = JSON.parse(String(data)) } catch { return }
934
+ if (parsed?.[0] === 'EOSE') { finish(); return }
935
+ if (parsed?.[0] !== 'EVENT') return
936
+ const event = parsed[2]
937
+ const candidate = parseNip66Event(event)
938
+ if (candidate) onCandidate(candidate)
939
+ }
940
+ /**
941
+ * 处理连接关闭。
942
+ * @returns {void}
943
+ */
944
+ const onClose = () => finish(new Error('closed'))
945
+ /**
946
+ * 处理连接错误。
947
+ * @returns {void}
948
+ */
949
+ const onError = () => finish(new Error('error'))
950
+ /**
951
+ * 处理外部取消信号。
952
+ * @returns {void}
953
+ */
954
+ const onAbort = () => finish(new Error('aborted'))
955
+ const timer = setTimeout(() => finish(new Error('nip66 timeout')), PROBE_TIMEOUT_MS)
956
+ timer.unref?.()
957
+ ws.on('message', onMessage)
958
+ ws.once('close', onClose)
959
+ ws.once('error', onError)
960
+ signal?.addEventListener('abort', onAbort, { once: true })
961
+ try { ws.send(JSON.stringify(['REQ', subId, ...filters])) } catch (error) { finish(error) }
962
+ })
963
+ }
964
+ finally {
965
+ if (ws.readyState !== WebSocket.CLOSED)
966
+ try { ws.terminate() } catch { /* ignore */ }
967
+ }
968
+ }
969
+
970
+ /**
971
+ * 启动周期 NIP-66 发现:首次在下个 macrotask 执行(不阻塞 startup),之后每 NIP66_REFRESH_MS。
972
+ * @returns {() => void} 停止函数
973
+ */
974
+ export function startNostrRelayDiscovery() {
975
+ if (!discoveryEnabled) return () => { }
976
+ let stopped = false
977
+ const controller = new AbortController()
978
+ /**
979
+ * @returns {void}
980
+ */
981
+ const run = () => {
982
+ if (stopped) return
983
+ void discoverNostrRelays({ signal: controller.signal }).catch(() => { })
984
+ }
985
+ const firstTimer = setTimeout(run, 0)
986
+ firstTimer.unref?.()
987
+ const timer = setInterval(run, NIP66_REFRESH_MS)
988
+ timer.unref?.()
989
+ return () => {
990
+ stopped = true
991
+ controller.abort()
992
+ clearTimeout(firstTimer)
993
+ clearInterval(timer)
994
+ }
995
+ }
996
+
997
+ /** 立即刷新待写盘状态(测试用)。 */
998
+ export function flushRelayStateNow() {
999
+ if (flushTimer) {
1000
+ clearTimeout(flushTimer)
1001
+ flushTimer = null
1002
+ }
1003
+ flushRelayState()
1004
+ }
1005
+
1006
+ /**
1007
+ * 测试用:注入存储 IO 并复位池状态。
1008
+ * @param {{ read: () => object | null, write: (data: object) => void }} io 存储读写实现
1009
+ * @returns {void}
1010
+ */
1011
+ export function setRelayStorageIOForTests(io) {
1012
+ storageIO = {
1013
+ /**
1014
+ * 读取测试存储。
1015
+ * @returns {object | null} 存储数据
1016
+ */
1017
+ read: () => {
1018
+ try { return io.read() } catch { return null }
1019
+ },
1020
+ /**
1021
+ * 写入测试存储。
1022
+ * @param {object} data 存储数据
1023
+ * @returns {void}
1024
+ */
1025
+ write: data => io.write(data),
1026
+ }
1027
+ resetNostrRelaysForTests()
1028
+ }
1029
+
1030
+ /**
1031
+ * 测试用:禁用/启用 NIP-66 发现(单测避免触发公网)。
1032
+ * @param {boolean} enabled 是否启用发现
1033
+ * @returns {void}
1034
+ */
1035
+ export function setNostrRelayDiscoveryEnabledForTests(enabled) {
1036
+ discoveryEnabled = !!enabled
1037
+ }
1038
+
1039
+ /**
1040
+ * 测试用:覆盖 NIP-66 引导集。
1041
+ * @param {string[]} urls relay URL 列表
1042
+ * @returns {void}
1043
+ */
1044
+ export function setNip66BootstrapRelaysForTests(urls) {
1045
+ bootstrapRelaysOverride = Array.isArray(urls) ? urls.map(normalizeNostrRelayUrl).filter(Boolean) : []
1046
+ }
1047
+
1048
+ /** 测试用:清空池与 peerRoutes、定时器(不重播种)。 */
1049
+ export function clearRelayPoolForTests() {
1050
+ if (flushTimer) {
1051
+ clearTimeout(flushTimer)
1052
+ flushTimer = null
1053
+ }
1054
+ poolEntries = new Map()
1055
+ peerRoutes = new Map()
1056
+ providerTrustedRelayUrls.clear()
1057
+ dirty = false
1058
+ }
1059
+
1060
+ /** 测试用:清空池与 peerRoutes、定时器并重新播种默认。 */
1061
+ export function resetNostrRelaysForTests() {
1062
+ if (flushTimer) {
1063
+ clearTimeout(flushTimer)
1064
+ flushTimer = null
1065
+ }
1066
+ poolEntries = new Map()
1067
+ peerRoutes = new Map()
1068
+ providerTrustedRelayUrls.clear()
1069
+ dirty = false
1070
+ seedPublicDefaults()
1071
+ }