@steve02081504/fount-p2p 0.0.49 → 0.0.50

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.
@@ -103,7 +103,7 @@ let discoveryEnabled = true
103
103
 
104
104
  /**
105
105
  * 规范化 Nostr relay URL(唯一入站入口)。
106
- * - 仅允许 wss://;`ws://` 仅限回环/私有地址(本地测试/开发)。
106
+ * - `wss://` 不限地址;`ws://` 只接受本机回环(`localhost` / `::1` / `127.x`),私网与公网一律拒绝。
107
107
  * - hostname 小写,去除默认端口,去除尾部斜杠,保留非空 path。
108
108
  * @param {unknown} raw 原始 URL
109
109
  * @returns {string | null} 规范化字符串,无效返回 null
@@ -250,7 +250,8 @@ function currentTrustedRelayUrls() {
250
250
 
251
251
  /**
252
252
  * 判定该 relay 是否允许作为连接目的地(probe/发布/订阅)。
253
- * 本机显式配置或 NIP-66 引导集保留信任例外;其余须为公网 hostname 且 DNS 全部解析为公网地址。
253
+ * 受信 relay(本机显式配置、提供方注册、NIP-66 引导集、pinned,见 [currentTrustedRelayUrls])不受公网限制;
254
+ * 其余须为公网 hostname 且 DNS 全部解析为公网地址。
254
255
  * @param {string} relayUrl 规范化 relay URL
255
256
  * @returns {Promise<boolean>} 允许连接为 true
256
257
  */
@@ -261,20 +262,32 @@ export async function isRelayDestinationAllowed(relayUrl) {
261
262
  return relayUrlResolvesPublic(relayUrl)
262
263
  }
263
264
 
265
+ /**
266
+ * 最近一次尝试是否失败。时间戳与 lastSuccess 打平按失败算(同一毫秒内的并发尝试里,成功者会记下更晚的时间戳)。
267
+ * @param {number} lastFailure 最近一次失败时间戳
268
+ * @param {number} lastSuccess 最近一次成功时间戳
269
+ * @returns {boolean} 最近一次尝试失败为 true
270
+ */
271
+ function latestAttemptFailed(lastFailure, lastSuccess) {
272
+ return lastFailure >= lastSuccess
273
+ }
274
+
264
275
  /**
265
276
  * 计算 relay 健康分(越低越优)。
277
+ * 最近一次尝试失败(或从未成功过)时 RTT 与失败率都取最差值,分数必定高于任何活着的 relay。
266
278
  * @param {Partial<RelayPoolEntry>} entry 条目
267
279
  * @returns {number} 健康分
268
280
  */
269
281
  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))
282
+ const { rttMs, successCount = 0, failureCount = 0, lastProbe = 0, lastSuccess = 0, lastFailure = 0 } = entry
283
+ const failed = failureCount > 0 && (!successCount || latestAttemptFailed(lastFailure, lastSuccess))
284
+ const total = successCount + failureCount
285
+ const failureRate = failed ? 1 : total > 0 ? failureCount / total : 0
286
+ // 失败的 relay 最后一次测量已被作废:没有可信 RTT 可依,按上限算。
287
+ const measuredRtt = failed ? MAX_RTT_MS : rttMs
288
+ const rtt = Math.max(1, Math.min(MAX_RTT_MS, Number.isFinite(measuredRtt) && measuredRtt > 0 ? measuredRtt : DEFAULT_RTT_MS))
276
289
  let score = rtt * (1 + failureRate * FAILURE_WEIGHT)
277
- if (Date.now() - (lastProbe || 0) > PROBE_STALE_MS) score *= STALE_PENALTY
290
+ if (Date.now() - lastProbe > PROBE_STALE_MS) score *= STALE_PENALTY
278
291
  return score
279
292
  }
280
293
 
@@ -512,7 +525,7 @@ export function recordProbeSuccess(url, rttMs) {
512
525
  const entry = ensureEntry(normalized)
513
526
  if (rttMs != null && Number.isFinite(Number(rttMs))) entry.rttMs = Math.max(0, Math.min(MAX_RTT_MS, Math.round(Number(rttMs))))
514
527
  entry.successCount++
515
- entry.lastSuccess = entry.lastProbe = entry.lastSeen = Date.now()
528
+ entry.lastSuccess = entry.lastProbe = entry.lastSeen = Math.max(Date.now(), entry.lastProbe + 1)
516
529
  markDirty()
517
530
  }
518
531
 
@@ -525,7 +538,8 @@ export function recordProbeFailure(url) {
525
538
  if (!normalized) return
526
539
  const entry = ensureEntry(normalized)
527
540
  entry.failureCount++
528
- entry.lastFailure = entry.lastProbe = entry.lastSeen = Date.now()
541
+ entry.rttMs = null
542
+ entry.lastFailure = entry.lastProbe = entry.lastSeen = Math.max(Date.now(), entry.lastProbe + 1)
529
543
  markDirty()
530
544
  }
531
545
 
@@ -583,17 +597,23 @@ export function clearStale() {
583
597
  }
584
598
 
585
599
  /**
586
- * @returns {RelayPoolEntry[]} 工作集(健康分升序,含全部 pinned)
600
+ * @returns {RelayPoolEntry[]} 工作集(保留未探测引导项,排除失败或过期项,优先 pinned)
587
601
  */
