@steve02081504/fount-p2p 0.0.10 → 0.0.11

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.
@@ -1,106 +1,22 @@
1
1
  import { Buffer } from 'node:buffer'
2
- import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
3
2
 
4
3
  import { compareHex64Asc, normalizeHex64 } from '../core/hexIds.mjs'
5
- import { normalizeTcpPort } from '../core/tcp_port.mjs'
6
- import { sha256Hex, keyPairFromSeed } from '../crypto/crypto.mjs'
4
+ import { keyPairFromSeed } from '../crypto/crypto.mjs'
7
5
  import { noteAdvertPeerHints } from '../discovery/advert_peer_hints.mjs'
8
- import { advertiseTopic, listenSignals, listDiscoveryProviders, registerDiscoveryProvider, sendSignal, subscribeTopic } from '../discovery/index.mjs'
9
- import { createMdnsDiscoveryProvider } from '../discovery/mdns.mjs'
10
- import { mergeSignalingRelayUrls, createNostrDiscoveryProvider } from '../discovery/nostr.mjs'
11
- import { buildSignedAdvert, verifySignedAdvert } from '../link/handshake.mjs'
12
- import { createBleGattLinkProvider } from '../link/providers/ble_gatt.mjs'
13
- import {
14
- listAvailableLinkProviders,
15
- listLinkProviders,
16
- registerLinkProvider,
17
- unregisterLinkProvider,
18
- } from '../link/providers/index.mjs'
19
- import { createLanTcpLinkProvider } from '../link/providers/lan_tcp.mjs'
20
- import { createWebRtcLinkProvider } from '../link/providers/webrtc.mjs'
21
- import { ensureNodeSeed, getNodeHash, getNodeTransportSettings } from '../node/identity.mjs'
22
- import { getSignalingRuntimeConfig } from '../node/instance.mjs'
6
+ import { subscribeTopic } from '../discovery/index.mjs'
7
+ import { verifySignedAdvert } from '../link/handshake.mjs'
8
+ import { listLinkProviders } from '../link/providers/index.mjs'
9
+ import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
23
10
  import { createOverlayRouter } from '../overlay/index.mjs'
24
11
  import { createLruMap } from '../utils/lru.mjs'
25
12
 
26
13
  import { DEFAULT_ICE_SERVERS } from './ice_servers.mjs'
