@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.
- package/AGENTS.md +2 -2
- package/core/entity_id.mjs +6 -6
- package/core/entity_id_parse.mjs +7 -7
- package/core/hexIds.mjs +6 -18
- package/crypto/checkpoint_sign.mjs +4 -4
- package/dag/canonicalize_row.mjs +4 -7
- package/discovery/adverts.mjs +17 -10
- package/discovery/bt/index.mjs +11 -11
- package/discovery/bt/peer_hints.mjs +4 -7
- package/discovery/index.mjs +8 -12
- package/discovery/internal/signal_crypto.mjs +1 -2
- package/discovery/lan.mjs +7 -9
- package/discovery/lan_peer_hints.mjs +5 -8
- package/discovery/nostr/census.mjs +281 -0
- package/discovery/nostr/census_math.mjs +54 -0
- package/discovery/nostr/constants.mjs +43 -0
- package/discovery/nostr/index.mjs +612 -0
- package/discovery/nostr/relays.mjs +1071 -0
- package/discovery/nostr/selection.mjs +224 -0
- package/discovery/nostr/session.mjs +584 -0
- package/discovery/peer_clue.mjs +1 -4
- package/docs/nostr_relay_discovery.md +92 -0
- package/federation/entity_key_chain.mjs +10 -9
- package/federation/message_rate_limit.mjs +1 -3
- package/files/manifest/fetch.mjs +4 -6
- package/files/manifest/normalize.mjs +2 -2
- package/files/manifest/public.mjs +5 -5
- package/governance/branch.mjs +1 -2
- package/governance/join_pow.mjs +2 -4
- package/governance/owner_succession_ballot.mjs +5 -5
- package/index.mjs +11 -4
- package/infra/priority.mjs +1 -2
- package/link/handshake.mjs +153 -41
- package/link/pipe.mjs +1 -2
- package/link/providers/ble_gatt.mjs +2 -3
- package/link/providers/lan_tcp.mjs +2 -3
- package/link/providers/link_id_pipe.mjs +3 -3
- package/link/providers/{nostr.mjs → nostr/index.mjs} +22 -17
- package/mailbox/deliver_or_store.mjs +11 -12
- package/mailbox/prune.mjs +3 -4
- package/mailbox/rate.mjs +3 -2
- package/mailbox/store.mjs +6 -9
- package/node/denylist.mjs +25 -28
- package/node/feature_config.mjs +30 -0
- package/node/identity.mjs +6 -6
- package/node/instance.mjs +21 -0
- package/node/network.mjs +12 -12
- package/node/personal_block.mjs +4 -4
- package/node/reputation_store.mjs +13 -14
- package/node/reputation_sync.mjs +9 -10
- package/node/storage.mjs +23 -0
- package/package.json +10 -4
- package/pages/app.mjs +172 -0
- package/pages/index.html +36 -0
- package/reputation/engine.mjs +13 -16
- package/schemas/discovery.mjs +5 -6
- package/schemas/federation_pull.mjs +7 -7
- package/schemas/mailbox.mjs +3 -3
- package/schemas/part_query.mjs +5 -5
- package/timeline/verify_remote.mjs +7 -7
- package/transport/link_registry.mjs +16 -19
- package/transport/mesh_keepalive.mjs +8 -11
- package/transport/offer_answer.mjs +3 -3
- package/transport/peer_health.mjs +12 -15
- package/transport/runtime_bootstrap.mjs +39 -3
- package/trust_graph/send.mjs +3 -3
- 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
|
+
}
|