@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 +1 -1
- package/discovery/nostr.mjs +21 -3
- package/docs/runtime.md +15 -3
- package/docs/signaling.md +12 -2
- package/docs/transports.md +1 -1
- package/index.mjs +2 -0
- package/link/frame.mjs +24 -2
- package/link/pipe.mjs +6 -2
- package/link/providers/nostr.mjs +155 -22
- package/link/providers/webrtc.mjs +1 -1
- package/link/rtc/polyfill.mjs +1 -1
- package/node/instance.mjs +7 -3
- package/node/signaling_config.mjs +52 -29
- package/package.json +5 -2
- package/transport/link_registry.mjs +7 -0
- package/transport/runtime_bootstrap.mjs +120 -103
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
|
|
package/discovery/nostr.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
21
|
+
## Runtime channels
|
|
22
22
|
|
|
23
|
-
`setSignalingRuntimeConfig({
|
|
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 })`).
|
package/docs/transports.md
CHANGED
|
@@ -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
|
|
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.
|
|
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++
|
package/link/providers/nostr.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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)
|
package/link/rtc/polyfill.mjs
CHANGED
|
@@ -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
|
|
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({
|
|
88
|
-
|
|
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
|
-
*
|
|
8
|
-
* iceLocalHostnamePolicy: IceLocalHostnamePolicy
|
|
9
|
-
* trickleIceOff: boolean
|
|
7
|
+
* channels: ChannelsConfig
|
|
10
8
|
* }} SignalingRuntimeConfig
|
|
11
9
|
*/
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Record<string, object | boolean>} ChannelsConfig
|
|
13
|
+
*/
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
* @returns {
|
|
16
|
+
* webrtc 通道默认配置。
|
|
17
|
+
* @returns {{ iceLocalHostnamePolicy: IceLocalHostnamePolicy, trickleIceOff: boolean }} webrtc 通道默认配置
|
|
18
18
|
*/
|
|
19
|
-
|
|
19
|
+
function defaultWebRtcConfig() {
|
|
20
20
|
const iceLocalHostnamePolicy = process.platform === 'win32' ? 'drop' : 'none'
|
|
21
|
-
return {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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.
|
|
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 {
|
|
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 {
|
|
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
|
-
* @
|
|
104
|
+
* @param {string} name 通道名
|
|
105
|
+
* @returns {boolean} 该通道是否启用(默认启用,false 禁用)
|
|
108
106
|
*/
|
|
109
|
-
function
|
|
110
|
-
return getSignalingRuntimeConfig().
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
340
|
+
reconcileLinkProviders()
|
|
357
341
|
if (autoRegisterDiscoveryProviders)
|
|
358
|
-
|
|
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.
|
|
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,
|