@steve02081504/fount-p2p 0.0.32 → 0.0.34

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/README.md CHANGED
@@ -88,7 +88,7 @@ Facade entry: `index.mjs` (`startNode`, `createGroupLinkSet`, `registerDiscovery
88
88
  | `user_room.mjs` / `group_link_set.mjs` / `node_scope/wire` + `node_scope/features` | rooms + composable node-scope wires (`registerNodeScopeWireHook`, attaches) |
89
89
  | `room_scopes.mjs` / `remote_user_room.mjs` | scope constants / remote user slot |
90
90
 
91
- `runtime_bootstrap`, `offer_answer` are **internal** (transport). Signal crypto / rendezvous live under `discovery/internal/signal_crypto.mjs` (used by discovery `nostr.mjs` / `adverts.mjs`; not a package export). The Nostr **link** provider (`link/providers/nostr.mjs`) reuses the same discovery signal path (`type: 'link'`) as a last-resort duplex pipe. `ensureRuntime` returns after registration and scheduling warm-up; it does not await lan_tcp listen, public relays, or Bluetooth. `setSignalingRuntimeConfig` → `reloadDiscoveryRelays`. See [docs/runtime.md](./docs/runtime.md) and [docs/transports.md](./docs/transports.md).
91
+ `runtime_bootstrap`, `offer_answer` are **internal** (transport). Signal crypto / rendezvous live under `discovery/internal/signal_crypto.mjs` (used by discovery `nostr.mjs` / `adverts.mjs`; not a package export). The Nostr **link** provider (`link/providers/nostr.mjs`) reuses the same discovery signal path (`type: 'link'`) as a last-resort duplex pipe. `ensureRuntime` returns after registration and scheduling warm-up; it does not await lan_tcp listen, public relays, or Bluetooth. Call `ensureChannelAvailable('bt')` to await BT discovery readiness. `setSignalingRuntimeConfig` → `reloadDiscoveryRelays`. See [docs/runtime.md](./docs/runtime.md) and [docs/transports.md](./docs/transports.md).
92
92
 
93
93
  Root contains only the facade and package metadata; all modules live in layered subdirectories.
94
94
 
@@ -6,6 +6,8 @@ import WebSocket from 'ws'
6
6
  import { base64ToBytes, hexToBytes, bytesToBase64, bytesToHex } from '../core/bytes_codec.mjs'
7
7
  import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
8
8
  import { sha256Hex } from '../crypto/crypto.mjs'
9
+ import { getNodeTransportSettings } from '../node/identity.mjs'
10
+ import { getSignalingRuntimeConfig } from '../node/instance.mjs'
9
11
  import { nodeDebug, shortHash } from '../node/log.mjs'
10
12
 
11
13
  import { noteAdvertPeerHints } from './advert_peer_hints.mjs'
@@ -23,6 +25,10 @@ export const DEFAULT_RELAY_URLS = [
23
25
  'wss://relay.damus.io',
24
26
  'wss://nos.lol',
25
27
  'wss://relay.nostr.band',
28
+ 'wss://relay.nostr.com',
29
+ 'wss://nostr.bitcoiner.social',
30
+ 'wss://nostr.mom',
31
+ 'wss://relay.snort.social',
26
32
  ]
27
33
 
28
34
  /** 单中继 WebSocket 首连超时(短超时 + 并行,避免串行 10s×N)。 */
@@ -42,6 +48,9 @@ export const NOSTR_ADVERT_KIND = 30787
42
48
  /** Nostr signal 事件 kind(ephemeral,实时转发)。 */
43
49
  export const NOSTR_SIGNAL_KIND = 20787
44
50
 
51
+ /** 打广告用的话题 tag(hashtag,NIP-01),公开可被搜索聚合。 */
52
+ const NOSTR_TOPIC_TAG = ['t', 'fount']
53
+
45
54
  const ADVERT_TTL_MS = 10 * 60_000
46
55
 
47
56
  /** @type {Map<string, number>} 网络域 nodeHash → lastSeenAt */
@@ -200,6 +209,15 @@ export function mergeSignalingRelayUrls(userRelayUrls) {
200
209
  return merged.length ? merged : [...DEFAULT_RELAY_URLS]
201
210
  }
202
211
 
212
+ /**
213
+ * 当前可用 nostr 中继:nostr 通道配置 relay(替换默认),否则节点默认 + 公共中继。
214
+ * @returns {string[]} relay URL 列表
215
+ */
216
+ export function resolveNostrRelayUrls() {
217
+ const relay = getSignalingRuntimeConfig().channels?.nostr?.relay
218
+ return relay?.length ? relay : mergeSignalingRelayUrls(getNodeTransportSettings().relayUrls)
219
+ }
220
+
203
221
  /**
204
222
  * @param {number} kind 事件 kind
205
223
  * @param {string[][]} tags 事件标签
@@ -914,7 +932,7 @@ export function createNostrDiscoveryProvider(options = {}) {
914
932
  const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
915
933
  const event = await signNostrEvent(
916
934
  NOSTR_ADVERT_KIND,
917
- [['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
935
+ [NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
918
936
  bytesToBase64(bytes),
919
937
  secretKey,
920
938
  )
@@ -946,7 +964,7 @@ export function createNostrDiscoveryProvider(options = {}) {
946
964
  const rendezvousKey = nodeRendezvousKey(hash)
947
965
  const event = await signNostrEvent(
948
966
  NOSTR_SIGNAL_KIND,
949
- [['t', rendezvousKey], ['x', 'signal'], ['p', hash]],
967
+ [NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'signal'], ['p', hash]],
950
968
  bytesToBase64(bytes),
951
969
  secretKey,
952
970
  )
@@ -1008,7 +1026,7 @@ export function createNostrDiscoveryProvider(options = {}) {
1008
1026
  const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
1009
1027
  const event = await signNostrEvent(
1010
1028
  NOSTR_ADVERT_KIND,
1011
- [['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
1029
+ [NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
1012
1030
  bytesToBase64(bytes),
1013
1031
  secretKey,
1014
1032
  )
package/docs/runtime.md CHANGED
@@ -4,15 +4,27 @@
4
4
 
5
5
  ## `ensureRuntime` contract
6
6
 
7
- Returns after registering lan / nostr / bt discovery providers and scheduling background warm — does **not** await lan_tcp listen, Nostr relays, or BT.
7
+ Returns after registering lan / nostr discovery providers (subject to the signaling `channels` whitelist), registering all enabled link providers (including `ble_gatt`), and scheduling background warm — does **not** await lan_tcp listen, Nostr relays, or BT availability.
8
8
 
9
9
  | Who waits | For what |
10
10
  | --- | --- |
11
11
  | Shells (`startNode` / `ensureUserRoom`) | Nothing beyond `ensureRuntime` itself — never read `lanTcpPort` or await public-signaling warm-up |
12
12
  | `buildLocalAdvert` / `whenListening` | Local lan_tcp listen only |
13
13
  | `ensureLinkToNode` | `whenSignalListening` (Nostr `listenNodeSignals` attached) before dial, so offer/answer does not drop the first signal |
14
+ | `ensureChannelAvailable('bt')` | BT discovery provider registered and BT signal/presence fan-in restarted |
14
15
 
15
- Nostr / LAN / BT hooks are progressive. BT discovery / `ble_gatt` always warm in the background.
16
+ Nostr / LAN hooks are progressive. The `bt` discovery provider is **not** registered by `ensureRuntime`; it is registered on demand via `ensureChannelAvailable('bt')`.
17
+
18
+ ## `ensureChannelAvailable(channel)` contract
19
+
20
+ Await this to make a specific channel available:
21
+
22
+ - `bt` — probes the Bluetooth runtime, registers the `bt` discovery provider if available, and restarts the signal/presence fan-in so BT participates.
23
+ - `lan` — awaits `whenListening` (local lan_tcp listen).
24
+ - `nostr` — awaits `whenSignalListening` (Nostr `listenNodeSignals` attached).
25
+ - `webrtc` — awaits `whenSignalListening` (the shared signal medium WebRTC depends on).
26
+
27
+ `startNode` does not call `ensureChannelAvailable` automatically; shells that need BT discovery should `await getLinkRegistry().ensureChannelAvailable('bt')` (or the top-level `ensureChannelAvailable('bt')` export) after `startNode`.
16
28
 
17
29
  ### Fast-listen & dial path
18
30
 
@@ -28,7 +40,7 @@ Nostr / LAN / BT hooks are progressive. BT discovery / `ble_gatt` always warm in
28
40
  | init → shutdown → natural exit | ≤10s | `test/pure/shutdown_exit.test.mjs` |
29
41
  | 10s warm → shutdown → exit | ≤2s | same |
30
42
 
31
- Shutdown-exit tests use the production path (default public Nostr + lan). Do not use `relayOverride` / dead-relay crutches there.
43
+ Shutdown-exit tests use the production path (default public Nostr + lan). Do not use per-channel relay / dead-relay crutches there.
32
44
 
33
45
  ## Nostr cleanup
34
46
 
package/docs/signaling.md CHANGED
@@ -18,6 +18,16 @@ Frames: `hello` then `auth`. On simultaneous dial, peer `auth` can arrive before
18
18
 
19
19
  When set: send final offer/answer after ICE gathering, dedupe remote signals, queue remote ICE until both descriptions are ready.
20
20
 
21
- ## Runtime relay override
21
+ ## Runtime channels
22
22
 
23
- `setSignalingRuntimeConfig({ relayOverride, iceLocalHostnamePolicy, trickleIceOff })` after `initNode` (or pass `signaling` once on first `startNode`). `relayOverride` **replaces** the default public relay list (do not merge defaults back in). Changes emit `signaling-changed` and trigger `reloadDiscoveryRelays` (swap Nostr provider + rebind node presence/signals).
23
+ `setSignalingRuntimeConfig({ channels })` after `initNode` (or pass `signaling` once on first `startNode`). Changes emit `signaling-changed` and trigger `reloadDiscoveryRelays` (rebind discovery providers + node presence/signals).
24
+
25
+ `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
+
27
+ Channel → components:
28
+ - `nostr` (nostr discovery + nostr link) — config `relay` **replaces** the default public relay list (do not merge defaults back in).
29
+ - `lan` (lan discovery + lan_tcp link)
30
+ - `bt` (bt discovery + ble_gatt link)
31
+ - `webrtc` (webrtc link data-transport fallback) — config `iceLocalHostnamePolicy` (`none` / `rewrite-loopback` / `drop`, Windows defaults `drop`) and `trickleIceOff` (send final offer/answer after ICE gathering).
32
+
33
+ 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 })`).
@@ -33,7 +33,7 @@ Topic / rendezvous / signal crypto live under `discovery/` (`nostr.mjs`, `intern
33
33
  | **Discovery** (`discovery/`) | Per medium: `listVisibleNodeHashes` + `connectToNode`. Encrypted adverts/signals via `adverts.mjs` + `index.mjs` helpers. |
