@steve02081504/fount-p2p 0.0.32 → 0.0.33

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'
@@ -200,6 +202,15 @@ export function mergeSignalingRelayUrls(userRelayUrls) {
200
202
  return merged.length ? merged : [...DEFAULT_RELAY_URLS]
201
203
  }
202
204
 
205
+ /**
206
+ * 当前可用 nostr 中继:nostr 通道配置 relay(替换默认),否则节点默认 + 公共中继。
207
+ * @returns {string[]} relay URL 列表
208
+ */
209
+ export function resolveNostrRelayUrls() {
210
+ const relay = getSignalingRuntimeConfig().channels?.nostr?.relay
211
+ return relay?.length ? relay : mergeSignalingRelayUrls(getNodeTransportSettings().relayUrls)
212
+ }
213
+
203
214
  /**
204
215
  * @param {number} kind 事件 kind
205
216
  * @param {string[][]} tags 事件标签
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,
@@ -4,9 +4,7 @@ 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 { resolveNostrRelayUrls } from '../../discovery/nostr.mjs'
10
8
  import { ms } from '../../utils/duration.mjs'
11
9
  import { createLruMap } from '../../utils/lru.mjs'
12
10
  import { asLinkHandle } from '../pipe.mjs'
@@ -25,14 +23,6 @@ const NOSTR_HEARTBEAT_MS = ms('60s')
25
23
  /** Nostr 链空闲超时。 */
26
24
  const NOSTR_IDLE_TIMEOUT_MS = ms('3m')
27
25
 
28
- /**
29
- * @returns {string[]} 当前可用中继 URL
30
- */
31
- function resolveDefaultRelayUrls() {
32
- return getSignalingRuntimeConfig().relayOverride
33
- ?? mergeSignalingRelayUrls(getNodeTransportSettings().relayUrls)
34
- }
35
-
36
26
  /**
37
27
  * @param {string} remoteNodeHash 对端
38
28
  * @param {object} packet link 包
@@ -48,7 +38,7 @@ async function publishLinkPacket(remoteNodeHash, packet) {
48
38
  * @returns {import('./index.mjs').LinkProvider & { deliverPacket: (packet: object) => void }} provider
49
39
  */
50
40
  export function createNostrLinkProvider(options = {}) {
51
- const resolveRelayUrls = options.getRelayUrls || resolveDefaultRelayUrls
41
+ const resolveRelayUrls = options.getRelayUrls || resolveNostrRelayUrls
52
42
 
53
43
  /** @type {((link: import('./index.mjs').LinkHandle) => void) | null} */
54
44
  let onInbound = null
@@ -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.33",
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,