@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
- const targets = (await Promise.all(urls.map(async relayUrl => {
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
- return { relayUrl, connectTarget: await resolveRelayConnectTarget(relayUrl) }
235
+ const connectTarget = await resolveRelayConnectTarget(relayUrl)
236
+ if (connectTarget) targets.set(relayUrl, connectTarget)
237
+ else recordPublishOutcome(relayUrl, false, signal)
219
238
  }
220
- catch {
221
- return { relayUrl, connectTarget: null }
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
- }))).filter(target => target.connectTarget)
243
+ }))
244
+ if (!targets.size) throw new Error('nostr: no relay')
224
245
  let lastError = null
225
- if (!targets.length) throw new Error('nostr: no relay')
226
- const attempts = targets.map(target => publishViaSharedRelay(target.relayUrl, event, signal, target.connectTarget)
227
- .then(ok => {
228
- if (ok) return true
229
- lastError = new Error(`nostr: relay rejected publish (${target.relayUrl})`)
230
- return false
231
- }, error => {
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
- return false
234
- }))
235
- // 首个成功的 relay 即结算调用方;其余尝试继续在后台跑(失败已就地吞掉,不会变成 unhandled rejection)。
236
- const accepted = await Promise.race(attempts.map((attempt, index) => attempt.then(ok => ok ? index : -1)))
237
- if (accepted >= 0) return
238
- await Promise.all(attempts)
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
- const rttMs = Number(raw?.rttMs)
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
- if (ok) recordProbeSuccess(url, null)
554
- else recordProbeFailure(url)
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.successCount === 0
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[]} 监听/发布子集(仅含工作集内 public/manual)
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
@@ -422,6 +422,7 @@ export function createLinkPipe(options) {
422
422
  stats() {
423
423
  return {
424
424
  ready,
425
+ closed,
425
426
  providerId,
426
427
  level,
427
428
  nodeHash: remoteNodeHash,
@@ -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
- await sendSignal({
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
  // 已认证的数据链路不应被后续信令发送失败关闭。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.50",
3
+ "version": "0.0.51",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",