@steve02081504/fount-p2p 0.0.44 → 0.0.48
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.
- package/AGENTS.md +17 -4
- package/dag/storage.mjs +157 -79
- package/discovery/nostr/census.mjs +5 -5
- package/discovery/nostr/census_monitor.mjs +2 -2
- package/discovery/nostr/constants.mjs +1 -0
- package/discovery/nostr/index.mjs +24 -12
- package/discovery/nostr/session.mjs +96 -12
- package/docs/nostr_relay_discovery.md +2 -0
- package/docs/signaling.md +15 -3
- package/docs/transports.md +15 -1
- package/federation/part_query/runtime.mjs +4 -4
- package/index.mjs +5 -0
- package/link/providers/ble_gatt.mjs +2 -2
- package/link/providers/index.mjs +2 -1
- package/link/providers/nostr/index.mjs +19 -6
- package/link/providers/webrtc.mjs +235 -56
- package/link/rtc/polyfill.mjs +9 -6
- package/mailbox/store.mjs +3 -22
- package/node/signaling_config.mjs +30 -8
- package/node/verification.mjs +163 -0
- package/package.json +4 -2
- package/sim/AGENTS.md +1 -1
- package/sim/{cold_start.md → docs/cold_start.md} +1 -1
- package/timeline/prune.mjs +27 -23
- package/timeline/retention.mjs +38 -34
- package/transport/link_registry.mjs +75 -6
- package/transport/peer_health.mjs +43 -25
- package/transport/runtime_bootstrap.mjs +44 -9
- package/utils/async_mutex.mjs +11 -4
- package/utils/shuffle.mjs +1 -1
- package/wire/adapter.mjs +1 -1
- package/pages/index.html +0 -36
- package/pages/index.mjs +0 -62
|
@@ -16,6 +16,24 @@ const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
|
|
|
16
16
|
const NOSTR_RECONNECT_DELAY_MS = 500
|
|
17
17
|
/** 无 sub/publish 工作时共享 relay 空闲回收延迟(给连续 send 复用窗口)。 */
|
|
18
18
|
const NOSTR_IDLE_DROP_MS = 2_000
|
|
19
|
+
/**
|
|
20
|
+
* 已入队 publish 的等待上限(从入队起算,覆盖多次「连不上 → 退避重连」)。
|
|
21
|
+
* 连接超时 2s + 重连间隔 0.5s ≈ 2.5s/轮,20s 约等于 8 轮;连不上的 relay 必须结算,
|
|
22
|
+
* 否则请求永久留在 pendingPublishes 里(见 fount-p2p#37)。
|
|
23
|
+
*/
|
|
24
|
+
export const NOSTR_QUEUED_PUBLISH_DEADLINE_MS = 20_000
|
|
25
|
+
|
|
26
|
+
/** 测试可缩短的入队 publish 等待上限。 */
|
|
27
|
+
let queuedPublishDeadlineMs = NOSTR_QUEUED_PUBLISH_DEADLINE_MS
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 覆盖入队 publish 等待上限(测试用;传 null 恢复默认)。
|
|
31
|
+
* @param {number | null} value 毫秒上限;null 恢复默认
|
|
32
|
+
* @returns {void}
|
|
33
|
+
*/
|
|
34
|
+
export function setQueuedPublishDeadlineMsForTests(value) {
|
|
35
|
+
queuedPublishDeadlineMs = value == null ? NOSTR_QUEUED_PUBLISH_DEADLINE_MS : value
|
|
36
|
+
}
|
|
19
37
|
|
|
20
38
|
/**
|
|
21
39
|
* @param {string[] | undefined | null} urls 原始列表
|
|
@@ -216,6 +234,7 @@ function publishEventOnRelay(ws, relayUrl, event, signal, isCurrent) {
|
|
|
216
234
|
* attempt: number,
|
|
217
235
|
* onAbort: (() => void) | null,
|
|
218
236
|
* removeAbort: (() => void) | null,
|
|
237
|
+
* deadline: ReturnType<typeof setTimeout> | null,
|
|
219
238
|
* resolve: (ok: boolean) => void,
|
|
220
239
|
* reject: (err: Error) => void,
|
|
221
240
|
* }} NostrPublishRequest
|
|
@@ -326,7 +345,7 @@ function attachSharedRelaySocket(relayUrl, session, ws) {
|
|
|
326
345
|
if (session.inflightPublishes.length) {
|
|
327
346
|
session.pendingPublishes.push(...session.inflightPublishes)
|
|
328
347
|
for (const publishRequest of session.inflightPublishes)
|
|
329
|
-
attachQueuedAbort(session, publishRequest)
|
|
348
|
+
attachQueuedAbort(relayUrl, session, publishRequest)
|
|
330
349
|
session.inflightPublishes = []
|
|
331
350
|
}
|
|
332
351
|
if (!hasPendingWork(session)) {
|
|
@@ -465,6 +484,8 @@ function flushPendingPublishes(relayUrl, session, socket) {
|
|
|
465
484
|
publishRequest.removeAbort?.()
|
|
466
485
|
publishRequest.onAbort = null
|
|
467
486
|
publishRequest.removeAbort = null
|
|
487
|
+
// 请求已进入实际发送:等待上限改由本次 send 的 OK 超时负责。
|
|
488
|
+
clearQueuedDeadline(publishRequest)
|
|
468
489
|
const attempt = ++publishRequest.attempt
|
|
469
490
|
session.inflightPublishes.push(publishRequest)
|
|
470
491
|
void publishEventOnRelay(socket, relayUrl, publishRequest.event, publishRequest.signal, () => publishRequest.attempt === attempt)
|
|
@@ -475,23 +496,83 @@ function flushPendingPublishes(relayUrl, session, socket) {
|
|
|
475
496
|
}
|
|
476
497
|
}
|
|
477
498
|
|
|
499
|
+
/**
|
|
500
|
+
* 清除待发请求的等待上限定时器。
|
|
501
|
+
* @param {NostrPublishRequest} publishRequest 待发请求
|
|
502
|
+
* @returns {void}
|
|
503
|
+
*/
|
|
504
|
+
function clearQueuedDeadline(publishRequest) {
|
|
505
|
+
if (!publishRequest.deadline) return
|
|
506
|
+
clearTimeout(publishRequest.deadline)
|
|
507
|
+
publishRequest.deadline = null
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* 把一个尚未发出的 publish 移出队列并清掉它的等待上限。
|
|
512
|
+
* 队列已在别处清空(已 flush 到 socket / 已 abort)时返回 false。
|
|
513
|
+
* @param {string} relayUrl 中继 URL
|
|
514
|
+
* @param {SharedRelaySession} session 会话
|
|
515
|
+
* @param {NostrPublishRequest} publishRequest 待发请求
|
|
516
|
+
* @returns {boolean} 是否确实已出队
|
|
517
|
+
*/
|
|
518
|
+
function dequeueQueuedPublish(relayUrl, session, publishRequest) {
|
|
519
|
+
const pendingIndex = session.pendingPublishes.indexOf(publishRequest)
|
|
520
|
+
if (pendingIndex < 0) return false
|
|
521
|
+
clearQueuedDeadline(publishRequest)
|
|
522
|
+
publishRequest.removeAbort?.()
|
|
523
|
+
publishRequest.onAbort = null
|
|
524
|
+
publishRequest.removeAbort = null
|
|
525
|
+
session.pendingPublishes.splice(pendingIndex, 1)
|
|
526
|
+
// 这条 relay 已无任何工作:立刻回收,别把连不上的 socket / 重连循环留在进程里。
|
|
527
|
+
if (!hasPendingWork(session)) {
|
|
528
|
+
sharedRelaySessions.delete(relayUrl)
|
|
529
|
+
clearSharedRelayReconnect(session)
|
|
530
|
+
if (session.ws) dropWebSocket(session.ws)
|
|
531
|
+
session.ws = null
|
|
532
|
+
}
|
|
533
|
+
return true
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* 为队列中的 publish 挂上等待上限:从入队起算,超时即 reject 并出队。
|
|
538
|
+
* 只覆盖「尚未拿到可用 socket」的阶段;已 flush 到 open socket 的请求由 publishEventOnRelay 的 OK 超时负责。
|
|
539
|
+
* 定时器跨断线重发保留(请求在 pending ↔ inflight 间移动时不重置),故僵尸请求的总等待有界。
|
|
540
|
+
* @param {string} relayUrl 中继 URL
|
|
541
|
+
* @param {SharedRelaySession} session 会话
|
|
542
|
+
* @param {NostrPublishRequest} publishRequest 待发请求
|
|
543
|
+
* @returns {void}
|
|
544
|
+
*/
|
|
545
|
+
function attachQueuedDeadline(relayUrl, session, publishRequest) {
|
|
546
|
+
clearQueuedDeadline(publishRequest)
|
|
547
|
+
if (queuedPublishDeadlineMs <= 0) return
|
|
548
|
+
const timer = setTimeout(() => {
|
|
549
|
+
publishRequest.deadline = null
|
|
550
|
+
if (!dequeueQueuedPublish(relayUrl, session, publishRequest)) return
|
|
551
|
+
nodeDebug('p2p:nostr publish deadline', {
|
|
552
|
+
url: relayUrl,
|
|
553
|
+
err: `nostr: connect timeout for ${relayUrl}`,
|
|
554
|
+
})
|
|
555
|
+
publishRequest.reject(new Error(`nostr: connect timeout for ${relayUrl}`))
|
|
556
|
+
}, queuedPublishDeadlineMs)
|
|
557
|
+
timer.unref?.()
|
|
558
|
+
publishRequest.deadline = timer
|
|
559
|
+
}
|
|
560
|
+
|
|
478
561
|
/**
|
|
479
562
|
* 为队列中的 publish 挂上 abort 处理:先移除监听器(释放 signal 对回调/event/session 的引用),
|
|
480
563
|
* 再从队列删除并 reject。
|
|
564
|
+
* @param {string} relayUrl 中继 URL
|
|
481
565
|
* @param {SharedRelaySession} session 会话
|
|
482
566
|
* @param {SharedRelaySession['pendingPublishes'][number]} publishRequest 待发请求
|
|
483
567
|
* @returns {void}
|
|
484
568
|
*/
|
|
485
|
-
function attachQueuedAbort(session, publishRequest) {
|
|
569
|
+
function attachQueuedAbort(relayUrl, session, publishRequest) {
|
|
486
570
|
const { signal, reject } = publishRequest
|
|
487
571
|
/**
|
|
488
572
|
* 处理 abort 事件
|
|
489
573
|
*/
|
|
490
574
|
publishRequest.onAbort = () => {
|
|
491
|
-
publishRequest
|
|
492
|
-
const pendingIndex = session.pendingPublishes.indexOf(publishRequest)
|
|
493
|
-
if (pendingIndex < 0) return
|
|
494
|
-
session.pendingPublishes.splice(pendingIndex, 1)
|
|
575
|
+
if (!dequeueQueuedPublish(relayUrl, session, publishRequest)) return
|
|
495
576
|
reject(new Error('nostr: aborted'))
|
|
496
577
|
}
|
|
497
578
|
/**
|
|
@@ -525,7 +606,8 @@ function settleInflightPublish(relayUrl, session, publishRequest, attempt, settl
|
|
|
525
606
|
|
|
526
607
|
/**
|
|
527
608
|
* 通过共享 relay 会话发布 EVENT:复用已打开 socket,避免每次 send 重开一条连接(内存泄漏)。
|
|
528
|
-
* 无现成连接时入队并触发连接,连上后由 flushPendingPublishes
|
|
609
|
+
* 无现成连接时入队并触发连接,连上后由 flushPendingPublishes 统一派发;
|
|
610
|
+
* 连不上的 relay 由入队等待上限(NOSTR_QUEUED_PUBLISH_DEADLINE_MS)reject,不会永久悬挂。
|
|
529
611
|
* @param {string} relayUrl 中继 URL
|
|
530
612
|
* @param {object} event 待发布事件
|
|
531
613
|
* @param {AbortSignal} [signal] 取消信号
|
|
@@ -541,13 +623,15 @@ export function publishViaSharedRelay(relayUrl, event, signal, connectTarget) {
|
|
|
541
623
|
const session = acquireSharedRelay(relayUrl, connectTarget)
|
|
542
624
|
clearIdleDrop(session)
|
|
543
625
|
/** @type {SharedRelaySession['pendingPublishes'][number]} */
|
|
544
|
-
const publishRequest = { event, signal, attempt: 0, resolve, reject, onAbort: null, removeAbort: null }
|
|
545
|
-
attachQueuedAbort(session, publishRequest)
|
|
626
|
+
const publishRequest = { event, signal, attempt: 0, deadline: null, resolve, reject, onAbort: null, removeAbort: null }
|
|
627
|
+
attachQueuedAbort(relayUrl, session, publishRequest)
|
|
546
628
|
session.pendingPublishes.push(publishRequest)
|
|
547
|
-
if (session.ws?.readyState === WebSocket.OPEN)
|
|
629
|
+
if (session.ws?.readyState === WebSocket.OPEN) {
|
|
548
630
|
flushPendingPublishes(relayUrl, session, session.ws)
|
|
549
|
-
|
|
550
|
-
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
attachQueuedDeadline(relayUrl, session, publishRequest)
|
|
634
|
+
scheduleSharedRelayConnect(relayUrl, session)
|
|
551
635
|
})
|
|
552
636
|
}
|
|
553
637
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Nostr is the fount-network fallback transport (`link` level −∞, discovery `priority` 100). This document covers the **relay pool**, NIP-66 discovery, health scoring, advert relay-field signing, and handshake routing. Top-level surface: [signaling.md](signaling.md) · [transports.md](transports.md) · [mesh.md](mesh.md).
|
|
4
4
|
|
|
5
5
|
Source layout (`discovery/nostr/`):
|
|
6
|
+
|
|
6
7
|
- `index.mjs` — provider (presence / signal / advert subscriptions)
|
|
7
8
|
- `relays.mjs` — pool, health, NIP-66 discovery, normalization, persistence
|
|
8
9
|
- `selection.mjs` — handshake routing, backoff, fanout
|
|
@@ -72,6 +73,7 @@ Lower is better. `recordProbeSuccess` / `recordProbeFailure` / `recordPublishRes
|
|
|
72
73
|
## Handshake routing (`selection.mjs`)
|
|
73
74
|
|
|
74
75
|
`handshakeTargets(nodeHash, attempt)`:
|
|
76
|
+
|
|
75
77
|
- **Round 0**: peer-claimed `listenRelays` top 4 by composite score (own health + peer rtt); else local `workingRelays` top 4; else pinned top 4.
|
|
76
78
|
- **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).
|
|
77
79
|
- Retries ≤ `MAX_ROUTING_ATTEMPTS` (4).
|
package/docs/signaling.md
CHANGED
|
@@ -14,9 +14,20 @@ Internal WebRTC (`needsOfferAnswer`) glare and handshake. Shells use the fount-n
|
|
|
14
14
|
|
|
15
15
|
Frames: `hello` then `auth`. On simultaneous dial, peer `auth` can arrive before peer `hello` — buffer it (`pendingAuth` in `link/pipe.mjs`); never drop.
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## ICE candidate gathering and the local-hostname ladder
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
There is no `trickleIceOff` switch: candidates travel inside the description, because a peer must already hold the remote description before it accepts candidates. Every link therefore gathers locally first and then sends one offer/answer.
|
|
20
|
+
|
|
21
|
+
`.local` (mDNS) host candidates are usually unresolvable for the peer, so the default policy drops them. When that leaves the local candidate set **empty**, the link cannot work no matter what the peer supports, so the initiator escalates to the next, more permissive policy and rebuilds the peer connection on that rung (`ICE_LOCAL_HOSTNAME_LADDER` in `node/signaling_config.mjs`; `iceLocalHostnameLadder` derives the rungs from the configured start):
|
|
22
|
+
|
|
23
|
+
1. `drop` — solve the mDNS problem by not offering the candidate.
|
|
24
|
+
2. `none` — offer everything and let the peer decide.
|
|
25
|
+
|
|
26
|
+
This decision is driven purely by the observed candidate set, never by the platform, so any backend on any host follows the same path. `rewrite-loopback` is not part of the ladder (rewriting a candidate to `127.0.0.1` is only useful same-machine); it stays available as an explicit start and, when set, is used alone.
|
|
27
|
+
|
|
28
|
+
Each rung is a **fresh link attempt** — new peer connection, new offer, new DTLS fingerprint — not a renegotiation of a live connection, so `pipe` fingerprint-binding semantics are unchanged. The offer carries the rung number; the responder rebuilds on that same rung and echoes it in the answer and never escalates on its own, which keeps convergence bounded. The whole ladder shares the `handshakeTimeoutMs` budget.
|
|
29
|
+
|
|
30
|
+
Gathering itself is bounded by `collectIceGathering` (`link/providers/webrtc.mjs`): it finishes on `iceGatheringState === 'complete'`, on a quiet candidate stream after candidates arrived (`ICE_CANDIDATE_SETTLE_MS`), or after `ICE_GATHERING_STALL_MS` when nothing was gathered at all — the last case is logged, and DTLS/data-channel timeouts decide instead of a hard 10s failure. `handshakeTimeoutMs` stays the hard stop on every path, including the quiet-window wait. Server-side polyfills (`node-datachannel`) deliver candidates only through `icecandidate` events and `localDescription.sdp` carries no candidate lines, so progress is counted from events rather than by diffing SDP.
|
|
20
31
|
|
|
21
32
|
## Runtime channels
|
|
22
33
|
|
|
@@ -25,9 +36,10 @@ When set: send final offer/answer after ICE gathering, dedupe remote signals, qu
|
|
|
25
36
|
`channels` selects which discovery/link media are active — `nostr`, `lan`, `bt`, `webrtc`. Semantics per channel key: `false` **disables**; any other value (`undefined`, `true`, or an object) **enables** — an object is merged over that channel's default config. Channels not mentioned keep their default enabled state.
|
|
26
37
|
|
|
27
38
|
Channel → components:
|
|
39
|
+
|
|
28
40
|
- `nostr` (nostr discovery + nostr link) — config `relay` **replaces** the default public relay list (do not merge defaults back in).
|
|
29
41
|
- `lan` (lan discovery + lan_tcp link)
|
|
30
42
|
- `bt` (bt discovery + ble_gatt link)
|
|
31
|
-
- `webrtc` (webrtc link data-transport fallback) — config `iceLocalHostnamePolicy`
|
|
43
|
+
- `webrtc` (webrtc link data-transport fallback) — config `iceLocalHostnamePolicy` is the ladder's **start** (`drop` by default; `none`, or `rewrite-loopback` for same-machine debugging).
|
|
32
44
|
|
|
33
45
|
To run a node on only a few channels, use the `disableAllChannels` helper: `{ channels: disableAllChannels({ nostr: { relay: [...] } }) }` enables only `nostr` (with its per-channel relay) and disables the rest; use `true` to enable a channel with its default config (`disableAllChannels({ nostr: true, webrtc: true })`).
|
package/docs/transports.md
CHANGED
|
@@ -45,10 +45,20 @@ LinkHandle for upper layers: `ready` / `nodeHash` / `send` / `onEnvelope` / `onD
|
|
|
45
45
|
|
|
46
46
|
Provider optional hooks (package-internal): `ensureListening`, `localEndpoint`, `canReach`, `caps.probe: 'sync' | 'native'` (`native` = skipped on ensureRuntime fast-listen). Discovery `connectToNode` / `sendNodeSignal` may return `false` when the path is unavailable; fan-out treats that as silent skip. Per-provider throw/false in discovery and link dial fallback are silent; only total failure of the abstraction surfaces to the caller.
|
|
47
47
|
|
|
48
|
-
Each registry only
|
|
48
|
+
Each registry only owns its own `lan_tcp` / `ble_gatt` instances (unique registry ids like `lan_tcp:ab12cd34`), so those are listened to per registry. Every other **registered and enabled** provider (e.g. `nostr`, `webrtc`) is listened to by the registry that can see it: `ensureRuntime` and `reloadDiscoveryRelays` call `ensureListening` on any enabled provider that has no registered stop function yet. A provider registered later — notably the fresh instance `reconcileLinkProviders()` installs when a channel is toggled off and back on — must be picked up by that pass, otherwise it silently drops every inbound `link-open` (a missing listener is logged as `p2p:nostr link-open dropped — listener not attached`).
|
|
49
49
|
|
|
50
50
|
Chain `providerId` on the LinkHandle stays the short name (`lan_tcp` / `ble_gatt` / `webrtc` / `nostr`) for scheduling/stats.
|
|
51
51
|
|
|
52
|
+
## Bounded waits (no unbounded dial / publish)
|
|
53
|
+
|
|
54
|
+
Nothing on the dial path may wait forever:
|
|
55
|
+
|
|
56
|
+
- Intra-relay publish (`discovery/nostr/session.mjs`): a request queued behind a relay that never completes its WebSocket connect is rejected with a connect-stage error after `NOSTR_QUEUED_PUBLISH_DEADLINE_MS`, and the session is reclaimed. The EVent OK timeout only starts once a socket is open, so it cannot cover the connect stage.
|
|
57
|
+
- Relay fan-out (`discovery/nostr/index.mjs` `publishEvent`): resolves as soon as **one** relay accepts; the remaining publishes keep running in the background but are bounded by the above.
|
|
58
|
+
- One `ensureLinkToNode` (`transport/link_registry.mjs`): bounded by `LINK_DIAL_DEADLINE_MS` (larger than the publish deadline, so a dead relay normally fails through the provider's own error path). On timeout the in-flight promise is dropped from `inflights` so later dials start a fresh attempt instead of reusing a stuck one.
|
|
59
|
+
|
|
60
|
+
`kotlin/` mirrors all three; there a connect coroutine holding `sessionMutex` must also be cancellable (see [kotlin/AGENTS.md](../../kotlin/AGENTS.md)).
|
|
61
|
+
|
|
52
62
|
## Level table
|
|
53
63
|
|
|
54
64
|
| id | level |
|
|
@@ -81,6 +91,10 @@ Plain TCP on the LAN. Registry schedules listen in the background after `ensureR
|
|
|
81
91
|
|
|
82
92
|
Discovery signal + dual DataChannel; DTLS fingerprint as handshake binding; `needsOfferAnswer` glare path. Soft-fail (`null`) continues to lower-level providers. Backend: `node-datachannel` when the native addon loads, else pure-JS `node-rtc-connection` (Android/Termux skips native). See [runtime.md](runtime.md).
|
|
83
93
|
|
|
94
|
+
Every link gathers local ICE candidates first and then sends one offer/answer (there is no trickle mode: a peer must already hold the remote description before it accepts candidates). `collectIceGathering` (`link/providers/webrtc.mjs`) therefore does not wait for `iceGatheringState === 'complete'` alone — server-side polyfills leave the state at `gathering` when every relay times out or when the local-hostname policy drops all host candidates. It also finishes on a quiet candidate stream (`ICE_CANDIDATE_SETTLE_MS`) and gives up after `ICE_GATHERING_STALL_MS` when nothing was gathered, so DTLS/data-channel timeouts decide instead of a hard failure. `handshakeTimeoutMs` remains the hard stop on every path. Progress is counted from `icecandidate` events, not SDP diffs, because `localDescription.sdp` carries no candidate lines on those backends.
|
|
95
|
+
|
|
96
|
+
When the configured local-hostname policy drains every candidate the link cannot work at all, so the initiator escalates through `ICE_LOCAL_HOSTNAME_LADDER` (`drop` → `none`) and rebuilds the peer connection per rung; the responder follows the rung named in the offer. Nothing here is platform-specific. Details: [signaling.md](signaling.md).
|
|
97
|
+
|
|
84
98
|
### `ble_gatt` (40)
|
|
85
99
|
|
|
86
100
|
GATT write/notify; binding = shared `linkId`; needs BT peer hint (`peripheralId` in discovery meta); optional noble/bleno. Per-registry instance like `lan_tcp`; `isAvailable` / `canReach` gate dial. On Win32, scan-only stacks cannot accept inbound BLE links. One BLE adapter cannot host two independent peripherals in-process — production is one node per process. Hardware probe: [runtime.md](runtime.md).
|
|
@@ -227,7 +227,7 @@ function mergeRowsWithSources(entries, maxHits, rowKey) {
|
|
|
227
227
|
try { return JSON.stringify(row) }
|
|
228
228
|
catch { return `\0${rows.length}` }
|
|
229
229
|
})
|
|
230
|
-
for (const entry of entries)
|
|
230
|
+
for (const entry of entries)
|
|
231
231
|
for (const row of entry.rows || []) {
|
|
232
232
|
const key = keyOf(row)
|
|
233
233
|
if (!seen.has(key)) {
|
|
@@ -240,7 +240,7 @@ function mergeRowsWithSources(entries, maxHits, rowKey) {
|
|
|
240
240
|
}
|
|
241
241
|
if (entry.sourceNodeHash) sources.get(key).add(entry.sourceNodeHash)
|
|
242
242
|
}
|
|
243
|
-
|
|
243
|
+
|
|
244
244
|
return { rows, sources }
|
|
245
245
|
}
|
|
246
246
|
|
|
@@ -325,10 +325,10 @@ function filterRowsBySource(rows, sources, rowKey, isSourceBlocked) {
|
|
|
325
325
|
return rows.filter(row => {
|
|
326
326
|
let key
|
|
327
327
|
if (rowKey) key = rowKey(row)
|
|
328
|
-
else
|
|
328
|
+
else
|
|
329
329
|
try { key = JSON.stringify(row) }
|
|
330
330
|
catch { return true }
|
|
331
|
-
|
|
331
|
+
|
|
332
332
|
const set = sources.get(key)
|
|
333
333
|
if (!set || set.size === 0) return true
|
|
334
334
|
for (const source of set) if (!isSourceBlocked(source)) return true
|
package/index.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
unlockReputationMax,
|
|
38
38
|
} from './node/reputation_sync.mjs'
|
|
39
39
|
import { getRoutingProfile, setRoutingProfile } from './node/routing_profile.mjs'
|
|
40
|
+
import { attachNetworkVerification, createNetworkVerificationService, getNetworkVerificationService, proveNetworkVerification } from './node/verification.mjs'
|
|
40
41
|
import { createGroupLinkSet } from './transport/group_link_set.mjs'
|
|
41
42
|
import {
|
|
42
43
|
configureLinkRegistry,
|
|
@@ -62,12 +63,14 @@ import {
|
|
|
62
63
|
* 包门面:节点、infra、mesh/registry、信誉同步、node-scope 等公开导出。
|
|
63
64
|
*/
|
|
64
65
|
export {
|
|
66
|
+
attachNetworkVerification,
|
|
65
67
|
attachReputationSyncWire,
|
|
66
68
|
attachNodeScopeDefaultFeatures,
|
|
67
69
|
closeNode,
|
|
68
70
|
configureLinkRegistry,
|
|
69
71
|
configureNodeStorage,
|
|
70
72
|
createGroupLinkSet,
|
|
73
|
+
createNetworkVerificationService,
|
|
71
74
|
createScopedLinkRoom,
|
|
72
75
|
ensureChannelAvailable,
|
|
73
76
|
ensureLinkToNode,
|
|
@@ -76,6 +79,7 @@ export {
|
|
|
76
79
|
ensureOverlayRouter,
|
|
77
80
|
ensureUserRoom,
|
|
78
81
|
getLinkRegistry,
|
|
82
|
+
getNetworkVerificationService,
|
|
79
83
|
getNodeDir,
|
|
80
84
|
getNodeHash,
|
|
81
85
|
getNodePopulationEstimate,
|
|
@@ -93,6 +97,7 @@ export {
|
|
|
93
97
|
loadReputation,
|
|
94
98
|
lockReputationMax,
|
|
95
99
|
onPeerHealth,
|
|
100
|
+
proveNetworkVerification,
|
|
96
101
|
pullReputationFromNode,
|
|
97
102
|
registerDiscoveryProvider,
|
|
98
103
|
registerLinkProvider,
|
|
@@ -63,7 +63,7 @@ async function openGattPipe(options) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
*
|
|
66
|
+
* central 角色主动拨号:按 peer hint 扫描外围设备并建立 GATT 链路。
|
|
67
67
|
* @param {object} options dial 选项
|
|
68
68
|
* @returns {Promise<import('./index.mjs').LinkHandle>} 已就绪的 link
|
|
69
69
|
*/
|
|
@@ -187,7 +187,7 @@ export function createBleGattLinkProvider() {
|
|
|
187
187
|
uuid: BLE_DATA_CHAR_UUID,
|
|
188
188
|
properties: ['write', 'writeWithoutResponse', 'notify'],
|
|
189
189
|
/**
|
|
190
|
-
* stoprocent/bleno onWriteRequest
|
|
190
|
+
* peripheral 角色的写入回调(stoprocent/bleno onWriteRequest 签名)。
|
|
191
191
|
* @param {*} _connection 连接句柄
|
|
192
192
|
* @param {Buffer} data 写入
|
|
193
193
|
* @param {number} _offset 偏移
|
package/link/providers/index.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { base64ToBytes, bytesToBase64 } from '../../../core/bytes_codec.mjs'
|
|
|
5
5
|
import { isHex64 } from '../../../core/hexIds.mjs'
|
|
6
6
|
import { getDiscoveryProvider, sendNodeSignalPacket } from '../../../discovery/index.mjs'
|
|
7
7
|
import { NOSTR_SIGNAL_KIND, resolveNostrRelayUrls } from '../../../discovery/nostr/index.mjs'
|
|
8
|
+
import { nodeDebug, shortHash } from '../../../node/log.mjs'
|
|
8
9
|
import { ms } from '../../../utils/duration.mjs'
|
|
9
10
|
import { createLruMap } from '../../../utils/lru.mjs'
|
|
10
11
|
import { FRAME_HEADER_BYTES, maxFrameChunkBytesForPayload } from '../../frame.mjs'
|
|
@@ -12,9 +13,11 @@ import { asLinkHandle } from '../../pipe.mjs'
|
|
|
12
13
|
import { LINK_LEVEL_NOSTR } from '../levels.mjs'
|
|
13
14
|
import { createLinkIdBoundPipe } from '../link_id_pipe.mjs'
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
/**
|
|
17
|
+
* 单包 payload(UTF-8 / base64)上限,避免撞 relay content 限制。
|
|
18
|
+
* 默认兜底取 2026-08 本机对默认公共 relay 的 NIP-11 `max_message_length` 非零最小值(131072 = nostr.mom)。
|
|
19
|
+
* 有 relay 信息时用实测非零最小值覆盖(见 refreshPayloadCap),无 relay / 未探测到时用此默认。
|
|
20
|
+
*/
|
|
18
21
|
export const MAX_LINK_PAYLOAD_CHARS = 131072
|
|
19
22
|
|
|
20
23
|
/** relay info(NIP-11)单次探测超时。 */
|
|
@@ -52,8 +55,10 @@ export function estimateEventMessageBytes(packet) {
|
|
|
52
55
|
}])).length
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
/**
|
|
56
|
-
*
|
|
58
|
+
/**
|
|
59
|
+
* relay cap 低于此字符数视为无法承载最小正 chunk(帧头 + 1 字节 chunk 的完整 EVENT 封装),从统一上限中剔除。
|
|
60
|
+
* 这类 relay 即便能传也无法携带有效载荷(maxFrameChunkBytesForPayload 得 0),参与取最小值只会无谓拖低/毒化整条链路。
|
|
61
|
+
*/
|
|
57
62
|
export const MIN_USABLE_RELAY_CAP_CHARS = estimateEventMessageBytes({
|
|
58
63
|
type: 'link',
|
|
59
64
|
op: 'b',
|
|
@@ -323,7 +328,15 @@ export function createNostrLinkProvider(options = {}) {
|
|
|
323
328
|
const op = String(packet.op || '')
|
|
324
329
|
if (op === 'open') {
|
|
325
330
|
if (sessions.has(linkId)) return
|
|
326
|
-
if (!onInbound || !localIdentity)
|
|
331
|
+
if (!onInbound || !localIdentity) {
|
|
332
|
+
// 监听未挂上(例如通道关掉再打开后 registry 没重新 ensureListening)时入站包会被丢弃;
|
|
333
|
+
// 静默丢弃曾让「对端明明在线却始终握手超时」无法诊断(见 fount-p2p#38)。
|
|
334
|
+
nodeDebug('p2p:nostr link-open dropped — listener not attached', {
|
|
335
|
+
peer: shortHash(from),
|
|
336
|
+
linkId: shortHash(linkId),
|
|
337
|
+
})
|
|
338
|
+
return
|
|
339
|
+
}
|
|
327
340
|
await refreshPayloadCap(resolveRelayUrls())
|
|
328
341
|
if (sessions.has(linkId)) return
|
|
329
342
|
const pipe = openPipe({
|