34
34
  | **Link providers** (`link/providers/`) | Open a duplex pipe; sorted by **`level` (descending)** — not a shell import |
35
35
  | **Registry** (`transport/link_registry.mjs`) | fount-network facade: dial fallback, scope/overlay, one canonical link per peer |
36
- | **Bootstrap** (`transport/runtime_bootstrap.mjs`) | `ensureRuntime` register + progressive listen/discovery/BT warm; `reloadDiscoveryRelays` on `signaling-changed` |
36
+ | **Bootstrap** (`transport/runtime_bootstrap.mjs`) | `ensureRuntime` register + progressive listen/discovery; `ensureChannelAvailable` for BT discovery; `reloadDiscoveryRelays` on `signaling-changed` |
37
37
  | **Offer/answer** (`transport/offer_answer.mjs`) | Discovery-signal glare path for `caps.needsOfferAnswer` (**internal**; uses `sendNodeSignalPacket`) |
38
38
  | **Mesh keepalive** (`transport/mesh_keepalive.mjs`) | N/K pool, explore eviction, stable promote to `trustedPeers` |
39
39
 
package/index.mjs CHANGED
@@ -36,6 +36,7 @@ import { getRoutingProfile, setRoutingProfile } from './node/routing_profile.mjs
36
36
  import { createGroupLinkSet } from './transport/group_link_set.mjs'