27
-
28
- const SIGNAL_DOMAIN = 'fount-signal'
29
- const NODE_TOPIC_DOMAIN = 'fount-rdv-node:'
30
- const GROUP_TOPIC_DOMAIN = 'fount-rdv-group:'
31
-
32
- /**
33
- * 由 nodeHash 派生节点 rendezvous topic。
34
- * @param {string} nodeHash 节点 64 hex
35
- * @returns {string} rendezvous topic 哈希
36
- */
37
- export function nodeRendezvousTopic(nodeHash) {
38
- return sha256Hex(`${NODE_TOPIC_DOMAIN}${normalizeHex64(nodeHash)}`)
39
- }
40
-
41
- /**
42
- * 由房间密钥派生群组 rendezvous topic。
43
- * @param {string} roomSecret 房间密钥
44
- * @returns {string} rendezvous topic 哈希
45
- */
46
- export function groupRendezvousTopic(roomSecret) {
47
- return sha256Hex(`${GROUP_TOPIC_DOMAIN}${String(roomSecret || '')}`)
48
- }
49
-
50
- /**
51
- * 由 topic 派生信令 AES 密钥。
52
- * @param {string} topic rendezvous 主题
53
- * @returns {Buffer} AES-256 密钥
54
- */
55
- function signalKeyForTopic(topic) {
56
- return createHash('sha256').update(`${SIGNAL_DOMAIN}:${String(topic)}`).digest()
57
- }
58
-
59
- /**
60
- * 加密信令包为 AES-GCM 字节序列。
61
- * @param {string} topic rendezvous 主题
62
- * @param {unknown} packet 待加密 JSON 对象
63
- * @returns {Uint8Array} 加密后的字节
64
- */
65
- export function encryptSignalPacket(topic, packet) {
66
- const iv = randomBytes(12)
67
- const cipher = createCipheriv('aes-256-gcm', signalKeyForTopic(topic), iv)
68
- const ciphertext = Buffer.concat([
69
- cipher.update(Buffer.from(JSON.stringify(packet), 'utf8')),
70
- cipher.final(),
71
- ])
72
- return Buffer.from(JSON.stringify({
73
- iv: iv.toString('base64'),
74
- authTag: cipher.getAuthTag().toString('base64'),
75
- ciphertext: ciphertext.toString('base64'),
76
- }))
77
- }
78
-
79
- /**
80
- * 解密信令包;失败时返回 null。
81
- * @param {string} topic rendezvous 主题
82
- * @param {Uint8Array} bytes 加密字节
83
- * @returns {object | null} 解密后的 JSON 对象
84
- */
85
- export function decryptSignalPacket(topic, bytes) {
86
- try {
87
- const payload = JSON.parse(Buffer.from(bytes).toString('utf8'))
88
- const decipher = createDecipheriv(
89
- 'aes-256-gcm',
90
- signalKeyForTopic(topic),
91
- Buffer.from(payload.iv, 'base64'),
92
- )
93
- decipher.setAuthTag(Buffer.from(payload.authTag, 'base64'))
94
- const plain = Buffer.concat([
95
- decipher.update(Buffer.from(payload.ciphertext, 'base64')),
96
- decipher.final(),
97
- ])
98
- return JSON.parse(plain.toString('utf8'))
99
- }
100
- catch {
101
- return null
102
- }
103
- }
14
+ import { createOfferAnswerDial } from './offer_answer.mjs'
15
+ import { createRuntimeBootstrap } from './runtime_bootstrap.mjs'
16
+ import {
17
+ decryptSignalPacket,
18
+ nodeRendezvousTopic,
19
+ } from './signal_crypto.mjs'
104
20
 
105
21
  /**
106
22
  * 解析或从节点种子推导本地身份。
@@ -123,60 +39,6 @@ function resolveLocalIdentity(localIdentity) {
123
39
  }
124
40
  }
125
41
 
126
- /**
127
- * 创建带 backlog 的缓冲信令会话。
128
- * @param {(message: unknown) => Promise<void>} sendRemote 远端发送回调
129
- * @returns {{ send: (message: unknown) => Promise<void>, onRemote: (handler: (message: unknown) => void) => () => void, deliver: (message: unknown) => void, clear: () => void }} 信令会话
130
- */
131
- function createBufferedSignalSession(sendRemote) {
132
- /** @type {Set<(message: unknown) => void>} */
133
- const handlers = new Set()
134
- /** @type {unknown[]} */
135
- const backlog = []
136
- return {
137
- /**
138
- * 发送信令消息到远端。
139
- * @param {unknown} message 信令消息
140
- * @returns {Promise<void>}
141
- */
142
- async send(message) {
143
- await sendRemote(message)
144
- },
145
- /**
146
- * 注册远端信令 handler(含 backlog 回放)。
147
- * @param {(message: unknown) => void} handler 入站回调
148
- * @returns {() => void} 取消订阅函数
149
- */
150
- onRemote(handler) {
151
- handlers.add(handler)
152
- for (const pending of backlog.splice(0))
153
- handler(pending)
154
- return () => handlers.delete(handler)
155
- },
156
- /**
157
- * 投递信令消息;无 handler 时入 backlog。
158
- * @param {unknown} message 信令消息
159
- * @returns {void}
160
- */
161
- deliver(message) {
162
- if (!handlers.size) {
163
- backlog.push(message)
164
- return
165
- }
166
- for (const handler of handlers)
167
- handler(message)
168
- },
169
- /**
170
- * 清空 backlog 与 handler。
171
- * @returns {void}
172
- */
173
- clear() {
174
- backlog.length = 0
175
- handlers.clear()
176
- },
177
- }
178
- }
179
-
180
42
  /**
181
43
  * 向 Map<key, Set<listener>> 订阅并返回取消函数。
182
44
  * @param {Map<string, Set<Function>>} buckets 监听器桶
@@ -209,14 +71,12 @@ export function createLinkRegistry(options = {}) {
209
71
  const localIdentity = resolveLocalIdentity(options.localIdentity)
210
72
  const iceServers = options.iceServers?.length ? options.iceServers : DEFAULT_ICE_SERVERS
211
73
  const maxActive = Math.max(4, Number(options.maxActive) || 32)
212
- const autoRegisterDiscoveryProviders = options.autoRegisterDiscoveryProviders !== false
213
- const autoRegisterLinkProviders = options.autoRegisterLinkProviders !== false
214
74
  const selfTopic = nodeRendezvousTopic(localIdentity.nodeHash)
215
75
  /** @type {Map<string, object>} */
