@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.
@@ -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,
@@ -103,7 +108,7 @@ let discoveryEnabled = true
103
108
 
104
109
  /**
105
110
  * 规范化 Nostr relay URL(唯一入站入口)。
106
- * - 仅允许 wss://;`ws://` 仅限回环/私有地址(本地测试/开发)。
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
- * 本机显式配置或 NIP-66 引导集保留信任例外;其余须为公网 hostname 且 DNS 全部解析为公网地址。
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 total = (successCount || 0) + (failureCount || 0)
272
- const failureRate = total > 0 ? (failureCount || 0) / total : 0
273
- let rtt = rttMs !== undefined && rttMs !== null ? Number(rttMs) : DEFAULT_RTT_MS
274
- if (!Number.isFinite(rtt)) rtt = DEFAULT_RTT_MS
275
- rtt = Math.max(1, Math.min(MAX_RTT_MS, rtt))
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() - (lastProbe || 0) > PROBE_STALE_MS) score *= STALE_PENALTY
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
- const rttMs = Number(raw?.rttMs)
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.lastFailure = entry.lastProbe = entry.lastSeen = Date.now()
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
- if (ok) recordProbeSuccess(url, null)
540
- 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
+ }
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[]} 工作集(健康分升序,含全部 pinned)
624
+ * @returns {RelayPoolEntry[]} 工作集(保留未探测引导项,排除失败或过期项,优先 pinned)
587
625
  */
588
626
  export function getWorkingRelays() {
589
- const sorted = sortByHealth([...poolEntries.values()])
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[]} 监听/发布子集(含全部 public/manual)
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,再空则 pinned)。
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**: always in `working`/`listen` and never evicted by `clearStale` or pool-cap. Pin count may exceed the nominal caps.
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://` always; `ws://` only for loopback/private hosts (local dev/tests).
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
- failureRate = failureCount / (successCount + failureCount)
36
- rtt = clamp(rttMs ?? DEFAULT_RTT_MS, 1, MAX_RTT_MS)
37
- score = rtt * (1 + failureRate * FAILURE_WEIGHT) // FAILURE_WEIGHT = 4
38
- score *= STALE_PENALTY // ×2 if lastProbe older than PROBE_STALE_MS
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; else pinned 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
@@ -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
 
@@ -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
- await sendSignal({
424
- type: 'description',
425
- rung: rungIndex,
426
- description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? answer,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.49",
3
+ "version": "0.0.51",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -15,7 +15,7 @@ import {
15
15
  createNostrDiscoveryProvider,
16
16
  resolveNostrRelayUrls,
17
17
  } from '../discovery/nostr/index.mjs'
18
- import { getListenRelays, getWorkingRelays, loadRelayPool, startNostrRelayDiscovery } from '../discovery/nostr/relays.mjs'
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: getListenRelays().map(entry => entry.url),
191
+ listen: resolveNostrRelayUrls(),
192
192
  }
193
193
  : { pool: [], listen: [] },
194
194
  )