588
602
  export function getWorkingRelays() {
589
- const sorted = sortByHealth([...poolEntries.values()])
603
+ const now = Date.now()
604
+ // 合格项:最近一次尝试成功且没过期,或压根还没探测过的引导项。
605
+ const eligible = [...poolEntries.values()].filter(entry =>
606
+ entry.successCount === 0
607
+ ? entry.lastFailure === 0
608
+ : !latestAttemptFailed(entry.lastFailure, entry.lastSuccess) && now - entry.lastSuccess <= PROBE_STALE_MS)
609
+ const sorted = sortByHealth(eligible)
590
610
  const pinned = sorted.filter(isPinned)
591
611
  const rest = sorted.filter(entry => !isPinned(entry))
592
612
  return dedupeByUrl([...pinned, ...rest.slice(0, Math.max(0, WORKING_RELAYS_COUNT - pinned.length))])
593
613
  }
594
614
 
595
615
  /**
596
- * @returns {RelayPoolEntry[]} 监听/发布子集(含全部 public/manual)
616
+ * @returns {RelayPoolEntry[]} 监听/发布子集(仅含工作集内 public/manual)
597
617
  */
598
618
  export function getListenRelays() {
599
619
  const working = getWorkingRelays()
@@ -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,10 @@ 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; exclusion never evicts from the pool, so dead relays keep getting retried.
22
+ - Configured `relayUrls` override the automatic listen subset; adverts publish this resolved subscription set as `listenNostrRelays`.
21
23
  - Fresh nodes are seeded with `DEFAULT_RELAY_URLS` (`source: 'public'`) so `listenRelays` is never empty at cold start.
22
24
  - Persistent connections stay bounded by `workingRelays` (`WORKING_RELAYS_COUNT`, max 32); pins influence selection, not simultaneous connections.
23
25
 
@@ -25,20 +27,22 @@ Source layout (`discovery/nostr/`):
25
27
 
26
28
  `normalizeNostrRelayUrl` (in `relays.mjs`) is the only entry for every inbound URL (NIP-66 `d` tag, manual config, peer advert):
27
29
 
28
- - `wss://` always; `ws://` only for loopback/private hosts (local dev/tests).
30
+ - `wss://` to any host; `ws://` to loopback only (`localhost` / `::1` / `127.x` — local dev/tests). Private and public `ws://` are rejected outright.
31
+ - `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
32
  - hostname lowercased, default port removed, trailing slashes removed, non-empty path kept.
30
33
  - Invalid → `null` → dropped with an audit log (`nodeDebug('invalidRelayUrl', { url, reason })`), never silently cleaned.
31
34
 
32
35
  ## Health score
33
36
 
34
37
  ```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
38
+ dead = latest attempt failed, or the entry never succeeded // ties count as failed
39
+ failureRate = dead ? 1 : failureCount / (successCount + failureCount)
40
+ rtt = clamp(dead ? MAX_RTT_MS : rttMs, 1, MAX_RTT_MS) // DEFAULT_RTT_MS when unknown, zero or invalid
41
+ score = rtt * (1 + failureRate * FAILURE_WEIGHT) // FAILURE_WEIGHT = 4
42
+ score *= STALE_PENALTY // ×2 if lastProbe older than PROBE_STALE_MS
39
43
  ```
40
44
 
41
- Lower is better. `recordProbeSuccess` / `recordProbeFailure` / `recordPublishResult` share the same counters. Writes to `nodeDir/nostr/relays.json` are **throttled** (2s debounce).
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).
42
46
 
43
47
  ## Persistence
44
48
 
@@ -74,7 +78,7 @@ Lower is better. `recordProbeSuccess` / `recordProbeFailure` / `recordPublishRes
74
78
 
75
79
  `handshakeTargets(nodeHash, attempt)`:
76
80
 
77
- - **Round 0**: peer-claimed `listenRelays` top 4 by composite score (own health + peer rtt); else local `workingRelays` top 4; else pinned top 4.
81
+ - **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
82
  - **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
83
  - Retries ≤ `MAX_ROUTING_ATTEMPTS` (4).
80
84
 
@@ -142,6 +142,7 @@ export async function createWebRtcLink(options) {
142
142
  let bulkChannel = null
143
143
  let unlistenRemote = null
144
144
  let sendQueues = null
145
+ let linkReady = false
145
146
  let controlLowEvents = 0
146
147
  let bulkLowEvents = 0
147
148
  let reconnectCount = 0
@@ -216,6 +217,8 @@ export async function createWebRtcLink(options) {
216
217
  }
217
218
  },
218
219
  })
220
+ // 数据链路是否已完成握手认证:完成后,发不出去的重协商答复不该再拆掉这条活链路。
221
+ void pipe.ready.then(() => { linkReady = true }, () => { })
219
222
 
220
223
  /**
221
224
  * @param {RTCDataChannel} channel RTC 数据通道
@@ -420,11 +423,18 @@ export async function createWebRtcLink(options) {
420
423
  candidates: localCandidateCount,
421
424
  hasCandidates,
422
425
  })
423
- await sendSignal({
424
- type: 'description',
425
- rung: rungIndex,
426
- description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? answer,
427
- })
426
+ try {
427
+ await sendSignal({
428
+ type: 'description',
429
+ rung: rungIndex,
430
+ description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? answer,
431
+ })
432
+ }
433
+ catch (error) {
434
+ // 已认证的数据链路不应被后续信令发送失败关闭。
435
+ if (!linkReady) throw error
436
+ nodeDebug('p2p:webrtc answer signal send failed after link ready', { reason: formatErrorReason(error) })
437
+ }
428
438
  await pipe.maybeSendAuth()
429
439
  }
430
440
  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.50",
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
  )