@steve02081504/fount-p2p 0.0.46 → 0.0.49
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 +14 -1
- package/discovery/nostr/constants.mjs +1 -3
- package/discovery/nostr/index.mjs +23 -11
- package/discovery/nostr/session.mjs +96 -12
- package/docs/signaling.md +14 -3
- package/docs/transports.md +15 -1
- 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 +261 -56
- package/link/rtc/polyfill.mjs +9 -6
- 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/transport/link_registry.mjs +75 -6
- package/transport/peer_health.mjs +43 -25
- package/transport/runtime_bootstrap.mjs +44 -9
- package/pages/index.html +0 -36
- package/pages/index.mjs +0 -66
package/AGENTS.md
CHANGED
|
@@ -24,7 +24,7 @@ Detail docs: [transports](docs/transports.md) · [mesh](docs/mesh.md) · [signal
|
|
|
24
24
|
| `crypto/crypto.mjs` | Node + browser | `@noble/hashes` + `@noble/curves` only — never `node:crypto` |
|
|
25
25
|
| `crypto/key.mjs` / `crypto/channel.mjs`, disk I/O, LAN/BT, `ws`, CLI / `startNode` | Node (+ Deno bridge) | Do not load the whole package via esm.sh in the browser |
|
|
26
26
|
|
|
27
|
-
**Browser pages reusing package code:** pages import browser-safe package modules (`core/*`, `crypto/crypto.mjs`, `discovery/nostr/constants|_math|_verify|_monitor`) from esm.sh; their bare `@noble/*` deps resolve via an injected importmap. The frontend static test server (`test/frontend/census_page.test.mjs`) serves the
|
|
27
|
+
**Browser pages reusing package code:** pages import browser-safe package modules (`core/*`, `crypto/crypto.mjs`, `discovery/nostr/constants|_math|_verify|_monitor`) from esm.sh; their bare `@noble/*` deps resolve via an injected importmap. The frontend static test server (`test/frontend/census_page.test.mjs`) serves the monorepo root (offline) — the page lives in `pages/`, the package in `js/` — injects an importmap into served HTML (`https://esm.sh/@steve02081504/fount-p2p/` → local `js/` package root, `@noble/` → `https://esm.sh/@noble/`), and runs the page live against a fake relay (`test/helpers/fake_relay.mjs` with `store`), injecting its `ws://` URL via `?relay=` — no demo mode. Page code stays a thin shell — pass display fn + optional relays; logic lives in the package (`discovery/nostr/census_monitor.mjs`).
|
|
28
28
|
|
|
29
29
|
Deno / native / BT: [runtime.md](docs/runtime.md).
|
|
30
30
|
|
|
@@ -47,11 +47,24 @@ Deno / native / BT: [runtime.md](docs/runtime.md).
|
|
|
47
47
|
- `npm run test:live` — live link / LAN smoke
|
|
48
48
|
- `npm run test:fount` — cross-repo Deno bridge (`test/fount/`); see [runtime.md](docs/runtime.md)
|
|
49
49
|
- `npm run test:sim` — tunables co-evolution (dev-only; [sim/AGENTS.md](sim/AGENTS.md))
|
|
50
|
+
- `npm run test:checks` — the three static-health suites below only
|
|
51
|
+
- `npm run check:text_lf:fix` — rewrite line-ending violations (the suites themselves never write)
|
|
50
52
|
- `node scripts/check-imports.mjs` — relative import check
|
|
51
53
|
- `node scripts/find-unused-exports.mjs` — dead-export scan (`--fount <path>` optional)
|
|
52
54
|
- Assertions: `test/helpers/assert.mjs`
|
|
53
55
|
- Fixed-seed identity: `test/helpers/identity.mjs`
|
|
54
56
|
- Mock discovery: `test/helpers/mock_discovery.mjs`
|
|
57
|
+
- Transport fakes must mirror the backend's failure signalling: a fake `RTCPeerConnection` whose `close()` stays silent (no `connectionstatechange`) hides re-entrancy bugs where a replaced connection closes the live one (fount-p2p#39, `link/providers/webrtc.mjs`).
|
|
58
|
+
|
|
59
|
+
## Static checks (`scripts/checks/`, ported from fount's check standard)
|
|
60
|
+
|
|
61
|
+
| Check | Enforces |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `text_lf` | Every UTF-8 text file (fatal decode, no NUL / C0 control byte; empty files exempt) uses LF, ends with exactly one LF (single-line `.svg` instead ends with none), and does not start with LF (leading UTF-8 BOM skipped). Binary files such as `gradle-wrapper.jar` are skipped. Scanner: `scripts/checks/text_lf.mjs`; `npm run check:text_lf:fix` writes the fix |
|
|
64
|
+
| `jsdoc_no_english` | JSDoc summaries are Chinese (CJK required; a pure-English summary or a missing summary fails); multi-line blocks put a line break right after `/**` and before `*/`. Tag-only blocks (`@param` / `@typedef` …) are fine, empty `/** */` stubs are not. Scanner: `scripts/checks/jsdoc_no_english.mjs` |
|
|
65
|
+
| `agents_md_english` | Every `AGENTS.md` and each `.md` reachable through its local links is English (no CJK) and its links resolve; a non-`AGENTS.md` doc in that closure must live under a `docs/` directory. Scanner: `scripts/checks/agents_md_english.mjs` |
|
|
66
|
+
|
|
67
|
+
The scanners run from `js/test/pure/*.test.mjs` (repo-wide scope: they walk up to the monorepo root, so `kotlin/AGENTS.md` and the root docs are covered too). Docs language is unified to English — write agent docs and `.md` in the AGENTS.md closure in English.
|
|
55
68
|
|
|
56
69
|
## Hard rules
|
|
57
70
|
|
|
@@ -57,7 +57,5 @@ export const NOSTR_CENSUS_KIND = 30789
|
|
|
57
57
|
|
|
58
58
|
/** census 订阅/发布标签:`t=fount` + `x=census`(subscribeNostrKind 以 rendezvousKey/tagX 匹配)。 */
|
|
59
59
|
export const CENSUS_TAG_FOUNT = 'fount'
|
|
60
|
-
/**
|
|
61
|
-
*
|
|
62
|
-
*/
|
|
60
|
+
/** census 标签 `x` 的取值(`t=fount` + `x=census` 共同标识 census 事件)。 */
|
|
63
61
|
export const CENSUS_TAG_X = 'census'
|
|
@@ -203,7 +203,8 @@ async function signNostrEvent(kind, tags, content, secretKey) {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
/**
|
|
206
|
-
* 全量发布到给定 relay
|
|
206
|
+
* 全量发布到给定 relay:任一成功即返回,其余 relay 的发布留在后台自行结算。
|
|
207
|
+
* 每个 relay 尝试都有界(连接超时 / OK 超时 / 入队等待上限),故后台不会永久悬挂。
|
|
207
208
|
* @param {string[]} relayUrls 中继 URL 列表
|
|
208
209
|
* @param {object} event 待发布事件
|
|
209
210
|
* @param {AbortSignal} [signal] 取消信号
|
|
@@ -212,19 +213,30 @@ async function signNostrEvent(kind, tags, content, secretKey) {
|
|
|
212
213
|
async function publishEvent(relayUrls, event, signal) {
|
|
213
214
|
const urls = dedupeRelayUrls(relayUrls)
|
|
214
215
|
if (!urls.length) throw new Error('nostr: no relay')
|
|
215
|
-
|
|
216
|
-
let lastError = null
|
|
217
|
-
await Promise.allSettled(urls.map(async relayUrl => {
|
|
216
|
+
const targets = (await Promise.all(urls.map(async relayUrl => {
|
|
218
217
|
try {
|
|
219
|
-
|
|
220
|
-
if (!connectTarget) return
|
|
221
|
-
if (await publishViaSharedRelay(relayUrl, event, signal, connectTarget)) published = true
|
|
218
|
+
return { relayUrl, connectTarget: await resolveRelayConnectTarget(relayUrl) }
|
|
222
219
|
}
|
|
223
|
-
catch
|
|
224
|
-
|
|
220
|
+
catch {
|
|
221
|
+
return { relayUrl, connectTarget: null }
|
|
225
222
|
}
|
|
226
|
-
}))
|
|
227
|
-
|
|
223
|
+
}))).filter(target => target.connectTarget)
|
|
224
|
+
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 => {
|
|
232
|
+
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)
|
|
239
|
+
throw lastError || new Error('nostr: no relay accepted publish')
|
|
228
240
|
}
|
|
229
241
|
|
|
230
242
|
/**
|
|
@@ -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
|
|
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
|
|
|
@@ -29,6 +40,6 @@ Channel → components:
|
|
|
29
40
|
- `nostr` (nostr discovery + nostr link) — config `relay` **replaces** the default public relay list (do not merge defaults back in).
|
|
30
41
|
- `lan` (lan discovery + lan_tcp link)
|
|
31
42
|
- `bt` (bt discovery + ble_gatt link)
|
|
32
|
-
- `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).
|
|
33
44
|
|
|
34
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).
|
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({
|