@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 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 whole package root (offline), injects an importmap into served HTML (`https://esm.sh/@steve02081504/fount-p2p/` → local 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`).
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
- let published = false
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
- const connectTarget = await resolveRelayConnectTarget(relayUrl)
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 (error) {
224
- lastError = error
220
+ catch {
221
+ return { relayUrl, connectTarget: null }
225
222
  }
226
- }))
227
- if (!published) throw lastError || new Error('nostr: no relay accepted publish')
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.removeAbort?.()
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
- else
550
- scheduleSharedRelayConnect(relayUrl, session)
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
- ## Windows / `trickleIceOff`
17
+ ## ICE candidate gathering and the local-hostname ladder
18
18
 
19
- When set: send final offer/answer after ICE gathering, dedupe remote signals, queue remote ICE until both descriptions are ready.
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` (`none` / `rewrite-loopback` / `drop`, Windows defaults `drop`) and `trickleIceOff` (send final offer/answer after ICE gathering).
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 })`).
@@ -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 calls `ensureListening` on **its own** `lan_tcp` / `ble_gatt` instances (unique registry ids like `lan_tcp:ab12cd34`). Never fan out listening to other registries' sockets.
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
- * Central dial。
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(connection, data, offset, withoutResponse, callback)
190
+ * peripheral 角色的写入回调(stoprocent/bleno onWriteRequest 签名)。
191
191
  * @param {*} _connection 连接句柄
192
192
  * @param {Buffer} data 写入
193
193
  * @param {number} _offset 偏移
@@ -9,7 +9,8 @@
9
9
  * onDown: (callback: (reason: string) => void) => () => void,
10
10
  * close: (reason?: string) => Promise<void>,
11
11
  * stats: () => object,
12
- * }} LinkHandle */
12
+ * }} LinkHandle
13
+ */
13
14
 
14
15
  /**
15
16
  * @typedef {{
@@ -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
- /** 单包 payload(UTF-8 / base64)上限,避免撞 relay content 限制。
16
- * 默认兜底取 2026-08 本机对默认公共 relay 的 NIP-11 `max_message_length` 非零最小值(131072 = nostr.mom)。
17
- * 有 relay 信息时用实测非零最小值覆盖(见 refreshPayloadCap),无 relay / 未探测到时用此默认。 */
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
- /** relay cap 低于此字符数视为无法承载最小正 chunk(帧头 + 1 字节 chunk 的完整 EVENT 封装),从统一上限中剔除。
56
- * 这类 relay 即便能传也无法携带有效载荷(maxFrameChunkBytesForPayload 得 0),参与取最小值只会无谓拖低/毒化整条链路。 */
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) return
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({