37
37
  import {
38
38
  configureLinkRegistry,
39
+ ensureChannelAvailable,
39
40
  ensureLinkToNode,
40
41
  ensureOverlayRouter,
41
42
  getLinkRegistry,
@@ -63,6 +64,7 @@ export {
63
64
  configureLinkRegistry,
64
65
  createGroupLinkSet,
65
66
  createScopedLinkRoom,
67
+ ensureChannelAvailable,
66
68
  ensureLinkToNode,
67
69
  ensureNodeDefaults,
68
70
  ensureNodeScope,
package/link/frame.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto'
2
2
 
3
- import { bytesToHex, hexToBytes, toBytes } from '../core/bytes_codec.mjs'
3
+ import { bytesToBase64, bytesToHex, hexToBytes, toBytes } from '../core/bytes_codec.mjs'
4
4
 
5
5
  /** frameId 字段字节长度(128 位)。 */
6
6
  export const FRAME_ID_BYTES = 16
@@ -40,6 +40,28 @@ export function randomFrameIdHex() {
40
40
  return bytesToHex(randomBytes(FRAME_ID_BYTES))
41
41
  }
42
42
 
43
+ /**
44
+ * 在单包字符上限下,求单片最大可承载 chunk 字节数。
45
+ * 以 base64 逐片精确贴合上限为目标,用二分在 [0, limit] 内找最大 chunkBytes,
46
+ * 使 base64Len(headerBytes + chunkBytes) <= limit —— 最大化单帧载荷利用率,
47
+ * 而不是按 base64 膨胀预留固定比例(预留比例会浪费余量且对帧头/填充不精确)。
48
+ * @param {number} maxPayloadChars 单包载荷字符上限(编码后)
49
+ * @param {(bytes: Uint8Array) => string} [encode] 载荷编码函数(默认 base64)
50
+ * @param {number} [headerBytes=FRAME_HEADER_BYTES] 帧头字节数
51
+ * @returns {number} 最大 chunk 字节数(>=0)
52
+ */
53
+ export function maxFrameChunkBytesForPayload(maxPayloadChars, encode = bytesToBase64, headerBytes = FRAME_HEADER_BYTES) {
54
+ const limit = Math.max(1, Math.floor(Number(maxPayloadChars) || 0))
55
+ let lowerBound = 0
56
+ let upperBound = limit
57
+ while (lowerBound < upperBound) {
58
+ const candidateChunkBytes = Math.ceil((lowerBound + upperBound + 1) / 2)
59
+ if (encode(new Uint8Array(headerBytes + candidateChunkBytes)).length <= limit) lowerBound = candidateChunkBytes
60
+ else upperBound = candidateChunkBytes - 1
61
+ }
62
+ return lowerBound
63
+ }
64
+
43
65
  /**
44
66
  * 将消息切成带帧头的分片。
45
67
  * @param {string | Uint8Array} frameId 消息 id(hex 或 16 字节)
@@ -50,7 +72,7 @@ export function randomFrameIdHex() {
50
72
  export function encodeFrames(frameId, bytes, maxChunkBytes = DEFAULT_MAX_FRAME_CHUNK_BYTES) {
51
73
  const body = toBytes(bytes)
52
74
  const idBytes = normalizeFrameIdBytes(frameId)
53
- const chunkBytes = Math.max(256, Math.min(DEFAULT_MAX_MESSAGE_BYTES, Number(maxChunkBytes) || DEFAULT_MAX_FRAME_CHUNK_BYTES))
75
+ const chunkBytes = Math.min(DEFAULT_MAX_MESSAGE_BYTES, Number(maxChunkBytes) || DEFAULT_MAX_FRAME_CHUNK_BYTES)
54
76
  const total = Math.max(1, Math.ceil(body.byteLength / chunkBytes))
55
77
  /** @type {Uint8Array[]} */
56
78
  const frames = []
package/link/pipe.mjs CHANGED
@@ -4,7 +4,7 @@ import { ms } from '../utils/duration.mjs'
4
4
  import { emitSafe } from '../utils/emit_safe.mjs'
5
5
  import { createLruMap } from '../utils/lru.mjs'
6
6
 
7
- import { createReassembler, encodeFrames, randomFrameIdHex } from './frame.mjs'
7
+ import { createReassembler, DEFAULT_MAX_FRAME_CHUNK_BYTES, encodeFrames, FRAME_HEADER_BYTES, randomFrameIdHex } from './frame.mjs'
8
8
  import { buildAuth, buildHello, parseHello, verifyAuth } from './handshake.mjs'
9
9
 
10
10
  const encoder = new TextEncoder()
@@ -81,10 +81,14 @@ export function asLinkHandle(pipe, extras = {}) {
81
81
  * @param {number} [options.idleTimeoutMs] 空闲超时
82
82
  * @param {number} [options.handshakeTimeoutMs] 握手超时
83
83
  * @param {number} [options.rttWindowSize] RTT 样本滑动窗口大小
84
+ * @param {number} [options.maxFrameBytes] 单帧最大字节数(provider 按其传输载荷上限折算;默认 15 KiB)
84
85
  * @returns {object} link 句柄 + 入站 API
85
86
  */
86
87
  export function createLinkPipe(options) {
87
88
  const { providerId, level } = options
89
+ const maxFrameBytes = options.maxFrameBytes == null ? DEFAULT_MAX_FRAME_CHUNK_BYTES : Number(options.maxFrameBytes)
90
+ if (maxFrameBytes < FRAME_HEADER_BYTES)
91
+ throw new Error(`p2p: ${providerId} maxFrameBytes too small to carry a frame header`)
88
92
  const heartbeatMs = Number(options.heartbeatMs) || ms('15s')
89
93
  const idleTimeoutMs = Number(options.idleTimeoutMs) || ms('45s')
90
94
  const handshakeTimeoutMs = Number(options.handshakeTimeoutMs) || ms('10s')
@@ -348,7 +352,7 @@ export function createLinkPipe(options) {
348
352
  payload: envelope.payload ?? null,
349
353
  frameId,
350
354
  }))
351
- for (const frame of encodeFrames(frameId, bytes)) {
355
+ for (const frame of encodeFrames(frameId, bytes, maxFrameBytes)) {
352
356
  const sent = options.sendFrame(envelope.action, frame)
353
357
  if (sent?.then) await sent
354
358
  sentFrames++
@@ -4,18 +4,102 @@ import { randomBytes } from 'node:crypto'
4
4
  import { base64ToBytes, bytesToBase64 } from '../../core/bytes_codec.mjs'
5
5
  import { normalizeHex64 } from '../../core/hexIds.mjs'
6
6
  import { getDiscoveryProvider, sendNodeSignalPacket } from '../../discovery/index.mjs'
7
- import { mergeSignalingRelayUrls } from '../../discovery/nostr.mjs'
8
- import { getNodeTransportSettings } from '../../node/identity.mjs'
9
- import { getSignalingRuntimeConfig } from '../../node/instance.mjs'
7
+ import { NOSTR_SIGNAL_KIND, resolveNostrRelayUrls } from '../../discovery/nostr.mjs'
10
8
  import { ms } from '../../utils/duration.mjs'
11
9
  import { createLruMap } from '../../utils/lru.mjs'
10
+ import { FRAME_HEADER_BYTES, maxFrameChunkBytesForPayload } from '../frame.mjs'
12
11
  import { asLinkHandle } from '../pipe.mjs'
13
12
 
14
13
  import { LINK_LEVEL_NOSTR } from './levels.mjs'
15
14
  import { createLinkIdBoundPipe } from './link_id_pipe.mjs'
16
15
 
17
- /** 单包 payload(UTF-8 / base64)上限,避免撞 relay content 限制。 */
18
- const MAX_LINK_PAYLOAD_CHARS = 12 * 1024
16
+ /** 单包 payload(UTF-8 / base64)上限,避免撞 relay content 限制。
17
+ * 默认兜底取 2026-08 本机对默认公共 relay 的 NIP-11 `max_message_length` 非零最小值(131072 = nostr.mom)。
18
+ * 有 relay 信息时用实测非零最小值覆盖(见 refreshPayloadCap),无 relay / 未探测到时用此默认。 */
19
+ export const MAX_LINK_PAYLOAD_CHARS = 131072
20
+
21
+ /** relay info(NIP-11)单次探测超时。 */
22
+ const RELAY_INFO_TIMEOUT_MS = ms('4s')
23
+ /** 实测 payload 上限缓存有效期。 */
24
+ const PAYLOAD_CAP_CACHE_TTL_MS = ms('10m')
25
+ const textEncoder = new TextEncoder()
26
+ /** 固定 64 位 hex 占位(id/pubkey/sig/rendezvousKey/nodeHash 均固定宽度)。 */
27
+ const HEX64 = 'a'.repeat(64)
28
+ /** AES-GCM 封装输出中的固定长度字段占位:iv = base64(12B) = 16,authTag = base64(16B) = 24。 */
29
+ const GCM_IV_BASE64 = 'a'.repeat(16)
30
+ const GCM_AUTH_TAG_BASE64 = 'a'.repeat(24)
31
+
32
+ /**
33
+ * 估算把给定 link 包发布为 Nostr EVENT 后,完整 WebSocket 消息(["EVENT", event])的 UTF-8 字节长度。
34
+ * id/pubkey/sig/rendezvousKey/nodeHash 均为固定 64 hex,created_at/kind/tags 固定,故长度仅随 packet.payload 变化,
35
+ * 可同步精确构造(含加密封装壳与 event 字段),无需真正 AES-GCM 封装与 Schnorr 签名。
36
+ * @param {object} packet link 包
37
+ * @returns {number} 完整消息字节长度
38
+ */
39
+ export function estimateEventMessageBytes(packet) {
40
+ // encryptSignalPacket 输出全 ASCII:{"iv":<16>,"authTag":<24>,"ciphertext":base64(packetJson bytes)}。
41
+ return textEncoder.encode(JSON.stringify(['EVENT', {
42
+ id: HEX64,
43
+ pubkey: HEX64,
44
+ created_at: Math.floor(Date.now() / 1000),
45
+ kind: NOSTR_SIGNAL_KIND,
46
+ tags: [['t', HEX64], ['x', 'signal'], ['p', HEX64]],
47
+ content: bytesToBase64(textEncoder.encode(JSON.stringify({
48
+ iv: GCM_IV_BASE64,
49
+ authTag: GCM_AUTH_TAG_BASE64,
50
+ ciphertext: bytesToBase64(textEncoder.encode(JSON.stringify(packet))),
51
+ }))),
52
+ sig: HEX64,
53
+ }])).length
54
+ }
55
+
56
+ /** relay cap 低于此字符数视为无法承载最小正 chunk(帧头 + 1 字节 chunk 的完整 EVENT 封装),从统一上限中剔除。
57
+ * 这类 relay 即便能传也无法携带有效载荷(maxFrameChunkBytesForPayload 得 0),参与取最小值只会无谓拖低/毒化整条链路。 */
58
+ export const MIN_USABLE_RELAY_CAP_CHARS = estimateEventMessageBytes({
59
+ type: 'link',
60
+ op: 'b',
61
+ from: HEX64,
62
+ linkId: HEX64,
63
+ payload: bytesToBase64(new Uint8Array(FRAME_HEADER_BYTES + 1)),
64
+ })
65
+
66
+ /**
67
+ * 拉取单个 relay 的 NIP-11 relay info 并读取 max_message_length。
68
+ * @param {string} relayUrl relay URL
69
+ * @returns {Promise<number | null>} 非零上限;失败/未声明返回 null
70
+ */
71
+ async function queryRelayMaxMessageLength(relayUrl) {
72
+ const httpUrl = relayUrl.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:')
73
+ const controller = new AbortController()
74
+ const timer = setTimeout(() => controller.abort(), RELAY_INFO_TIMEOUT_MS)
75
+ timer.unref?.()
76
+ try {
77
+ const response = await fetch(httpUrl, { headers: { Accept: 'application/nostr+json' }, signal: controller.signal })
78
+ const info = await response.json()
79
+ const limit = { ...info?.limit || {}, ...info?.limitation || {} }
80
+ const value = Number(limit.max_message_length)
81
+ return Number.isFinite(value) && value > 0 ? value : null
82
+ }
83
+ catch {
84
+ return null
85
+ }
86
+ finally {
87
+ clearTimeout(timer)
88
+ }
89
+ }
90
+
91
+ /**
92
+ * 从各 relay 上报的 cap 中取「可用」(>= 最小可用帧,能产生正数 chunk budget)的非零最小值。
93
+ * cap 过低的 relay 无法承载最小正 chunk(完整 EVENT 封装装不下),取最小值只会拖低/毒化整条链路,故剔除;
94
+ * 剩余 relay 的最小值保证任意一个可用 relay 都能传该帧(publishEvent 只要求任一 relay 接受)。
95
+ * @param {Array<number | null | undefined>} caps 各 relay 上报的 cap
96
+ * @returns {number | null} 可用最小值;无可用 relay 返回 null
97
+ */
98
+ export function minUsablePayloadCap(caps) {
99
+ const usable = caps.filter(value => Number.isFinite(value) && value >= MIN_USABLE_RELAY_CAP_CHARS)
100
+ return usable.length ? Math.min(...usable) : null
101
+ }
102
+
19
103
  /** open 到达前为同一 linkId 暂存的 c/b 包上限。 */
20
104
  const PENDING_PACKETS_MAX = 32
21
105
  /** Nostr 链握手超时(relay RTT 更慢)。 */
@@ -25,14 +109,6 @@ const NOSTR_HEARTBEAT_MS = ms('60s')
25
109
  /** Nostr 链空闲超时。 */
26
110
  const NOSTR_IDLE_TIMEOUT_MS = ms('3m')
27
111
 
28
- /**
29
- * @returns {string[]} 当前可用中继 URL
30
- */
31
- function resolveDefaultRelayUrls() {
32
- return getSignalingRuntimeConfig().relayOverride
33
- ?? mergeSignalingRelayUrls(getNodeTransportSettings().relayUrls)
34
- }
35
-
36
112
  /**
37
113
  * @param {string} remoteNodeHash 对端
38
114
  * @param {object} packet link 包
@@ -45,10 +121,48 @@ async function publishLinkPacket(remoteNodeHash, packet) {
45
121
  /**
46
122
  * 创建 Nostr 末位数据链路 provider(level = -∞)。
47
123
  * @param {{ getRelayUrls?: () => string[] }} [options] 中继解析(测试可注入)
48
- * @returns {import('./index.mjs').LinkProvider & { deliverPacket: (packet: object) => void }} provider
124
+ * @returns {import('./index.mjs').LinkProvider & { deliverPacket: (packet: object) => void | Promise<void> }} provider
49
125
  */
50
126
  export function createNostrLinkProvider(options = {}) {
51
- const resolveRelayUrls = options.getRelayUrls || resolveDefaultRelayUrls
127
+ const resolveRelayUrls = options.getRelayUrls || resolveNostrRelayUrls
128
+
129
+ /** @type {number | null} 本实例实测非零最小 payload 上限(NIP-11 max_message_length) */
130
+ let queriedPayloadCap = null
131
+ /** @type {number} 本实例最近一次实测时间戳 */
132
+ let payloadCapQueriedAt = 0
133
+ /** @type {Promise<void> | null} 本实例进行中的探测(去重并发) */
134
+ let payloadCapRefreshPromise = null
135
+
136
+ /**
137
+ * 探测各 relay 的 payload 上限,缓存「可用 relay」的最小值(TTL 内不重复探测)。
138
+ * 仅在实际 wss/ws/https/http 中继上探测;失败或未声明的 relay 忽略。
139
+ * @param {string[]} relayUrls 中继 URL 列表
140
+ * @returns {Promise<void>}
141
+ */
142
+ async function refreshPayloadCap(relayUrls) {
143
+ if (payloadCapRefreshPromise) return payloadCapRefreshPromise
144
+ if (queriedPayloadCap != null && Date.now() - payloadCapQueriedAt < PAYLOAD_CAP_CACHE_TTL_MS) return
145
+ const urls = [...new Set(relayUrls.filter(url => /^(wss?|https?):\/\//i.test(url)))]
146
+ if (!urls.length) return
147
+ payloadCapRefreshPromise = Promise.allSettled(urls.map(queryRelayMaxMessageLength))
148
+ .then(results => {
149
+ const value = minUsablePayloadCap(results.map(result => result.status === 'fulfilled' ? result.value : null))
150
+ if (value != null) {
151
+ queriedPayloadCap = value
152
+ payloadCapQueriedAt = Date.now()
153
+ }
154
+ })
155
+ .finally(() => { payloadCapRefreshPromise = null })
156
+ return payloadCapRefreshPromise
157
+ }
158
+
159
+ /**
160
+ * 当前生效的单包上限:有 relay 实测非零最小值则用它(最大化载荷利用率),否则用默认兜底。
161
+ * @returns {number} 上限(针对完整 Nostr EVENT 消息的字节数)
162
+ */
163
+ function currentMaxPayloadChars() {
164
+ return queriedPayloadCap ?? MAX_LINK_PAYLOAD_CHARS
165
+ }
52
166
 
53
167
  /** @type {((link: import('./index.mjs').LinkHandle) => void) | null} */
54
168
  let onInbound = null
@@ -119,11 +233,10 @@ export function createNostrLinkProvider(options = {}) {
119
233
  * @param {string} linkId 链路 id
120
234
  * @param {string} op open|c|b|close
121
235
  * @param {string} [payload] 可选载荷
236
+ * @param {number} [maxChars=MAX_LINK_PAYLOAD_CHARS] 该 pipe 冻结的字符上限
122
237
  * @returns {Promise<void>}
123
238
  */
124
- async function sendOp(remoteNodeHash, linkId, op, payload) {
125
- if (payload != null && payload.length > MAX_LINK_PAYLOAD_CHARS)
126
- throw new Error('p2p: nostr link payload too large')
239
+ async function sendOp(remoteNodeHash, linkId, op, payload, maxChars = MAX_LINK_PAYLOAD_CHARS) {
127
240
  const packet = {
128
241
  type: 'link',
129
242
  op,
@@ -131,6 +244,8 @@ export function createNostrLinkProvider(options = {}) {
131
244
  linkId,
132
245
  }
133
246
  if (payload != null) packet.payload = payload
247
+ if (estimateEventMessageBytes(packet) > maxChars)
248
+ throw new Error('p2p: nostr link payload too large')
134
249
  await publishLinkPacket(remoteNodeHash, packet)
135
250
  }
136
251
 
@@ -140,6 +255,19 @@ export function createNostrLinkProvider(options = {}) {
140
255
  */
141
256
  function openPipe(opts) {
142
257
  const { linkId, remoteNodeHash, initiator } = opts
258
+ // 冻结该 pipe 的字符上限:按完整 Nostr EVENT 消息字节数切帧,与发送校验共用同一上限,保持自洽。
259
+ const payloadChars = currentMaxPayloadChars()
260
+ const maxFrameBytes = maxFrameChunkBytesForPayload(
261
+ payloadChars,
262
+ // maxFrameChunkBytesForPayload 用 encode(...).length 度量载荷,故返回等长字符串。
263
+ frameBytes => 'x'.repeat(estimateEventMessageBytes({
264
+ type: 'link',
265
+ op: 'b',
266
+ from: (opts.localIdentity || localIdentity)?.nodeHash || '',
267
+ linkId,
268
+ payload: bytesToBase64(frameBytes),
269
+ })),
270
+ )
143
271
  const pipe = createLinkIdBoundPipe({
144
272
  providerId: 'nostr',
145
273
  level: LINK_LEVEL_NOSTR,
@@ -147,6 +275,7 @@ export function createNostrLinkProvider(options = {}) {
147
275
  linkId,
148
276
  nodeHash: remoteNodeHash,
149
277
  localIdentity: opts.localIdentity || localIdentity,
278
+ maxFrameBytes,
150
279
  handshakeTimeoutMs: NOSTR_HANDSHAKE_TIMEOUT_MS,
151
280
  heartbeatMs: NOSTR_HEARTBEAT_MS,
152
281
  idleTimeoutMs: NOSTR_IDLE_TIMEOUT_MS,
@@ -155,7 +284,7 @@ export function createNostrLinkProvider(options = {}) {
155
284
  * @returns {Promise<void>}
156
285
  */
157
286
  async sendControlText(text) {
158
- await sendOp(remoteNodeHash, linkId, 'c', text)
287
+ await sendOp(remoteNodeHash, linkId, 'c', text, payloadChars)
159
288
  },
160
289
  /**
161
290
  * @param {string} _action action
@@ -163,7 +292,7 @@ export function createNostrLinkProvider(options = {}) {
163
292
  * @returns {Promise<void>}
164
293
  */
165
294
  async sendFrame(_action, frame) {
166
- await sendOp(remoteNodeHash, linkId, 'b', bytesToBase64(frame))
295
+ await sendOp(remoteNodeHash, linkId, 'b', bytesToBase64(frame), payloadChars)
167
296
  },
168
297
  /**
169
298
  * @returns {Promise<void>}
@@ -184,9 +313,9 @@ export function createNostrLinkProvider(options = {}) {
184
313
  /**
185
314
  * 入站已解密的 link 包(由信令 demux 调用)。
186
315
  * @param {object} packet link 包
187
- * @returns {void}
316
+ * @returns {Promise<void>}
188
317
  */
189
- function deliverPacket(packet) {
318
+ async function deliverPacket(packet) {
190
319
  if (packet?.type !== 'link') return
191
320
  const linkId = normalizeHex64(packet.linkId)
192
321
  const from = normalizeHex64(packet.from)
@@ -197,6 +326,8 @@ export function createNostrLinkProvider(options = {}) {
197
326
  if (op === 'open') {
198
327
  if (sessions.has(linkId)) return
199
328
  if (!onInbound || !localIdentity) return
329
+ await refreshPayloadCap(resolveRelayUrls())
330
+ if (sessions.has(linkId)) return
200
331
  const pipe = openPipe({
201
332
  linkId,
202
333
  remoteNodeHash: from,
@@ -248,6 +379,7 @@ export function createNostrLinkProvider(options = {}) {
248
379
  if (!remoteNodeHash) return null
249
380
  localIdentity = dialOptions.localIdentity || localIdentity
250
381
  if (!localIdentity?.nodeHash) throw new Error('p2p: nostr dial requires localIdentity')
382
+ await refreshPayloadCap(resolveRelayUrls())
251
383
  const linkId = randomBytes(32).toString('hex')
252
384
  const pipe = openPipe({
253
385
  linkId,
@@ -266,6 +398,7 @@ export function createNostrLinkProvider(options = {}) {
266
398
  ensureListening(handlers) {
267
399
  onInbound = handlers.onInbound
268
400
  localIdentity = handlers.localIdentity
401
+ void refreshPayloadCap(resolveRelayUrls())
269
402
  return () => {
270
403
  onInbound = null
271
404
  for (const session of sessions.values())
@@ -68,7 +68,7 @@ export async function createWebRtcLink(options) {
68
68
  const handshakeTimeoutMs = Number(options.handshakeTimeoutMs) || ms('10s')
69
69
  const channelOpenTimeoutMs = Math.max(handshakeTimeoutMs, ms('30s'))
70
70
  const rtc = options.rtc ?? await loadNodeRtcPolyfill()
71
- const trickleIceOff = getSignalingRuntimeConfig().trickleIceOff === true
71
+ const trickleIceOff = getSignalingRuntimeConfig().channels.webrtc?.trickleIceOff === true
72
72
  const peerConnection = new rtc.RTCPeerConnection(options.iceServers?.length ? { iceServers: options.iceServers } : undefined)
73
73
  const remoteSignalQueue = []
74
74
  const seenRemoteSignals = createLruMap(1024)
@@ -99,7 +99,7 @@ async function loadNodeRtcPolyfillUncached(options) {
99
99
  for (const backend of backends)
100
100
  try {
101
101
  const mod = await backend.load()
102
- const { iceLocalHostnamePolicy } = getSignalingRuntimeConfig()
102
+ const iceLocalHostnamePolicy = getSignalingRuntimeConfig().channels.webrtc?.iceLocalHostnamePolicy
103
103
  return {
104
104
  RTCPeerConnection: wrapRtcPeerConnectionForIceLocalHostname(
105
105
  mod.RTCPeerConnection,
package/node/instance.mjs CHANGED
@@ -83,9 +83,13 @@ export function setNodeLogger(logger) {
83
83
  */
84
84
  export function setSignalingRuntimeConfig(config) {
85
85
  if (!runtime) throw new Error('p2p: setSignalingRuntimeConfig requires initNode')
86
- const previousPolicy = runtime.signaling.iceLocalHostnamePolicy
87
- runtime.signaling = resolveSignalingRuntimeConfig({ ...runtime.signaling, ...config })
88
- if (runtime.signaling.iceLocalHostnamePolicy !== previousPolicy)
86
+ const previousPolicy = runtime.signaling.channels.webrtc?.iceLocalHostnamePolicy
87
+ runtime.signaling = resolveSignalingRuntimeConfig({
88
+ ...runtime.signaling,
89
+ ...config,
90
+ channels: { ...runtime.signaling.channels, ...config?.channels },
91
+ })
92
+ if (runtime.signaling.channels.webrtc?.iceLocalHostnamePolicy !== previousPolicy)
89
93
  rtcPolyfillCacheEpoch++
90
94
  emitNodeChange('signaling-changed', runtime.signaling)
91
95
  }
@@ -4,25 +4,64 @@ import process from 'node:process'
4
4
 
5
5
  /**
6
6
  * @typedef {{
7
- * relayOverride: string[] | null
8
- * iceLocalHostnamePolicy: IceLocalHostnamePolicy
9
- * trickleIceOff: boolean
7
+ * channels: ChannelsConfig
10
8
  * }} SignalingRuntimeConfig
11
9
  */
12
10
 
13
- const ICE_LOCAL_HOSTNAME_POLICIES = new Set(['none', 'rewrite-loopback', 'drop'])
11
+ /**
12
+ * @typedef {Record<string, object | boolean>} ChannelsConfig
13
+ */
14
14
 
15
15
  /**
16
- * 生产默认:win32 丢弃 `.local` host candidate;其它平台不过滤。
17
- * @returns {SignalingRuntimeConfig} 默认信令运行时配置
16
+ * webrtc 通道默认配置。
17
+ * @returns {{ iceLocalHostnamePolicy: IceLocalHostnamePolicy, trickleIceOff: boolean }} webrtc 通道默认配置
18
18
  */
19
- export function defaultSignalingRuntimeConfig() {
19
+ function defaultWebRtcConfig() {
20
20
  const iceLocalHostnamePolicy = process.platform === 'win32' ? 'drop' : 'none'
21
- return {
22
- relayOverride: null,
23
- iceLocalHostnamePolicy,
24
- trickleIceOff: iceLocalHostnamePolicy !== 'none',
21
+ return { iceLocalHostnamePolicy, trickleIceOff: iceLocalHostnamePolicy !== 'none' }
22
+ }
23
+
24
+ /** 各介质的默认 channel 配置;对象值是启用并覆盖默认。 */
25
+ const DEFAULT_CHANNEL_CONFIG = {
26
+ nostr: true,
27
+ lan: true,
28
+ bt: true,
29
+ webrtc: defaultWebRtcConfig(),
30
+ }
31
+
32
+ /**
33
+ * 归一到完整 channels 记录:false 禁用,其余(undefined / true / object)启用并合并默认配置。
34
+ * 未提及的通道保持默认启用。
35
+ * @param {object | undefined} [raw] 原始 channels 配置
36
+ * @returns {ChannelsConfig} 每个已知通道都有明确值(object | false)的完整记录
37
+ */
38
+ function resolveChannels(raw = {}) {
39
+ /** @type {ChannelsConfig} */
40
+ const out = {}
41
+ for (const name of Object.keys(DEFAULT_CHANNEL_CONFIG)) {
42
+ const value = raw[name]
43
+ out[name] = value !== false && { ...Object(DEFAULT_CHANNEL_CONFIG[name]), ...Object(value) }
25
44
  }
45
+ return out
46
+ }
47
+
48
+ /**
49
+ * 生成禁用全部通道的 channels 配置;`overrides` 可重新启用/覆盖指定通道。
50
+ * @param {Record<string, object | boolean>} [overrides] 需覆盖的通道配置
51
+ * @returns {ChannelsConfig} 除覆盖外全部 false 的 channels 配置
52
+ */
53
+ export function disableAllChannels(overrides = {}) {
54
+ /** @type {ChannelsConfig} */
55
+ const disabledChannels = {}
56
+ for (const name of Object.keys(DEFAULT_CHANNEL_CONFIG)) disabledChannels[name] = false
57
+ return { ...disabledChannels, ...overrides }
58
+ }
59
+
60
+ /**
61
+ * @returns {SignalingRuntimeConfig} 默认信令运行时配置
62
+ */
63
+ export function defaultSignalingRuntimeConfig() {
64
+ return { channels: resolveChannels() }
26
65
  }
27
66
 
28
67
  /**
@@ -30,22 +69,6 @@ export function defaultSignalingRuntimeConfig() {
30
69
  * @returns {SignalingRuntimeConfig} 合并后的信令运行时配置
31
70
  */
32
71
  export function resolveSignalingRuntimeConfig(patch = {}) {
33
- const base = defaultSignalingRuntimeConfig()
34
- if (!patch || typeof patch !== 'object') return base
35
- const policyRaw = patch.iceLocalHostnamePolicy
36
- const iceLocalHostnamePolicy = ICE_LOCAL_HOSTNAME_POLICIES.has(/** @type {string} */ policyRaw)
37
- ? /** @type {IceLocalHostnamePolicy} */ policyRaw
38
- : base.iceLocalHostnamePolicy
39
- let { relayOverride } = base
40
- if (Object.prototype.hasOwnProperty.call(patch, 'relayOverride'))
41
- relayOverride = patch.relayOverride == null
42
- ? null
43
- : [...new Set((Array.isArray(patch.relayOverride) ? patch.relayOverride : [])
44
- .map(url => String(url || ''))
45
- .filter(url => url.startsWith('wss://')))]
46
- return {
47
- relayOverride,
48
- iceLocalHostnamePolicy,
49
- trickleIceOff: patch.trickleIceOff !== undefined ? !!patch.trickleIceOff : iceLocalHostnamePolicy !== 'none',
50
- }
72
+ if (!patch || typeof patch !== 'object') return defaultSignalingRuntimeConfig()
73
+ return { channels: resolveChannels(patch.channels) }
51
74
  }
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.32",
3
+ "version": "0.0.34",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
7
7
  "p2p",
8
8
  "fount",
9
- "AI"
9
+ "AI",
10
+ "nostr",
11
+ "LAN",
12
+ "bluetooth"
10
13
  ],
11
14
  "type": "module",
12
15
  "repository": {
@@ -536,6 +536,7 @@ export function createLinkRegistry(options = {}) {
536
536
  lanTcpPort: bootstrap.lanTcpPort,
537
537
  whenListening: bootstrap.whenListening,
538
538
  ensureRuntime: ensureRuntimeWithMesh,
539
+ ensureChannelAvailable: bootstrap.ensureChannelAvailable,
539
540
  reloadDiscoveryRelays: bootstrap.reloadDiscoveryRelays,
540
541
  ensureOverlayRouter,
541
542
  getOverlayRouter,
@@ -759,6 +760,12 @@ export const ensureOverlayRouter = bindRegistryMethod('ensureOverlayRouter')
759
760
  export const reloadDiscoveryRelays = bindRegistryMethod('reloadDiscoveryRelays')
760
761
  /** 确保到 nodeHash 的活跃链路。 @type {(...methodArguments: unknown[]) => unknown} */
761
762
  export const ensureLinkToNode = bindRegistryMethod('ensureLinkToNode')
763
+ /**
764
+ * 确保指定 channel 可用。
765
+ * @param {string} channel 通道名
766
+ * @returns {Promise<boolean>} 该 channel 可用为 true
767
+ */
768
+ export const ensureChannelAvailable = bindRegistryMethod('ensureChannelAvailable')
762
769
  /** @type {(...methodArguments: unknown[]) => unknown} */
763
770
  export const getLink = bindRegistryMethod('getLink')
764
771
  /** @type {(...methodArguments: unknown[]) => unknown} */
@@ -10,7 +10,7 @@ import {
10
10
  unregisterDiscoveryProvider,
11
11
  } from '../discovery/index.mjs'
12
12
  import { createLanDiscoveryProvider } from '../discovery/lan.mjs'
13
- import { mergeSignalingRelayUrls, createNostrDiscoveryProvider } from '../discovery/nostr.mjs'
13
+ import { createNostrDiscoveryProvider, resolveNostrRelayUrls } from '../discovery/nostr.mjs'
14
14
  import { createBleGattLinkProvider } from '../link/providers/ble_gatt.mjs'
15
15
  import {
16
16
  listLinkProviders,
@@ -20,7 +20,6 @@ import {
20
20
  import { createLanTcpLinkProvider } from '../link/providers/lan_tcp.mjs'
21
21
  import { createNostrLinkProvider } from '../link/providers/nostr.mjs'
22
22
  import { createWebRtcLinkProvider } from '../link/providers/webrtc.mjs'
23
- import { getNodeTransportSettings } from '../node/identity.mjs'
24
23
  import { getSignalingRuntimeConfig, onNodeChange } from '../node/instance.mjs'
25
24
  import { isConnectivityDebug, nodeDebug, shortHash } from '../node/log.mjs'
26
25
 
@@ -85,14 +84,12 @@ export function createRuntimeBootstrap(deps) {
85
84
  let signalListenReady = null
86
85
  /** @type {Promise<void> | null} */
87
86
  let runtimeWarm = null
88
- /** @type {Promise<void> | null} */
89
- let bluetoothWarm = null
90
87
  /** @type {(() => void) | null} */
91
88
  let stopPresence = null
92
89
  /** @type {(() => void) | null} */
93
90
  let stopSignalListener = null
94
- /** @type {Array<() => void>} */
95
- const stopLinkListeners = []
91
+ /** @type {Map<string, () => void>} */
92
+ const stopLinkListeners = new Map()
96
93
  /** @type {ReturnType<typeof createLanTcpLinkProvider> | null} */
97
94
  let ownedLanTcp = null
98
95
  /** @type {ReturnType<typeof createBleGattLinkProvider> | null} */
@@ -104,22 +101,11 @@ export function createRuntimeBootstrap(deps) {
104
101
  let stopSignalingWatch = null
105
102
 
106
103
  /**
107
- * @returns {string[]} 当前 Nostr relay URL 列表
104
+ * @param {string} name 通道名
105
+ * @returns {boolean} 该通道是否启用(默认启用,false 禁用)
108
106
  */
109
- function resolveNostrRelayUrls() {
110
- return getSignalingRuntimeConfig().relayOverride
111
- ?? mergeSignalingRelayUrls(getNodeTransportSettings().relayUrls)
112
- }
113
-
114
- /** 注册默认 discovery provider(lan/nostr) */
115
- function registerDiscoveryDefaults() {
116
- const providerIds = new Set(listDiscoveryProviders().map(provider => provider.id))
117
- if (!providerIds.has('lan'))
118
- registerDiscoveryProvider(createLanDiscoveryProvider({
119
- localNodeHash: localIdentity.nodeHash,
120
- }))
121
- if (!providerIds.has('nostr'))
122
- registerNostrProvider()
107
+ function isChannelEnabled(name) {
108
+ return getSignalingRuntimeConfig().channels[name] !== false
123
109
  }
124
110
 
125
111
  /** 注册/替换 nostr discovery provider */
@@ -138,26 +124,6 @@ export function createRuntimeBootstrap(deps) {
138
124
  return runtimeStarted
139
125
  }
140
126
 
141
- /**
142
- * @returns {Promise<void>}
143
- */
144
- async function ensureLinkProviders() {
145
- if (!autoRegisterLinkProviders) return
146
- if (!ownedLanTcp) {
147
- ownedLanTcp = createLanTcpLinkProvider()
148
- registerLinkProvider(ownedLanTcp)
149
- }
150
- if (!ownedBleGatt) {
151
- ownedBleGatt = createBleGattLinkProvider()
152
- registerLinkProvider(ownedBleGatt)
153
- }
154
- const ids = new Set(listLinkProviders().map(provider => provider.id))
155
- if (!ids.has('webrtc'))
156
- registerLinkProvider(createWebRtcLinkProvider())
157
- if (!ids.has('nostr'))
158
- registerLinkProvider(createNostrLinkProvider({ getRelayUrls: resolveNostrRelayUrls }))
159
- }
160
-
161
127
  /**
162
128
  * @returns {number | null} 本机 lan_tcp 监听端口,未就绪为 null
163
129
  */
@@ -196,12 +162,13 @@ export function createRuntimeBootstrap(deps) {
196
162
  */
197
163
  async function startProviderListening(provider) {
198
164
  if (!provider.ensureListening) return
165
+ stopLinkListeners.get(provider.id)?.()
199
166
  try {
200
167
  const stop = await provider.ensureListening({
201
168
  localIdentity,
202
169
  onInbound: onInboundLink,
203
170
  })
204
- if (stop) stopLinkListeners.push(stop)
171
+ if (stop) stopLinkListeners.set(provider.id, stop)
205
172
  }
206
173
  catch { /* provider listen unavailable */ }
207
174
  }
@@ -214,60 +181,6 @@ export function createRuntimeBootstrap(deps) {
214
181
  return encryptAdvertForScope('network', localIdentity, body)
215
182
  }
216
183
 
217
- /**
218
- * @param {number} gen 启动世代
219
- * @returns {Promise<void>}
220
- */
221
- async function warmBluetoothTask(gen) {
222
- if (autoRegisterDiscoveryProviders) {
223
- const providerIds = new Set(listDiscoveryProviders().map(provider => provider.id))
224
- if (!providerIds.has('bt')) {
225
- const bt = await import('../discovery/bt/index.mjs').catch(() => null)
226
- if (generation !== gen || !isLive()) return
227
- if (await bt?.canUseBluetoothRuntime?.()) {
228
- if (generation !== gen || !isLive()) return
229
- const provider = bt.createBluetoothDiscoveryProvider()
230
- registerDiscoveryProvider(provider)
231
- if (generation !== gen || !isLive()) return
232
- if (provider.startPresence)
233
- try {
234
- const stop = await provider.startPresence(async () => ({
235
- nodeHash: localIdentity.nodeHash,
236
- advertBytes: await buildNetworkAdvertBytes(),
237
- }))
238
- if (stop && generation === gen && isLive()) {
239
- const prev = stopPresence
240
- /**
241
- * 停止 presence 广播并链式调用上一轮清理。
242
- */
243
- stopPresence = () => { try { stop() } catch { /* ignore */ }; prev?.() }
244
- }
245
- }
246
- catch { /* ignore */ }
247
- if (provider.caps?.canSignal && provider.listenNodeSignals)
248
- try {
249
- const stop = await provider.listenNodeSignals(localIdentity.nodeHash, bytes => {
250
- void handleIncomingSignal(bytes).catch(() => { })
251
- })
252
- if (stop && generation === gen && isLive()) {
253
- const prev = stopSignalListener
254
- /**
255
- * 停止信令监听并链式调用上一轮清理。
256
- */
257
- stopSignalListener = () => { try { stop() } catch { /* ignore */ }; prev?.() }
258
- }
259
- }
260
- catch { /* ignore */ }
261
- }
262
- }
263
- }
264
- if (generation !== gen || !isLive()) return
265
- if (ownedBleGatt && await Promise.resolve(ownedBleGatt.isAvailable())) {
266
- if (generation !== gen || !isLive()) return
267
- await startProviderListening(ownedBleGatt)
268
- }
269
- }
270
-
271
184
  /**
272
185
  * @param {number} gen 启动世代
273
186
  * @returns {Promise<void>}
@@ -306,20 +219,91 @@ export function createRuntimeBootstrap(deps) {
306
219
  })()
307
220
  }
308
221
 
222
+ /**
223
+ * 释放指定 id 的 link provider 监听与注册
224
+ * @param {id: string} id link provider id
225
+ */
226
+ function releaseLinkProvider(id) {
227
+ const stop = stopLinkListeners.get(id)
228
+ stopLinkListeners.delete(id)
229
+ try { stop?.() } catch { /* ignore */ }
230
+ unregisterLinkProvider(id)
231
+ }
232
+
233
+ /** 按 channels 配置同步 link provider(启用注册 / 禁用注销) */
234
+ function reconcileLinkProviders() {
235
+ if (!autoRegisterLinkProviders) return
236
+ const present = new Set(listLinkProviders().map(provider => provider.id.split(':')[0]))
237
+ if (isChannelEnabled('nostr')) {
238
+ if (!present.has('nostr'))
239
+ registerLinkProvider(createNostrLinkProvider({ getRelayUrls: resolveNostrRelayUrls }))
240
+ }
241
+ else releaseLinkProvider('nostr')
242
+
243
+ if (isChannelEnabled('lan')) {
244
+ if (!ownedLanTcp) {
245
+ ownedLanTcp = createLanTcpLinkProvider()
246
+ registerLinkProvider(ownedLanTcp)
247
+ }
248
+ }
249
+ else if (ownedLanTcp) {
250
+ releaseLinkProvider(ownedLanTcp.id)
251
+ ownedLanTcp = null
252
+ }
253
+ if (isChannelEnabled('bt')) {
254
+ if (!ownedBleGatt) {
255
+ ownedBleGatt = createBleGattLinkProvider()
256
+ registerLinkProvider(ownedBleGatt)
257
+ }
258
+ }
259
+ else if (ownedBleGatt) {
260
+ releaseLinkProvider(ownedBleGatt.id)
261
+ ownedBleGatt = null
262
+ }
263
+ if (isChannelEnabled('webrtc')) {
264
+ if (!listLinkProviders().some(provider => provider.id.split(':')[0] === 'webrtc'))
265
+ registerLinkProvider(createWebRtcLinkProvider())
266
+ }
267
+ else releaseLinkProvider('webrtc')
268
+ }
269
+
270
+ /** 按 channels 配置同步 discovery provider(启用注册 / 禁用注销;BT discovery 由 ensureChannelAvailable 显式注册) */
271
+ function reconcileDiscoveryProviders() {
272
+ if (!autoRegisterDiscoveryProviders) return
273
+ const present = new Set(listDiscoveryProviders().map(provider => provider.id))
274
+ if (isChannelEnabled('lan')) {
275
+ if (!present.has('lan'))
276
+ registerDiscoveryProvider(createLanDiscoveryProvider({ localNodeHash: localIdentity.nodeHash }))
277
+ }
278
+ else unregisterDiscoveryProvider('lan')
279
+
280
+ if (isChannelEnabled('nostr')) registerNostrProvider()
281
+ else unregisterDiscoveryProvider('nostr')
282
+ if (!isChannelEnabled('bt')) unregisterDiscoveryProvider('bt')
283
+ }
284
+
309
285
  /**
310
286
  * @returns {Promise<void>}
311
287
  */
312
288
  async function reloadDiscoveryRelays() {
313
- if (!runtimeStarted || !autoRegisterDiscoveryProviders) return
314
289
  if (reloadInflight) return await reloadInflight
315
290
  reloadInflight = (async () => {
291
+ if (runtimeStart) await runtimeStart
292
+ await runtimeWarm?.catch(() => { })
316
293
  const gen = generation
294
+ if (!isLive()) return
317
295
  stopPresence?.()
318
296
  stopSignalListener?.()
319
297
  stopPresence = null
320
298
  stopSignalListener = null
321
- registerNostrProvider()
299
+ reconcileLinkProviders()
300
+ reconcileDiscoveryProviders()
322
301
  if (generation !== gen || !isLive()) return
302
+ if (ownedLanTcp && !stopLinkListeners.has(ownedLanTcp.id))
303
+ await startProviderListening(ownedLanTcp)
304
+ if (ownedBleGatt && !stopLinkListeners.has(ownedBleGatt.id)
305
+ && await Promise.resolve(ownedBleGatt.isAvailable()))
306
+ await startProviderListening(ownedBleGatt)
323
307
  signalListenReady = (async () => {
324
308
  if (generation !== gen || !isLive()) return
325
309
  if (!listDiscoveryProviders().length) return
@@ -353,9 +337,9 @@ export function createRuntimeBootstrap(deps) {
353
337
  runtimeStart = (async () => {
354
338
  runtimeStarted = true
355
339
  const gen = generation
356
- await ensureLinkProviders()
340
+ reconcileLinkProviders()
357
341
  if (autoRegisterDiscoveryProviders)
358
- registerDiscoveryDefaults()
342
+ reconcileDiscoveryProviders()
359
343
  if (isConnectivityDebug())
360
344
  nodeDebug('p2p:runtime ensure', {
361
345
  self: shortHash(localIdentity.nodeHash),
@@ -367,7 +351,6 @@ export function createRuntimeBootstrap(deps) {
367
351
  if (event === 'signaling-changed')
368
352
  void reloadDiscoveryRelays().catch(() => { })
369
353
  })
370
- bluetoothWarm = warmBluetoothTask(gen).catch(() => { })
371
354
  runtimeWarm = warmListenAndDiscovery(gen)
372
355
  void runtimeWarm.catch(() => { })
373
356
  })()
@@ -379,6 +362,39 @@ export function createRuntimeBootstrap(deps) {
379
362
  }
380
363
  }
381
364
 
365
+ /**
366
+ * 确保指定 channel 可用:重 channel(bt)做探测并注册 discovery;其余 await 其渐进式就绪。
367
+ * @param {string} channel 通道名
368
+ * @returns {Promise<boolean>} 该 channel 可用为 true
369
+ */
370
+ async function ensureChannelAvailable(channel) {
371
+ if (!autoRegisterDiscoveryProviders) return false
372
+ if (runtimeStart) await runtimeStart
373
+ if (!isLive()) return false
374
+ switch (channel) {
375
+ case 'lan':
376
+ await whenListening()
377
+ return isChannelEnabled('lan')
378
+ case 'nostr':
379
+ case 'webrtc':
380
+ await whenSignalListening()
381
+ return isChannelEnabled(channel)
382
+ case 'bt': {
383
+ if (!isChannelEnabled('bt')) return false
384
+ if (listDiscoveryProviders().some(provider => provider.id === 'bt')) return true
385
+ const bt = await import('../discovery/bt/index.mjs')
386
+ if (!isLive()) return false
387
+ if (await bt.canUseBluetoothRuntime()) {
388
+ registerDiscoveryProvider(bt.createBluetoothDiscoveryProvider())
389
+ await reloadDiscoveryRelays()
390
+ }
391
+ return listDiscoveryProviders().some(provider => provider.id === 'bt')
392
+ }
393
+ default:
394
+ throw new Error(`p2p: unknown channel ${channel}`)
395
+ }
396
+ }
397
+
382
398
  /**
383
399
  * @returns {Promise<void>}
384
400
  */
@@ -395,8 +411,9 @@ export function createRuntimeBootstrap(deps) {
395
411
  runtimeWarm?.catch(() => { }) ?? Promise.resolve(),
396
412
  new Promise(resolve => setTimeout(resolve, 500)),
397
413
  ])
398
- for (const stop of stopLinkListeners.splice(0))
414
+ for (const stop of stopLinkListeners.values())
399
415
  try { stop() } catch { /* ignore */ }
416
+ stopLinkListeners.clear()
400
417
  clearDiscoveryProviders()
401
418
  if (ownedLanTcp) {
402
419
  unregisterLinkProvider(ownedLanTcp.id)
@@ -409,12 +426,12 @@ export function createRuntimeBootstrap(deps) {
409
426
  lanListenReady = null
410
427
  signalListenReady = null
411
428
  runtimeWarm = null
412
- bluetoothWarm = null
413
429
  reloadInflight = null
414
430
  }
415
431
 
416
432
  return {
417
433
  ensureRuntime,
434
+ ensureChannelAvailable,
418
435
  whenListening,
419
436
  whenSignalListening,
420
437
  buildLocalAdvert,