@steve02081504/fount-p2p 0.0.49 → 0.0.51
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/discovery/nostr/constants.mjs +2 -0
- package/discovery/nostr/index.mjs +40 -18
- package/discovery/nostr/relays.mjs +65 -18
- package/discovery/nostr/selection.mjs +1 -3
- package/docs/nostr_relay_discovery.md +15 -9
- package/link/pipe.mjs +1 -0
- package/link/providers/webrtc.mjs +43 -5
- package/package.json +1 -1
- package/transport/runtime_bootstrap.mjs +2 -2
|
@@ -26,6 +26,8 @@ export const STALE_PENALTY = 2
|
|
|
26
26
|
export const NIP66_REFRESH_MS = 6 * 3600 * 1000
|
|
27
27
|
/** 超过此时间未探测视为 stale。 */
|
|
28
28
|
export const PROBE_STALE_MS = 24 * 3600 * 1000
|
|
29
|
+
/** 发布失败后暂时避开 relay,冷却结束后允许再次尝试。 */
|
|
30
|
+
export const PUBLISH_FAILURE_COOLDOWN_MS = 30 * 60 * 1000
|
|
29
31
|
/** 路由退避基数。 */
|
|
30
32
|
export const BACKOFF_BASE_MS = 2000
|
|
31
33
|
/** 路由退避上限。 */
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
getPoolByUrl,
|
|
27
27
|
isRelayDestinationAllowed,
|
|
28
28
|
probeRelay,
|
|
29
|
+
recordPublishResult,
|
|
29
30
|
registerProviderTrustedRelayUrls,
|
|
30
31
|
resolveRelayConnectTarget,
|
|
31
32
|
setPeerRoute,
|
|
@@ -202,6 +203,20 @@ async function signNostrEvent(kind, tags, content, secretKey) {
|
|
|
202
203
|
return { id, pubkey, created_at, kind, tags, content, sig: bytesToHex(await schnorr.sign(hexToBytes(id), secretKey)) }
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
/**
|
|
207
|
+
* 记录一次发布结果并回报同一个布尔值,供 `Promise.any` 直接消费。
|
|
208
|
+
* 调用方已取消时不记:那不是 relay 的过错,不该压它的健康分。
|
|
209
|
+
* @param {string} relayUrl 中继 URL
|
|
210
|
+
* @param {boolean} ok 是否成功
|
|
211
|
+
* @param {AbortSignal} [signal] 取消信号
|
|
212
|
+
* @returns {boolean} ok
|
|
213
|
+
*/
|
|
214
|
+
function recordPublishOutcome(relayUrl, ok, signal) {
|
|
215
|
+
if (signal?.aborted) return ok
|
|
216
|
+
recordPublishResult(relayUrl, ok)
|
|
217
|
+
return ok
|
|
218
|
+
}
|
|
219
|
+
|
|
205
220
|
/**
|
|
206
221
|
* 全量发布到给定 relay:任一成功即返回,其余 relay 的发布留在后台自行结算。
|
|
207
222
|
* 每个 relay 尝试都有界(连接超时 / OK 超时 / 入队等待上限),故后台不会永久悬挂。
|
|
@@ -213,29 +228,36 @@ async function signNostrEvent(kind, tags, content, secretKey) {
|
|
|
213
228
|
async function publishEvent(relayUrls, event, signal) {
|
|
214
229
|
const urls = dedupeRelayUrls(relayUrls)
|
|
215
230
|
if (!urls.length) throw new Error('nostr: no relay')
|
|
216
|
-
|
|
231
|
+
/** @type {Map<string, { hostname: string, addresses: string[] }>} */
|
|
232
|
+
const targets = new Map()
|
|
233
|
+
await Promise.all(urls.map(async relayUrl => {
|
|
217
234
|
try {
|
|
218
|
-
|
|
235
|
+
const connectTarget = await resolveRelayConnectTarget(relayUrl)
|
|
236
|
+
if (connectTarget) targets.set(relayUrl, connectTarget)
|
|
237
|
+
else recordPublishOutcome(relayUrl, false, signal)
|
|
219
238
|
}
|
|
220
|
-
catch {
|
|
221
|
-
|
|
239
|
+
catch (error) {
|
|
240
|
+
recordPublishOutcome(relayUrl, false, signal)
|
|
241
|
+
nodeDebug('p2p:nostr relay target resolve fail', { relay: relayUrl, err: String(error?.message || error) })
|
|
222
242
|
}
|
|
223
|
-
}))
|
|
243
|
+
}))
|
|
244
|
+
if (!targets.size) throw new Error('nostr: no relay')
|
|
224
245
|
let lastError = null
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
lastError = new Error(`nostr: relay rejected publish (${
|
|
230
|
-
|
|
231
|
-
|
|
246
|
+
const accepted = await Promise.any([...targets].map(async ([relayUrl, connectTarget]) => {
|
|
247
|
+
let ok = false
|
|
248
|
+
try {
|
|
249
|
+
ok = await publishViaSharedRelay(relayUrl, event, signal, connectTarget)
|
|
250
|
+
if (!ok) lastError = new Error(`nostr: relay rejected publish (${relayUrl})`)
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (error?.message === 'nostr: aborted') throw error
|
|
232
254
|
lastError = error
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
255
|
+
}
|
|
256
|
+
// 快速失败不掩盖稍后成功的 relay:全部尝试各自结算并更新健康度,任一成功即视为发布成功。
|
|
257
|
+
if (!recordPublishOutcome(relayUrl, ok, signal)) throw new Error('nostr: relay rejected publish')
|
|
258
|
+
return relayUrl
|
|
259
|
+
})).catch(() => null)
|
|
260
|
+
if (accepted) return
|
|
239
261
|
throw lastError || new Error('nostr: no relay accepted publish')
|
|
240
262
|
}
|
|
241
263
|
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
NIP66_REFRESH_MS,
|
|
26
26
|
POOL_CAP,
|
|
27
27
|
PROBE_STALE_MS,
|
|
28
|
+
PUBLISH_FAILURE_COOLDOWN_MS,
|
|
28
29
|
STALE_PENALTY,
|
|
29
30
|
WORKING_RELAYS_COUNT,
|
|
30
31
|
} from './constants.mjs'
|
|
@@ -40,6 +41,8 @@ const NIP66_REQ_LIMIT = 500
|
|
|
40
41
|
const MAX_NIP66_PROBES_PER_ROUND = 48
|
|
41
42
|
/** NIP-66 候选 probe 并发批大小(有界,避免同轮瞬时打满连接)。 */
|
|
42
43
|
const NIP66_PROBE_BATCH_SIZE = 8
|
|
44
|
+
/** 规范化后的 NIP-66 引导集:它们先当发现源,只有发布成功过才进监听/发布子集。 */
|
|
45
|
+
const NIP66_BOOTSTRAP_RELAY_URLS = new Set(NIP66_BOOTSTRAP_RELAYS.map(normalizeNostrRelayUrl).filter(Boolean))
|
|
43
46
|
|
|
44
47
|
/** @typedef {'nip66' | 'public' | 'manual' | 'peer'} RelaySource */
|
|
45
48
|
|
|
@@ -52,6 +55,8 @@ const NIP66_PROBE_BATCH_SIZE = 8
|
|
|
52
55
|
* lastSuccess: number,
|
|
53
56
|
* lastFailure: number,
|
|
54
57
|
* lastProbe: number,
|
|
58
|
+
* lastPublishSuccess: number,
|
|
59
|
+
* lastPublishFailure: number,
|
|
55
60
|
* firstSeen: number,
|
|
56
61
|
* lastSeen: number,
|
|
57
62
|
* source: RelaySource,
|
|
@@ -103,7 +108,7 @@ let discoveryEnabled = true
|
|
|
103
108
|
|
|
104
109
|
/**
|
|
105
110
|
* 规范化 Nostr relay URL(唯一入站入口)。
|
|
106
|
-
* -
|
|
111
|
+
* - `wss://` 不限地址;`ws://` 只接受本机回环(`localhost` / `::1` / `127.x`),私网与公网一律拒绝。
|
|
107
112
|
* - hostname 小写,去除默认端口,去除尾部斜杠,保留非空 path。
|
|
108
113
|
* @param {unknown} raw 原始 URL
|
|
109
114
|
* @returns {string | null} 规范化字符串,无效返回 null
|
|
@@ -250,7 +255,8 @@ function currentTrustedRelayUrls() {
|
|
|
250
255
|
|
|
251
256
|
/**
|
|
252
257
|
* 判定该 relay 是否允许作为连接目的地(probe/发布/订阅)。
|
|
253
|
-
*
|
|
258
|
+
* 受信 relay(本机显式配置、提供方注册、NIP-66 引导集、pinned,见 [currentTrustedRelayUrls])不受公网限制;
|
|
259
|
+
* 其余须为公网 hostname 且 DNS 全部解析为公网地址。
|
|
254
260
|
* @param {string} relayUrl 规范化 relay URL
|
|
255
261
|
* @returns {Promise<boolean>} 允许连接为 true
|
|
256
262
|
*/
|
|
@@ -261,20 +267,32 @@ export async function isRelayDestinationAllowed(relayUrl) {
|
|
|
261
267
|
return relayUrlResolvesPublic(relayUrl)
|
|
262
268
|
}
|
|
263
269
|
|
|
270
|
+
/**
|
|
271
|
+
* 最近一次尝试是否失败。时间戳与 lastSuccess 打平按失败算(同一毫秒内的并发尝试里,成功者会记下更晚的时间戳)。
|
|
272
|
+
* @param {number} lastFailure 最近一次失败时间戳
|
|
273
|
+
* @param {number} lastSuccess 最近一次成功时间戳
|
|
274
|
+
* @returns {boolean} 最近一次尝试失败为 true
|
|
275
|
+
*/
|
|
276
|
+
function latestAttemptFailed(lastFailure, lastSuccess) {
|
|
277
|
+
return lastFailure >= lastSuccess
|
|
278
|
+
}
|
|
279
|
+
|
|
264
280
|
/**
|
|
265
281
|
* 计算 relay 健康分(越低越优)。
|
|
282
|
+
* 最近一次尝试失败(或从未成功过)时 RTT 与失败率都取最差值,分数必定高于任何活着的 relay。
|
|
266
283
|
* @param {Partial<RelayPoolEntry>} entry 条目
|
|
267
284
|
* @returns {number} 健康分
|
|
268
285
|
*/
|
|
269
286
|
export function computeRelayHealth(entry) {
|
|
270
|
-
const { rttMs, successCount, failureCount, lastProbe } = entry
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
287
|
+
const { rttMs, successCount = 0, failureCount = 0, lastProbe = 0, lastSuccess = 0, lastFailure = 0 } = entry
|
|
288
|
+
const failed = failureCount > 0 && (!successCount || latestAttemptFailed(lastFailure, lastSuccess))
|
|
289
|
+
const total = successCount + failureCount
|
|
290
|
+
const failureRate = failed ? 1 : total > 0 ? failureCount / total : 0
|
|
291
|
+
// 失败的 relay 最后一次测量已被作废:没有可信 RTT 可依,按上限算。
|
|
292
|
+
const measuredRtt = failed ? MAX_RTT_MS : rttMs
|
|
293
|
+
const rtt = Math.max(1, Math.min(MAX_RTT_MS, Number.isFinite(measuredRtt) && measuredRtt > 0 ? measuredRtt : DEFAULT_RTT_MS))
|
|
276
294
|
let score = rtt * (1 + failureRate * FAILURE_WEIGHT)
|
|
277
|
-
if (Date.now() -
|
|
295
|
+
if (Date.now() - lastProbe > PROBE_STALE_MS) score *= STALE_PENALTY
|
|
278
296
|
return score
|
|
279
297
|
}
|
|
280
298
|
|
|
@@ -375,7 +393,8 @@ export function loadRelayPool() {
|
|
|
375
393
|
* @returns {RelayPoolEntry | null} 规范化条目或 null
|
|
376
394
|
*/
|
|
377
395
|
function toRelayEntry(raw, url) {
|
|
378
|
-
|
|
396
|
+
// 失败作废的 RTT 落盘为 null:`Number(null)` 是 0,直接取数会把「没有可信 RTT」变成「0ms 最快」。
|
|
397
|
+
const rttMs = raw?.rttMs == null ? null : Number(raw.rttMs)
|
|
379
398
|
return {
|
|
380
399
|
url,
|
|
381
400
|
rttMs: Number.isFinite(rttMs) ? Math.round(rttMs) : null,
|
|
@@ -384,6 +403,8 @@ function toRelayEntry(raw, url) {
|
|
|
384
403
|
lastSuccess: Number(raw?.lastSuccess) || 0,
|
|
385
404
|
lastFailure: Number(raw?.lastFailure) || 0,
|
|
386
405
|
lastProbe: Number(raw?.lastProbe) || 0,
|
|
406
|
+
lastPublishSuccess: Number(raw?.lastPublishSuccess) || 0,
|
|
407
|
+
lastPublishFailure: Number(raw?.lastPublishFailure) || 0,
|
|
387
408
|
firstSeen: Number(raw?.firstSeen) || Date.now(),
|
|
388
409
|
lastSeen: Number(raw?.lastSeen) || Date.now(),
|
|
389
410
|
source: SOURCE_PRIORITY[String(raw?.source || '')] !== undefined ? raw.source : 'nip66',
|
|
@@ -446,6 +467,8 @@ function seedPublicDefaults() {
|
|
|
446
467
|
lastSuccess: 0,
|
|
447
468
|
lastFailure: 0,
|
|
448
469
|
lastProbe: 0,
|
|
470
|
+
lastPublishSuccess: 0,
|
|
471
|
+
lastPublishFailure: 0,
|
|
449
472
|
firstSeen: now,
|
|
450
473
|
lastSeen: now,
|
|
451
474
|
source: 'public',
|
|
@@ -476,6 +499,8 @@ export function upsertRelay(input) {
|
|
|
476
499
|
if (incoming.lastSuccess) existing.lastSuccess = incoming.lastSuccess
|
|
477
500
|
if (incoming.lastFailure) existing.lastFailure = incoming.lastFailure
|
|
478
501
|
if (incoming.lastProbe) existing.lastProbe = incoming.lastProbe
|
|
502
|
+
if (incoming.lastPublishSuccess) existing.lastPublishSuccess = incoming.lastPublishSuccess
|
|
503
|
+
if (incoming.lastPublishFailure) existing.lastPublishFailure = incoming.lastPublishFailure
|
|
479
504
|
if (incoming.rttMs != null) existing.rttMs = incoming.rttMs
|
|
480
505
|
if (incoming.nips.length) existing.nips = incoming.nips
|
|
481
506
|
existing.clearnet = existing.clearnet || incoming.clearnet
|
|
@@ -512,7 +537,7 @@ export function recordProbeSuccess(url, rttMs) {
|
|
|
512
537
|
const entry = ensureEntry(normalized)
|
|
513
538
|
if (rttMs != null && Number.isFinite(Number(rttMs))) entry.rttMs = Math.max(0, Math.min(MAX_RTT_MS, Math.round(Number(rttMs))))
|
|
514
539
|
entry.successCount++
|
|
515
|
-
entry.lastSuccess = entry.lastProbe = entry.lastSeen = Date.now()
|
|
540
|
+
entry.lastSuccess = entry.lastProbe = entry.lastSeen = Math.max(Date.now(), entry.lastProbe + 1)
|
|
516
541
|
markDirty()
|
|
517
542
|
}
|
|
518
543
|
|
|
@@ -525,19 +550,30 @@ export function recordProbeFailure(url) {
|
|
|
525
550
|
if (!normalized) return
|
|
526
551
|
const entry = ensureEntry(normalized)
|
|
527
552
|
entry.failureCount++
|
|
528
|
-
entry.
|
|
553
|
+
entry.rttMs = null
|
|
554
|
+
entry.lastFailure = entry.lastProbe = entry.lastSeen = Math.max(Date.now(), entry.lastProbe + 1)
|
|
529
555
|
markDirty()
|
|
530
556
|
}
|
|
531
557
|
|
|
532
558
|
/**
|
|
533
559
|
* 记录一次发布结果(与探测共用统计)。
|
|
560
|
+
* 成功/失败时间戳必须严格递增:同一毫秒内的并发发布否则无法分出先后,会误判 relay 是否已恢复。
|
|
534
561
|
* @param {string} url relay URL
|
|
535
562
|
* @param {boolean} ok 是否成功
|
|
536
563
|
* @returns {void}
|
|
537
564
|
*/
|
|
538
565
|
export function recordPublishResult(url, ok) {
|
|
539
|
-
|
|
540
|
-
|
|
566
|
+
const normalized = normalizeNostrRelayUrl(url)
|
|
567
|
+
if (!normalized) return
|
|
568
|
+
const entry = ensureEntry(normalized)
|
|
569
|
+
if (ok) {
|
|
570
|
+
entry.lastPublishSuccess = Math.max(Date.now(), entry.lastPublishFailure + 1)
|
|
571
|
+
recordProbeSuccess(normalized, null)
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
entry.lastPublishFailure = Math.max(Date.now(), entry.lastPublishSuccess + 1)
|
|
575
|
+
recordProbeFailure(normalized)
|
|
576
|
+
}
|
|
541
577
|
}
|
|
542
578
|
|
|
543
579
|
/**
|
|
@@ -557,6 +593,8 @@ function ensureEntry(url) {
|
|
|
557
593
|
lastSuccess: 0,
|
|
558
594
|
lastFailure: 0,
|
|
559
595
|
lastProbe: 0,
|
|
596
|
+
lastPublishSuccess: 0,
|
|
597
|
+
lastPublishFailure: 0,
|
|
560
598
|
firstSeen: now,
|
|
561
599
|
lastSeen: now,
|
|
562
600
|
source: 'nip66',
|
|
@@ -583,22 +621,31 @@ export function clearStale() {
|
|
|
583
621
|
}
|
|
584
622
|
|
|
585
623
|
/**
|
|
586
|
-
* @returns {RelayPoolEntry[]}
|
|
624
|
+
* @returns {RelayPoolEntry[]} 工作集(保留未探测引导项,排除失败或过期项,优先 pinned)
|
|
587
625
|
*/
|
|
588
626
|
export function getWorkingRelays() {
|
|
589
|
-
const
|
|
627
|
+
const now = Date.now()
|
|
628
|
+
// 合格项:最近一次尝试成功且没过期,或压根还没探测过的引导项;发布失败在冷却期内一律避开。
|
|
629
|
+
const eligible = [...poolEntries.values()].filter(entry =>
|
|
630
|
+
(entry.lastPublishFailure <= entry.lastPublishSuccess
|
|
631
|
+
|| now - entry.lastPublishFailure >= PUBLISH_FAILURE_COOLDOWN_MS)
|
|
632
|
+
&& (entry.successCount === 0
|
|
633
|
+
? entry.lastFailure === 0
|
|
634
|
+
: !latestAttemptFailed(entry.lastFailure, entry.lastSuccess) && now - entry.lastSuccess <= PROBE_STALE_MS))
|
|
635
|
+
const sorted = sortByHealth(eligible)
|
|
590
636
|
const pinned = sorted.filter(isPinned)
|
|
591
637
|
const rest = sorted.filter(entry => !isPinned(entry))
|
|
592
638
|
return dedupeByUrl([...pinned, ...rest.slice(0, Math.max(0, WORKING_RELAYS_COUNT - pinned.length))])
|
|
593
639
|
}
|
|
594
640
|
|
|
595
641
|
/**
|
|
596
|
-
* @returns {RelayPoolEntry[]}
|
|
642
|
+
* @returns {RelayPoolEntry[]} 监听/发布子集(NIP-66 引导 relay 需先成功发布)
|
|
597
643
|
*/
|
|
598
644
|
export function getListenRelays() {
|
|
599
645
|
const working = getWorkingRelays()
|
|
600
646
|
const pinned = working.filter(isPinned)
|
|
601
|
-
const rest = working.filter(entry => !isPinned(entry)
|
|
647
|
+
const rest = working.filter(entry => !isPinned(entry)
|
|
648
|
+
&& (!NIP66_BOOTSTRAP_RELAY_URLS.has(entry.url) || entry.lastPublishSuccess > 0))
|
|
602
649
|
return dedupeByUrl([...pinned, ...rest.slice(0, Math.max(0, LISTEN_RELAYS_COUNT - pinned.length))])
|
|
603
650
|
}
|
|
604
651
|
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
computeRelayHealth,
|
|
14
14
|
getPeerRoute,
|
|
15
|
-
getPinnedRelays,
|
|
16
15
|
getPoolByUrl,
|
|
17
16
|
getWorkingRelays,
|
|
18
17
|
recordPublishResult,
|
|
@@ -116,7 +115,7 @@ export function expandFromHistory(nodeHash, base, limit) {
|
|
|
116
115
|
|
|
117
116
|
/**
|
|
118
117
|
* 计算到指定节点的当前轮路由目标集。
|
|
119
|
-
* Round 0:对端声称前4(无则本机工作集前4
|
|
118
|
+
* Round 0:对端声称前4(无则本机工作集前4)。
|
|
120
119
|
* Round ≥1:历史成功 + 对端综合分补足 + 加权采样,附退避;总扇出封顶。
|
|
121
120
|
* @param {string} nodeHash 目标节点
|
|
122
121
|
* @param {number} attempt 当前尝试轮次(0 起)
|
|
@@ -139,7 +138,6 @@ export function handshakeTargets(nodeHash, attempt) {
|
|
|
139
138
|
for (const entry of working.slice(0, ROUND0_TARGET_COUNT)) push(entry.url)
|
|
140
139
|
return
|
|
141
140
|
}
|
|
142
|
-
for (const url of getPinnedRelays().slice(0, ROUND0_TARGET_COUNT)) push(url)
|
|
143
141
|
}
|
|
144
142
|
if (attempt <= 0) {
|
|
145
143
|
round0()
|
|
@@ -16,8 +16,12 @@ Source layout (`discovery/nostr/`):
|
|
|
16
16
|
|
|
17
17
|
`relayPool` (all known) → `workingRelays` (top-`WORKING_RELAYS_COUNT` by health) → `listenRelays` (top-`LISTEN_RELAYS_COUNT` working subset used for publish/listen).
|
|
18
18
|
|
|
19
|
-
- `public`/`manual` entries are **pinned**:
|
|
19
|
+
- `public`/`manual` entries are **pinned**: preferred in `working`/`listen` when eligible and never evicted by `clearStale` or pool-cap. Failed or stale pins remain in the pool for retry, but leave the working/listen sets. Pin count may exceed the nominal caps.
|
|
20
20
|
- `nip66`/`peer` entries are disposable: evicted when stale (`PROBE_STALE_MS`) or when the pool exceeds `POOL_CAP`.
|
|
21
|
+
- A relay leaves the working/listen sets when it is **not eligible**: never probed but already failed, or its latest attempt failed, or its last success is older than `PROBE_STALE_MS`. Untested bootstrap entries stay eligible for the working set; exclusion never evicts from the pool, so dead relays keep getting retried.
|
|
22
|
+
- A **publish** outcome is tracked separately from probes (`lastPublishSuccess`/`lastPublishFailure`): a rejected publish keeps the relay out of the working set for `PUBLISH_FAILURE_COOLDOWN_MS` (30min) even if a later connectivity probe succeeds, and a later accepted publish clears it — a relay that answers a WebSocket probe but refuses our events must not keep advertising capacity.
|
|
23
|
+
- The `NIP66_BOOTSTRAP_RELAYS` set is discovery-only: those URLs are `nip66` entries and are not publish/listen targets until one of our publishes is accepted (`lastPublishSuccess > 0`). Ordinary NIP-66 discoveries stay eligible for publish testing.
|
|
24
|
+
- Configured `relayUrls` override the automatic listen subset; adverts publish this resolved subscription set as `listenNostrRelays`.
|
|
21
25
|
- Fresh nodes are seeded with `DEFAULT_RELAY_URLS` (`source: 'public'`) so `listenRelays` is never empty at cold start.
|
|
22
26
|
- Persistent connections stay bounded by `workingRelays` (`WORKING_RELAYS_COUNT`, max 32); pins influence selection, not simultaneous connections.
|
|
23
27
|
|
|
@@ -25,20 +29,22 @@ Source layout (`discovery/nostr/`):
|
|
|
25
29
|
|
|
26
30
|
`normalizeNostrRelayUrl` (in `relays.mjs`) is the only entry for every inbound URL (NIP-66 `d` tag, manual config, peer advert):
|
|
27
31
|
|
|
28
|
-
- `wss://`
|
|
32
|
+
- `wss://` to any host; `ws://` to loopback only (`localhost` / `::1` / `127.x` — local dev/tests). Private and public `ws://` are rejected outright.
|
|
33
|
+
- `isRelayDestinationAllowed` then gates dialling: a **trusted** relay (local config, provider registration, the NIP-66 bootstrap set, or any pinned `public`/`manual` entry) may be private; every other relay must be a public hostname whose DNS resolves entirely to public addresses. So a private `wss://` is only reachable when something pinned or configured it.
|
|
29
34
|
- hostname lowercased, default port removed, trailing slashes removed, non-empty path kept.
|
|
30
35
|
- Invalid → `null` → dropped with an audit log (`nodeDebug('invalidRelayUrl', { url, reason })`), never silently cleaned.
|
|
31
36
|
|
|
32
37
|
## Health score
|
|
33
38
|
|
|
34
39
|
```text
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
score
|
|
40
|
+
dead = latest attempt failed, or the entry never succeeded // ties count as failed
|
|
41
|
+
failureRate = dead ? 1 : failureCount / (successCount + failureCount)
|
|
42
|
+
rtt = clamp(dead ? MAX_RTT_MS : rttMs, 1, MAX_RTT_MS) // DEFAULT_RTT_MS when unknown, zero or invalid
|
|
43
|
+
score = rtt * (1 + failureRate * FAILURE_WEIGHT) // FAILURE_WEIGHT = 4
|
|
44
|
+
score *= STALE_PENALTY // ×2 if lastProbe older than PROBE_STALE_MS
|
|
39
45
|
```
|
|
40
46
|
|
|
41
|
-
Lower is better. `recordProbeSuccess` / `recordProbeFailure` / `recordPublishResult` share the same counters. Writes to `nodeDir/nostr/relays.json` are **throttled** (2s debounce).
|
|
47
|
+
Lower is better. A dead entry (`failureCount > 0` and either no successes or a latest failure) scores with `MAX_RTT_MS`, `failureRate = 1`, and its stale RTT discarded, so it can never outrank a live one. `recordProbeSuccess` / `recordProbeFailure` / `recordPublishResult` share the same counters; attempt timestamps advance monotonically so success/failure ordering survives same-millisecond attempts. The health score itself does not look at publish outcomes — the publish-failure cooldown above is a separate eligibility gate. Writes to `nodeDir/nostr/relays.json` are **throttled** (2s debounce).
|
|
42
48
|
|
|
43
49
|
## Persistence
|
|
44
50
|
|
|
@@ -47,7 +53,7 @@ Lower is better. `recordProbeSuccess` / `recordProbeFailure` / `recordPublishRes
|
|
|
47
53
|
```text
|
|
48
54
|
{
|
|
49
55
|
"updatedAt": 1234567890,
|
|
50
|
-
"nostrRelays": [{ "url", "rttMs", "successCount", "failureCount", "lastSuccess", "lastFailure", "lastProbe", "firstSeen", "lastSeen", "source", "nips", "clearnet", "monitorCount" }],
|
|
56
|
+
"nostrRelays": [{ "url", "rttMs", "successCount", "failureCount", "lastSuccess", "lastFailure", "lastProbe", "lastPublishSuccess", "lastPublishFailure", "firstSeen", "lastSeen", "source", "nips", "clearnet", "monitorCount" }],
|
|
51
57
|
"peerRoutes": { "<nodeHash64>": { "listenRelays", "peerPool", "lastGoodNostrRelays", "lastSeen" } }
|
|
52
58
|
}
|
|
53
59
|
```
|
|
@@ -74,7 +80,7 @@ Lower is better. `recordProbeSuccess` / `recordProbeFailure` / `recordPublishRes
|
|
|
74
80
|
|
|
75
81
|
`handshakeTargets(nodeHash, attempt)`:
|
|
76
82
|
|
|
77
|
-
- **Round 0**: peer-claimed `listenRelays` top 4 by composite score (own health + peer rtt); else local `workingRelays` top 4
|
|
83
|
+
- **Round 0**: peer-claimed `listenRelays` top 4 by composite score (own health + peer rtt); else local `workingRelays` top 4. Empty working sets do not fall back to failed pins.
|
|
78
84
|
- **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).
|
|
79
85
|
- Retries ≤ `MAX_ROUTING_ATTEMPTS` (4).
|
|
80
86
|
|
package/link/pipe.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { LINK_LEVEL_WEBRTC } from './levels.mjs'
|
|
|
22
22
|
export const ICE_CANDIDATE_SETTLE_MS = 300
|
|
23
23
|
/** gathering 状态轮询间隔。 */
|
|
24
24
|
const ICE_GATHERING_POLL_MS = 50
|
|
25
|
+
const ANSWER_SIGNAL_RETRY_DELAYS_MS = [200, 500]
|
|
25
26
|
/** 一个候选都没收到时,等这么久就放行让 DTLS/数据通道自行判成败。 */
|
|
26
27
|
export const ICE_GATHERING_STALL_MS = ms('3s')
|
|
27
28
|
|
|
@@ -142,6 +143,7 @@ export async function createWebRtcLink(options) {
|
|
|
142
143
|
let bulkChannel = null
|
|
143
144
|
let unlistenRemote = null
|
|
144
145
|
let sendQueues = null
|
|
146
|
+
let linkReady = false
|
|
145
147
|
let controlLowEvents = 0
|
|
146
148
|
let bulkLowEvents = 0
|
|
147
149
|
let reconnectCount = 0
|
|
@@ -157,6 +159,32 @@ export async function createWebRtcLink(options) {
|
|
|
157
159
|
await Promise.resolve(options.signal.send(message))
|
|
158
160
|
}
|
|
159
161
|
|
|
162
|
+
/**
|
|
163
|
+
* 发送 answer 信令:暂时性失败按 [ANSWER_SIGNAL_RETRY_DELAYS_MS] 重试。
|
|
164
|
+
* 链路已就绪(重协商)或已被换掉/关闭时不再重试——那时重发只会打到过期的会话。
|
|
165
|
+
* @param {object} message 待发送的 answer
|
|
166
|
+
* @param {() => boolean} isCurrent 该 answer 是否仍属于当前一级与存活的链路
|
|
167
|
+
* @returns {Promise<void>}
|
|
168
|
+
*/
|
|
169
|
+
async function sendAnswerSignal(message, isCurrent) {
|
|
170
|
+
for (let attempt = 0; ; attempt++) {
|
|
171
|
+
if (!isCurrent()) return
|
|
172
|
+
try {
|
|
173
|
+
await sendSignal(message)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
// 过期的 answer(换级/换 pc 或链路已关):直接放弃,不能再靠失败去拆掉当前活链路。
|
|
178
|
+
if (!isCurrent()) return
|
|
179
|
+
if (linkReady) throw error
|
|
180
|
+
const delayMs = ANSWER_SIGNAL_RETRY_DELAYS_MS[attempt]
|
|
181
|
+
if (delayMs == null) throw error
|
|
182
|
+
nodeDebug('p2p:webrtc answer signal retry', { attempt: attempt + 1, reason: formatErrorReason(error) })
|
|
183
|
+
await new Promise(resolve => setTimeout(resolve, delayMs))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
160
188
|
const createPipe = options.createPipe ?? createLinkPipe
|
|
161
189
|
const pipe = createPipe({
|
|
162
190
|
providerId: 'webrtc',
|
|
@@ -216,6 +244,8 @@ export async function createWebRtcLink(options) {
|
|
|
216
244
|
}
|
|
217
245
|
},
|
|
218
246
|
})
|
|
247
|
+
// 数据链路是否已完成握手认证:完成后,发不出去的重协商答复不该再拆掉这条活链路。
|
|
248
|
+
void pipe.ready.then(() => { linkReady = true }, () => { })
|
|
219
249
|
|
|
220
250
|
/**
|
|
221
251
|
* @param {RTCDataChannel} channel RTC 数据通道
|
|
@@ -420,11 +450,19 @@ export async function createWebRtcLink(options) {
|
|
|
420
450
|
candidates: localCandidateCount,
|
|
421
451
|
hasCandidates,
|
|
422
452
|
})
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
453
|
+
try {
|
|
454
|
+
const answerPeerConnection = peerConnection
|
|
455
|
+
await sendAnswerSignal({
|
|
456
|
+
type: 'description',
|
|
457
|
+
rung: rungIndex,
|
|
458
|
+
description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? answer,
|
|
459
|
+
}, () => peerConnection === answerPeerConnection && rungIndex === remoteRung && !pipe.stats().closed)
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
// 已认证的数据链路不应被后续信令发送失败关闭。
|
|
463
|
+
if (!linkReady) throw error
|
|
464
|
+
nodeDebug('p2p:webrtc answer signal send failed after link ready', { reason: formatErrorReason(error) })
|
|
465
|
+
}
|
|
428
466
|
await pipe.maybeSendAuth()
|
|
429
467
|
}
|
|
430
468
|
return
|
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
createNostrDiscoveryProvider,
|
|
16
16
|
resolveNostrRelayUrls,
|
|
17
17
|
} from '../discovery/nostr/index.mjs'
|
|
18
|
-
import {
|
|
18
|
+
import { getWorkingRelays, loadRelayPool, startNostrRelayDiscovery } from '../discovery/nostr/relays.mjs'
|
|
19
19
|
import { createBleGattLinkProvider } from '../link/providers/ble_gatt.mjs'
|
|
20
20
|
import {
|
|
21
21
|
listLinkProviders,
|
|
@@ -188,7 +188,7 @@ export function createRuntimeBootstrap(deps) {
|
|
|
188
188
|
.filter(entry => entry.rttMs != null)
|
|
189
189
|
.slice(0, MAX_ADVERT_RELAY_POOL)
|
|
190
190
|
.map(entry => ({ url: entry.url, rttMs: entry.rttMs })),
|
|
191
|
-
listen:
|
|
191
|
+
listen: resolveNostrRelayUrls(),
|
|
192
192
|
}
|
|
193
193
|
: { pool: [], listen: [] },
|
|
194
194
|
)
|