@steve02081504/fount-p2p 0.0.50 → 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.
|
@@ -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,
|
|
@@ -388,7 +393,8 @@ export function loadRelayPool() {
|
|
|
388
393
|
* @returns {RelayPoolEntry | null} 规范化条目或 null
|
|
389
394
|
*/
|
|
390
395
|
function toRelayEntry(raw, url) {
|
|
391
|
-
|
|
396
|
+
// 失败作废的 RTT 落盘为 null:`Number(null)` 是 0,直接取数会把「没有可信 RTT」变成「0ms 最快」。
|
|
397
|
+
const rttMs = raw?.rttMs == null ? null : Number(raw.rttMs)
|
|
392
398
|
return {
|
|
393
399
|
url,
|
|
394
400
|
rttMs: Number.isFinite(rttMs) ? Math.round(rttMs) : null,
|
|
@@ -397,6 +403,8 @@ function toRelayEntry(raw, url) {
|
|
|
397
403
|
lastSuccess: Number(raw?.lastSuccess) || 0,
|
|
398
404
|
lastFailure: Number(raw?.lastFailure) || 0,
|
|
399
405
|
lastProbe: Number(raw?.lastProbe) || 0,
|
|
406
|
+
lastPublishSuccess: Number(raw?.lastPublishSuccess) || 0,
|
|
407
|
+
lastPublishFailure: Number(raw?.lastPublishFailure) || 0,
|
|
400
408
|
firstSeen: Number(raw?.firstSeen) || Date.now(),
|
|
401
409
|
lastSeen: Number(raw?.lastSeen) || Date.now(),
|
|
402
410
|
source: SOURCE_PRIORITY[String(raw?.source || '')] !== undefined ? raw.source : 'nip66',
|
|
@@ -459,6 +467,8 @@ function seedPublicDefaults() {
|
|
|
459
467
|
lastSuccess: 0,
|
|
460
468
|
lastFailure: 0,
|
|
461
469
|
lastProbe: 0,
|
|
470
|
+
lastPublishSuccess: 0,
|
|
471
|
+
lastPublishFailure: 0,
|
|
462
472
|
firstSeen: now,
|
|
463
473
|
lastSeen: now,
|
|
464
474
|
source: 'public',
|
|
@@ -489,6 +499,8 @@ export function upsertRelay(input) {
|
|
|
489
499
|
if (incoming.lastSuccess) existing.lastSuccess = incoming.lastSuccess
|
|
490
500
|
if (incoming.lastFailure) existing.lastFailure = incoming.lastFailure
|
|
491
501
|
if (incoming.lastProbe) existing.lastProbe = incoming.lastProbe
|
|
502
|
+
if (incoming.lastPublishSuccess) existing.lastPublishSuccess = incoming.lastPublishSuccess
|
|
503
|
+
if (incoming.lastPublishFailure) existing.lastPublishFailure = incoming.lastPublishFailure
|
|
492
504
|
if (incoming.rttMs != null) existing.rttMs = incoming.rttMs
|
|
493
505
|
if (incoming.nips.length) existing.nips = incoming.nips
|
|
494
506
|
existing.clearnet = existing.clearnet || incoming.clearnet
|
|
@@ -545,13 +557,23 @@ export function recordProbeFailure(url) {
|
|
|
545
557
|
|
|
546
558
|
/**
|
|
547
559
|
* 记录一次发布结果(与探测共用统计)。
|
|
560
|
+
* 成功/失败时间戳必须严格递增:同一毫秒内的并发发布否则无法分出先后,会误判 relay 是否已恢复。
|
|
548
561
|
* @param {string} url relay URL
|
|
549
562
|
* @param {boolean} ok 是否成功
|
|
550
563
|
* @returns {void}
|
|
551
564
|
*/
|
|
552
565
|
export function recordPublishResult(url, ok) {
|
|
553
|
-
|
|
554
|
-
|
|
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
|
+
}
|
|
555
577
|
}
|
|
556
578
|
|
|
557
579
|
/**
|
|
@@ -571,6 +593,8 @@ function ensureEntry(url) {
|
|
|
571
593
|
lastSuccess: 0,
|
|
572
594
|
lastFailure: 0,
|
|
573
595
|
lastProbe: 0,
|
|
596
|
+
lastPublishSuccess: 0,
|
|
597
|
+
lastPublishFailure: 0,
|
|
574
598
|
firstSeen: now,
|
|
575
599
|
lastSeen: now,
|
|
576
600
|
source: 'nip66',
|
|
@@ -601,11 +625,13 @@ export function clearStale() {
|
|
|
601
625
|
*/
|
|
602
626
|
export function getWorkingRelays() {
|
|
603
627
|
const now = Date.now()
|
|
604
|
-
//
|
|
628
|
+
// 合格项:最近一次尝试成功且没过期,或压根还没探测过的引导项;发布失败在冷却期内一律避开。
|
|
605
629
|
const eligible = [...poolEntries.values()].filter(entry =>
|
|
606
|
-
entry.
|
|
630
|
+
(entry.lastPublishFailure <= entry.lastPublishSuccess
|
|
631
|
+
|| now - entry.lastPublishFailure >= PUBLISH_FAILURE_COOLDOWN_MS)
|
|
632
|
+
&& (entry.successCount === 0
|
|
607
633
|
? entry.lastFailure === 0
|
|
608
|
-
: !latestAttemptFailed(entry.lastFailure, entry.lastSuccess) && now - entry.lastSuccess <= PROBE_STALE_MS)
|
|
634
|
+
: !latestAttemptFailed(entry.lastFailure, entry.lastSuccess) && now - entry.lastSuccess <= PROBE_STALE_MS))
|
|
609
635
|
const sorted = sortByHealth(eligible)
|
|
610
636
|
const pinned = sorted.filter(isPinned)
|
|
611
637
|
const rest = sorted.filter(entry => !isPinned(entry))
|
|
@@ -613,12 +639,13 @@ export function getWorkingRelays() {
|
|
|
613
639
|
}
|
|
614
640
|
|
|
615
641
|
/**
|
|
616
|
-
* @returns {RelayPoolEntry[]}
|
|
642
|
+
* @returns {RelayPoolEntry[]} 监听/发布子集(NIP-66 引导 relay 需先成功发布)
|
|
617
643
|
*/
|
|
618
644
|
export function getListenRelays() {
|
|
619
645
|
const working = getWorkingRelays()
|
|
620
646
|
const pinned = working.filter(isPinned)
|
|
621
|
-
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))
|
|
622
649
|
return dedupeByUrl([...pinned, ...rest.slice(0, Math.max(0, LISTEN_RELAYS_COUNT - pinned.length))])
|
|
623
650
|
}
|
|
624
651
|
|
|
@@ -18,7 +18,9 @@ Source layout (`discovery/nostr/`):
|
|
|
18
18
|
|
|
19
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; exclusion never evicts from the pool, so dead relays keep getting retried.
|
|
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.
|
|
22
24
|
- Configured `relayUrls` override the automatic listen subset; adverts publish this resolved subscription set as `listenNostrRelays`.
|
|
23
25
|
- Fresh nodes are seeded with `DEFAULT_RELAY_URLS` (`source: 'public'`) so `listenRelays` is never empty at cold start.
|
|
24
26
|
- Persistent connections stay bounded by `workingRelays` (`WORKING_RELAYS_COUNT`, max 32); pins influence selection, not simultaneous connections.
|
|
@@ -42,7 +44,7 @@ score = rtt * (1 + failureRate * FAILURE_WEIGHT) // FAILURE_WE
|
|
|
42
44
|
score *= STALE_PENALTY // ×2 if lastProbe older than PROBE_STALE_MS
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
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. 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).
|
|
46
48
|
|
|
47
49
|
## Persistence
|
|
48
50
|
|
|
@@ -51,7 +53,7 @@ Lower is better. A dead entry (`failureCount > 0` and either no successes or a l
|
|
|
51
53
|
```text
|
|
52
54
|
{
|
|
53
55
|
"updatedAt": 1234567890,
|
|
54
|
-
"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" }],
|
|
55
57
|
"peerRoutes": { "<nodeHash64>": { "listenRelays", "peerPool", "lastGoodNostrRelays", "lastSeen" } }
|
|
56
58
|
}
|
|
57
59
|
```
|
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
|
|
|
@@ -158,6 +159,32 @@ export async function createWebRtcLink(options) {
|
|
|
158
159
|
await Promise.resolve(options.signal.send(message))
|
|
159
160
|
}
|
|
160
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
|
+
|
|
161
188
|
const createPipe = options.createPipe ?? createLinkPipe
|
|
162
189
|
const pipe = createPipe({
|
|
163
190
|
providerId: 'webrtc',
|
|
@@ -424,11 +451,12 @@ export async function createWebRtcLink(options) {
|
|
|
424
451
|
hasCandidates,
|
|
425
452
|
})
|
|
426
453
|
try {
|
|
427
|
-
|
|
454
|
+
const answerPeerConnection = peerConnection
|
|
455
|
+
await sendAnswerSignal({
|
|
428
456
|
type: 'description',
|
|
429
457
|
rung: rungIndex,
|
|
430
458
|
description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? answer,
|
|
431
|
-
})
|
|
459
|
+
}, () => peerConnection === answerPeerConnection && rungIndex === remoteRung && !pipe.stats().closed)
|
|
432
460
|
}
|
|
433
461
|
catch (error) {
|
|
434
462
|
// 已认证的数据链路不应被后续信令发送失败关闭。
|