@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
|
-
* -
|
|
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
|
-
*
|
|
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
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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() -
|
|
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.
|
|
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[]}
|
|
600
|
+
* @returns {RelayPoolEntry[]} 工作集(保留未探测引导项,排除失败或过期项,优先 pinned)
|
|
587
601
|
*/
|
|
588
602
|
export function getWorkingRelays() {
|
|
589
|
-
const
|
|
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[]}
|
|
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
|
|
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**:
|
|
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://`
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
score
|
|
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
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
@@ -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
|
)
|