216
76
  const links = new Map()
217
- /** @type {Map<string, Promise<object | null>>} 按 nodeHash 去重的主动外拨 */
77
+ /** @type {Map<string, Promise<object | null>>} */
218
78
  const inflights = new Map()
219
- /** @type {Map<string, ReturnType<typeof createBufferedSignalSession>>} 按 connId 索引的信令会话(每条 PC 一个方向) */
79
+ /** @type {Map<string, ReturnType<typeof import('./offer_answer.mjs').createBufferedSignalSession>>} */
220
80
  const signalSessions = new Map()
221
81
  /** @type {Map<string, Set<string>>} */
222
82
  const scopeInterests = new Map()
@@ -229,176 +89,19 @@ export function createLinkRegistry(options = {}) {
229
89
  /** @type {Set<(nodeHash: string, reason: string) => void>} */
230
90
  const linkDownListeners = new Set()
231
91
  const recentAdverts = createLruMap(1024)
232
- let runtimeStarted = false
233
- let stopAdvert = null
234
- let stopSignalListener = null
235
- /** @type {Array<() => void>} */
236
- const stopLinkListeners = []
237
92
  let overlayRouter = null
238
- /** @type {ReturnType<typeof createLanTcpLinkProvider> | null} 本 registry 持有的 lan_tcp 实例 */
239
- let ownedLanTcp = null
240
- /** @type {ReturnType<typeof createBleGattLinkProvider> | null} 本 registry 持有的 ble_gatt 实例 */
241
- let ownedBleGatt = null
242
-
243
- /**
244
- * 自动注册默认 LinkProvider(lan_tcp / webrtc / ble_gatt)。
245
- * lan_tcp / ble_gatt 每 registry 一个实例(避免 ensureListening 覆盖他人的 localIdentity);
246
- * webrtc 按 id 单例注册。拨号时再经 isAvailable / canReach 跳过。
247
- * @returns {Promise<void>}
248
- */
249
- async function ensureLinkProviders() {
250
- if (!autoRegisterLinkProviders) return
251
- if (!ownedLanTcp) {
252
- ownedLanTcp = createLanTcpLinkProvider()
253
- registerLinkProvider(ownedLanTcp)
254
- }
255
- if (!ownedBleGatt) {
256
- ownedBleGatt = createBleGattLinkProvider()
257
- registerLinkProvider(ownedBleGatt)
258
- }
259
- const ids = new Set(listLinkProviders().map(provider => provider.id))
260
- if (!ids.has('webrtc'))
261
- registerLinkProvider(createWebRtcLinkProvider())
262
- }
263
93
 
264
- /**
265
- * registry LAN TCP 监听端口。
266
- * @returns {number | null} listen 端口;未 listen null
267
- */
268
- function resolveLanTcpPort() {
269
- const endpoint = typeof ownedLanTcp?.localEndpoint === 'function' ? ownedLanTcp.localEndpoint() : null
270
- return normalizeTcpPort(endpoint?.port)
271
- }
272
-
273
- /**
274
- * 构造带本机身份与(若已 listen)tcpPort 的签名 advert。
275
- * node / group / scoped 广播共用,保证 LAN hint 可从任意 topic 学到。
276
- * @param {string} topic 广播主题
277
- * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number }>} 签名 advert
278
- */
279
- async function buildLocalAdvert(topic) {
280
- const tcpPort = resolveLanTcpPort()
281
- return await buildSignedAdvert(topic, Date.now(), {
282
- ...localIdentity,
283
- ...tcpPort != null ? { tcpPort } : {},
284
- })
285
- }
286
-
287
- /**
288
- * 启动 discovery + link provider listening。
289
- * 先 ensureListening(拿到 lan_tcp 端口),再广播带 tcpPort 的 advert。
290
- * @returns {Promise<void>}
291
- */
292
- async function ensureDiscoveryRuntime() {
293
- if (runtimeStarted) return
294
- runtimeStarted = true
295
- await ensureLinkProviders()
296
- if (autoRegisterDiscoveryProviders) {
297
- const providerIds = new Set(listDiscoveryProviders().map(provider => provider.id))
298
- if (!providerIds.has('mdns'))
299
- registerDiscoveryProvider(createMdnsDiscoveryProvider())
300
- if (!providerIds.has('bt')) {
301
- const bt = await import('../discovery/bt/index.mjs').catch(() => null)
302
- if (await bt?.canUseBluetoothDiscovery?.())
303
- registerDiscoveryProvider(bt.createBluetoothDiscoveryProvider())
304
- }
305
- if (!providerIds.has('nostr'))
306
- // 测试通过 initNode({ signaling: { relayOverride } }) 注入共享 loopback relay;生产则回落到用户 relay + 默认公网 relay。
307
- // 新 discovery 栈也必须尊重这层 runtime override,否则 live 双节点测试会各打各的公网 relay。
308
- registerDiscoveryProvider(createNostrDiscoveryProvider({
309
- relayUrls: getSignalingRuntimeConfig().relayOverride
310
- ?? mergeSignalingRelayUrls(getNodeTransportSettings().relayUrls),
311
- }))
312
- }
313
- // 只对本 registry 持有的 lan_tcp / ble_gatt 调用 ensureListening。
314
- // 切勿遍历其它 registry 的 lan_tcp:* / ble_gatt:* ——会覆盖其 localIdentity/onInbound。
315
- /** @type {import('../link/providers/index.mjs').LinkProvider[]} */
316
- const listenProviders = []
317
- if (ownedLanTcp) listenProviders.push(ownedLanTcp)
318
- // 无适配器时 isAvailable 应在 import native 前返回 false;勿无条件 ensureListening(会 loadBleno)。
319
- if (ownedBleGatt && await Promise.resolve(ownedBleGatt.isAvailable()))
320
- listenProviders.push(ownedBleGatt)
321
- for (const provider of await listAvailableLinkProviders()) {
322
- const id = String(provider.id)
323
- if (id.startsWith('lan_tcp') || id.startsWith('ble_gatt')) continue
324
- if (typeof provider.ensureListening === 'function')
325
- listenProviders.push(provider)
326
- }
327
- for (const provider of listenProviders)
328
- try {
329
- const stop = await provider.ensureListening({
330
- localIdentity,
331
- /**
332
- * @param {object} link 入站链路
333
- * @returns {void}
334
- */
335
- onInbound(link) {
336
- void (async () => {
337
- try {
338
- await link.ready
339
- const remote = normalizeHex64(link.nodeHash)
340
- if (!remote) {
341
- await link.close('inbound-no-nodehash')
342
- return
343
- }
344
- await registerResolvedLink(remote, link)
345
- }
346
- catch { /* ignore failed inbound */ }
347
- })()
348
- },
349
- })
350
- if (typeof stop === 'function') stopLinkListeners.push(stop)
351
- }
352
- catch { /* provider listen unavailable — normal degrade */ }
353
-
354
- if (listDiscoveryProviders().length) {
355
- stopSignalListener = await listenSignals(selfTopic, bytes => {
356
- void handleIncomingSignal(bytes).catch(() => { })
357
- })
358
- stopAdvert = await advertiseTopic(selfTopic, encryptSignalPacket(selfTopic, {
359
- type: 'advert',
360
- body: await buildLocalAdvert(selfTopic),
361
- }))
362
- }
363
- }
364
-
365
- /**
366
- * 为单条 PC 创建按 connId 标记的信令会话。出站信令都带上 connId,
367
- * 使同一对节点的两个方向(各自一条 PC)在信令层互不串扰。
368
- * @param {string} remoteNodeHash 远端节点 64 hex
369
- * @param {string} connId 连接标识
370
- * @returns {ReturnType<typeof createBufferedSignalSession>} 信令会话
371
- */
372
- function createConnSession(remoteNodeHash, connId) {
373
- const normalized = normalizeHex64(remoteNodeHash)
374
- const topic = nodeRendezvousTopic(normalized)
375
- return createBufferedSignalSession(async message => {
376
- await sendSignal(topic, normalized, encryptSignalPacket(topic, {
377
- type: 'signal',
378
- from: localIdentity.nodeHash,
379
- connId,
380
- body: message,
381
- }))
382
- })
383
- }
384
-
385
- /**
386
- * 判断信令 body 是否为发起方 offer(据此决定入站是否新建应答 PC)。
387
- * @param {unknown} body 信令 body
388
- * @returns {boolean} 是 offer 则 true
389
- */
390
- function isOfferSignalBody(body) {
391
- return !!body && typeof body === 'object'
392
- && body.type === 'description'
393
- && body.description?.type === 'offer'
394
- }
94
+ /** @type {(bytes: Uint8Array) => Promise<void>} */
95
+ let handleIncomingSignal = async () => { }
96
+ /** @type {(provider: import('../link/providers/index.mjs').LinkProvider, remoteNodeHash: string) => Promise<object | null>} */
97
+ let dialOfferAnswer = async () => null
395
98
 
396
99
  /**
397
100
  * 择链:更高 level 优先;同 level 时保留由较小 nodeHash 发起的那条(glare 两端一致)。
398
- * @param {object} link 链路实例
101
+ * @param {object} link 候选链路
399
102
  * @param {string} remoteNodeHash 远端节点 64 hex
400
- * @param {object | null} [against] 现有规范链
401
- * @returns {boolean} 本条链应保留则 true
103
+ * @param {object | null} [against] 当前规范链
104
+ * @returns {boolean} 候选链应保留为规范链时 true
402
105
  */
403
106
  function linkIsPreferred(link, remoteNodeHash, against = null) {
404
107
  const level = Number(link.level) || 0
@@ -419,7 +122,6 @@ export function createLinkRegistry(options = {}) {
419
122
  void dispatchEnvelope(senderNodeHash, envelope, link).catch(() => { })
420
123
  })
421
124
  link.onDown(reason => {
422
- // 只有关闭的是当前规范链路才对外报 linkDown;双 PC 并存期关掉的败者不应误发 peer-leave。
423
125
  const wasCanonical = links.get(remoteNodeHash) === link
424
126
  if (!wasCanonical) return
425
127
  links.delete(remoteNodeHash)
@@ -450,67 +152,33 @@ export function createLinkRegistry(options = {}) {
450
152
  }
451
153
 
452
154
  /**
453
- * 找已注册的 offer/answer provider(按 level 降序的第一个)。
454
- * @returns {import('../link/providers/index.mjs').LinkProvider | null} 命中的 provider
455
- */
456
- function findOfferAnswerProvider() {
457
- return listLinkProviders().find(provider => provider.caps?.needsOfferAnswer) ?? null
458
- }
459
-
460
- /**
461
- * Offer/answer provider:为一条 connId 会话建链并走 glare 择一。
462
- * @param {{ provider: import('../link/providers/index.mjs').LinkProvider, remoteNodeHash: string, connId: string, session: ReturnType<typeof createBufferedSignalSession>, initiator: boolean }} options 建链参数
463
- * @returns {Promise<object | null>} 当前规范链;失败 null
155
+ * 入站链路共用 onInbound(lan_tcp / 后台 ble 等)。
156
+ * @param {object} link 入站链路
157
+ * @returns {void}
464
158
  */
465
- async function buildConnLink({ provider, remoteNodeHash, connId, session, initiator }) {
466
- try {
467
- if (initiator) await trimToBudget()
468
- const link = await (initiator ? provider.dial : provider.accept)({
469
- nodeHash: remoteNodeHash,
470
- signal: session,
471
- iceServers,
472
- localIdentity,
473
- })
474
- if (!link) {
475
- signalSessions.delete(connId)
476
- return null
159
+ function onInboundLink(link) {
160
+ void (async () => {
161
+ try {
162
+ await link.ready
163
+ const remote = normalizeHex64(link.nodeHash)
164
+ if (!remote) {
165
+ await link.close('inbound-no-nodehash')
166
+ return
167
+ }
168
+ await registerResolvedLink(remote, link)
477
169
  }
478
- link.onDown(() => signalSessions.delete(connId))
479
- await link.ready
480
- await registerResolvedLink(remoteNodeHash, link)
481
- return links.get(remoteNodeHash) ?? null
482
- }
483
- catch {
484
- signalSessions.delete(connId)
485
- return null
486
- }
170
+ catch { /* ignore failed inbound */ }
171
+ })()
487
172
  }
488
173
 
489
- /**
490
- * 处理入站加密信令并可能发起被动建链。
491
- * @param {Uint8Array} bytes 加密信令字节
492
- * @returns {Promise<void>}
493
- */
494
- async function handleIncomingSignal(bytes) {
495
- const packet = decryptSignalPacket(selfTopic, bytes)
496
- if (!packet || packet.type !== 'signal') return
497
- const remoteNodeHash = normalizeHex64(packet.from)
498
- const connId = String(packet.connId || '')
499
- if (!remoteNodeHash || remoteNodeHash === localIdentity.nodeHash || !connId) return
500
- // 仅 needsOfferAnswer 走双 PC glare;其它 provider 不吃 SDP 信令。
501
- let session = signalSessions.get(connId)
502
- if (!session) {
503
- // 只有全新的 offer 才开一条独立应答 PC;answer/ice 若无对应 connId 会话则是迟到/无效帧,丢弃。
504
- // 应答 PC 按 connId 独立创建,不受按 nodeHash 去重的 inflight 阻挡——这是支持双向同时建链的关键。
505
- if (!isOfferSignalBody(packet.body)) return
506
- const provider = findOfferAnswerProvider()
507
- if (!provider) return
508
- session = createConnSession(remoteNodeHash, connId)
509
- signalSessions.set(connId, session)
510
- void buildConnLink({ provider, remoteNodeHash, connId, session, initiator: false })
511
- }
512
- session.deliver(packet.body)
513
- }
174
+ const bootstrap = createRuntimeBootstrap({
175
+ localIdentity,
176
+ selfTopic,
177
+ autoRegisterDiscoveryProviders: options.autoRegisterDiscoveryProviders !== false,
178
+ autoRegisterLinkProviders: options.autoRegisterLinkProviders !== false,
179
+ onInboundLink,
180
+ handleIncomingSignal: bytes => handleIncomingSignal(bytes),
181
+ })
514
182
 
515
183
  /**
516
184
  * 计算节点在 scope 兴趣中的权重(用于 eviction)。
@@ -536,18 +204,20 @@ export function createLinkRegistry(options = {}) {
536
204
  if (victim) await victim[1].close('budget-evict')
537
205
  }
538
206
 
539
- /**
540
- * 经 offer/answer provider 拨号(discovery signal + glare)。
541
- * @param {import('../link/providers/index.mjs').LinkProvider} provider 链路提供者
542
- * @param {string} remoteNodeHash 远端 64 hex
543
- * @returns {Promise<object | null>} 当前规范链;失败 null
544
- */
545
- async function dialOfferAnswer(provider, remoteNodeHash) {
546
- const connId = randomBytes(16).toString('hex')
547
- const session = createConnSession(remoteNodeHash, connId)
548
- signalSessions.set(connId, session)
549
- return await buildConnLink({ provider, remoteNodeHash, connId, session, initiator: true })
550
- }
207
+ ;({ handleIncomingSignal, dialOfferAnswer } = createOfferAnswerDial({
208
+ localIdentity,
209
+ iceServers,
210
+ selfTopic,
211
+ signalSessions,
212
+ registerResolvedLink,
213
+ trimToBudget,
214
+ /**
215
+ * 读取当前规范链。
216
+ * @param {string} remoteNodeHash 远端节点 64 hex
217
+ * @returns {object | null | undefined} 规范链实例
218
+ */
219
+ getCanonicalLink: remoteNodeHash => links.get(normalizeHex64(remoteNodeHash)),
220
+ }))
551
221
 
552
222
  /**
553
223
  * 经非 offer/answer provider 拨号。
@@ -574,21 +244,25 @@ export function createLinkRegistry(options = {}) {
574
244
  * @returns {Promise<object | null>} 链路实例;失败时 null
575
245
  */
576
246
  async function ensureDirectLinkToNode(remoteNodeHash) {
577
- await ensureDiscoveryRuntime()
247
+ await bootstrap.ensureRuntime()
248
+ await bootstrap.whenSignalListening()
578
249
  const normalized = normalizeHex64(remoteNodeHash)
579
250
  if (!normalized || normalized === localIdentity.nodeHash) return null
580
251
  if (links.has(normalized)) return links.get(normalized)
581
252
  if (inflights.has(normalized)) return await inflights.get(normalized)
582
253
  const task = (async () => {
583
- const providers = await listAvailableLinkProviders()
584
- for (const provider of providers)
254
+ const providers = listLinkProviders()
255
+ for (const provider of providers)
585
256
  try {
586
257
  if (typeof provider.canReach === 'function') {
587
258
  const reachable = await Promise.resolve(provider.canReach({ nodeHash: normalized }))
588
259
  if (!reachable) continue
589
260
  }
261
+ if (typeof provider.isAvailable === 'function') {
262
+ const available = await Promise.resolve(provider.isAvailable())
263
+ if (!available) continue
264
+ }
590
265
  if (provider.caps?.needsOfferAnswer) {
591
- // soft-fail 返回 null(不 throw);必须 continue 才能落到更低 level。
592
266
  const link = await dialOfferAnswer(provider, normalized)
593
267
  if (link) return link
594
268
  continue
@@ -597,7 +271,7 @@ export function createLinkRegistry(options = {}) {
597
271
  if (link) return link
598
272
  }
599
273
  catch { /* dial failed — try next provider */ }
600
-
274
+
601
275
  return null
602
276
  })().finally(() => inflights.delete(normalized))
603
277
  inflights.set(normalized, task)
@@ -632,10 +306,7 @@ export function createLinkRegistry(options = {}) {
632
306
  overlayRouter = createOverlayRouter({
633
307
  localIdentity,
634
308
  sendToNodeLink: sendDirectToNodeLink,
635
- /**
636
- * 列出当前所有活跃链路。
637
- * @returns {Array<{ nodeHash: string, link: object }>} 链路列表
638
- */
309
+ /** @returns {Array<{ nodeHash: string, link: object }>} 当前活跃链路列表 */
639
310
  listLinks() {
640
311
  return [...links.entries()].map(([nodeHash, link]) => ({ nodeHash, link }))
641
312
  },
@@ -665,15 +336,6 @@ export function createLinkRegistry(options = {}) {
665
336
  }
666
337
  }
667
338
 
668
- /**
669
- * 确保到远端节点的链路(直连或被动应答)。
670
- * @param {string} remoteNodeHash 远端节点 64 hex
671
- * @returns {Promise<object | null>} 链路实例
672
- */
673
- async function ensureLinkToNode(remoteNodeHash) {
674
- return await ensureDirectLinkToNode(remoteNodeHash)
675
- }
676
-
677
339
  /**
678
340
  * 发送 envelope:优先直连,失败则 overlay relay。
679
341
  * @param {string} remoteNodeHash 远端节点 64 hex
@@ -708,7 +370,7 @@ export function createLinkRegistry(options = {}) {
708
370
  /**
709
371
  * 订阅指定 scope 前缀的 envelope。
710
372
  * @param {string} prefix scope 前缀
711
- * @param {(senderNodeHash: string, envelope: { scope: string, action: string, payload: unknown }, link: object) => void | Promise<void>} listener 监听器
373
+ * @param {Function} listener 监听器
712
374
  * @returns {() => void} 取消订阅函数
713
375
  */
714
376
  function subscribeScope(prefix, listener) {
@@ -718,7 +380,7 @@ export function createLinkRegistry(options = {}) {
718
380
  /**
719
381
  * 注册 scope 前缀的 authorizer(入站校验)。
720
382
  * @param {string} prefix scope 前缀
721
- * @param {(scope: string, senderNodeHash: string, envelope: object, link: object) => boolean | Promise<boolean>} authorizer 校验函数
383
+ * @param {Function} authorizer 校验函数
722
384
  * @returns {() => void} 取消注册函数
723
385
  */
724
386
  function registerScopeAuthorizer(prefix, authorizer) {
@@ -728,10 +390,11 @@ export function createLinkRegistry(options = {}) {
728
390
 
729
391
  return {
730
392
  localIdentity,
731
- buildLocalAdvert,
732
- lanTcpPort: resolveLanTcpPort,
733
- ensureRuntime: ensureDiscoveryRuntime,
734
- ensureLinkToNode,
393
+ buildLocalAdvert: bootstrap.buildLocalAdvert,
394
+ lanTcpPort: bootstrap.lanTcpPort,
395
+ whenListening: bootstrap.whenListening,
396
+ ensureRuntime: bootstrap.ensureRuntime,
397
+ ensureLinkToNode: ensureDirectLinkToNode,
735
398
  /**
736
399
  * 获取到指定节点的活跃链路。
737
400
  * @param {string} nodeHash 节点 64 hex
@@ -822,10 +485,7 @@ export function createLinkRegistry(options = {}) {
822
485
  * @returns {Promise<void>}
823
486
  */
824
487
  async shutdown() {
825
- stopAdvert?.()
826
- stopSignalListener?.()
827
- for (const stop of stopLinkListeners.splice(0))
828
- try { stop() } catch { /* ignore */ }
488
+ await bootstrap.shutdown()
829
489
  overlayRouter?.close()
830
490
  overlayRouter = null
831
491
  for (const link of links.values())
@@ -834,15 +494,6 @@ export function createLinkRegistry(options = {}) {
834
494
  inflights.clear()
835
495
  for (const session of signalSessions.values()) session.clear()
836
496
  signalSessions.clear()
837
- if (ownedLanTcp) {
838
- unregisterLinkProvider(ownedLanTcp.id)
839
- ownedLanTcp = null
840
- }
841
- if (ownedBleGatt) {
842
- unregisterLinkProvider(ownedBleGatt.id)
843
- ownedBleGatt = null
844
- }
845
- runtimeStarted = false
846
497
  },
847
498
  }
848
499
  }
@@ -854,8 +505,7 @@ let defaultRegistry = null
854
505
  * @returns {ReturnType<typeof createLinkRegistry>} 默认 registry
855
506
  */
856
507
  export function getLinkRegistry() {
857
- if (!defaultRegistry) defaultRegistry = createLinkRegistry()
858
- return defaultRegistry
508
+ return defaultRegistry ||= createLinkRegistry()
859
509
  }
860
510
 
861
511
  /**