@steve02081504/fount-p2p 0.0.9 → 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.
@@ -0,0 +1,190 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ import { normalizeHex64 } from '../core/hexIds.mjs'
4
+ import { sendSignal } from '../discovery/index.mjs'
5
+ import { listLinkProviders } from '../link/providers/index.mjs'
6
+
7
+ import { decryptSignalPacket, encryptSignalPacket, nodeRendezvousTopic } from './signal_crypto.mjs'
8
+
9
+ /**
10
+ * 创建带 backlog 的缓冲信令会话。
11
+ * @param {(message: unknown) => Promise<void>} sendRemote 远端发送回调
12
+ * @returns {{ send: (message: unknown) => Promise<void>, onRemote: (handler: (message: unknown) => void) => () => void, deliver: (message: unknown) => void, clear: () => void }} 信令会话
13
+ */
14
+ export function createBufferedSignalSession(sendRemote) {
15
+ /** @type {Set<(message: unknown) => void>} */
16
+ const handlers = new Set()
17
+ /** @type {unknown[]} */
18
+ const backlog = []
19
+ return {
20
+ /**
21
+ * 发送信令消息到远端。
22
+ * @param {unknown} message 信令消息
23
+ * @returns {Promise<void>}
24
+ */
25
+ async send(message) {
26
+ await sendRemote(message)
27
+ },
28
+ /**
29
+ * 注册远端信令 handler(含 backlog 回放)。
30
+ * @param {(message: unknown) => void} handler 入站回调
31
+ * @returns {() => void} 取消订阅函数
32
+ */
33
+ onRemote(handler) {
34
+ handlers.add(handler)
35
+ for (const pending of backlog.splice(0))
36
+ handler(pending)
37
+ return () => handlers.delete(handler)
38
+ },
39
+ /**
40
+ * 投递信令消息;无 handler 时入 backlog。
41
+ * @param {unknown} message 信令消息
42
+ * @returns {void}
43
+ */
44
+ deliver(message) {
45
+ if (!handlers.size) {
46
+ backlog.push(message)
47
+ return
48
+ }
49
+ for (const handler of handlers)
50
+ handler(message)
51
+ },
52
+ /**
53
+ * 清空 backlog 与 handler。
54
+ * @returns {void}
55
+ */
56
+ clear() {
57
+ backlog.length = 0
58
+ handlers.clear()
59
+ },
60
+ }
61
+ }
62
+
63
+ /**
64
+ * 判断信令 body 是否为发起方 offer(据此决定入站是否新建应答 PC)。
65
+ * @param {unknown} body 信令 body
66
+ * @returns {boolean} 是 offer 则 true
67
+ */
68
+ function isOfferSignalBody(body) {
69
+ return !!body && typeof body === 'object'
70
+ && body.type === 'description'
71
+ && body.description?.type === 'offer'
72
+ }
73
+
74
+ /**
75
+ * 找已注册的 offer/answer provider(按 level 降序的第一个)。
76
+ * @returns {import('../link/providers/index.mjs').LinkProvider | null} 命中的 provider;无则 null
77
+ */
78
+ export function findOfferAnswerProvider() {
79
+ return listLinkProviders().find(provider => provider.caps?.needsOfferAnswer) ?? null
80
+ }
81
+
82
+ /**
83
+ * Offer/answer glare 拨号控制器(WebRTC 等 needsOfferAnswer provider)。
84
+ * @param {object} deps 依赖注入
85
+ * @param {{ nodeHash: string, nodePubKey: string, secretKey: Uint8Array }} deps.localIdentity 本地身份
86
+ * @param {RTCConfiguration['iceServers']} deps.iceServers ICE 服务器列表
87
+ * @param {string} deps.selfTopic 本机 rendezvous topic
88
+ * @param {Map<string, ReturnType<typeof createBufferedSignalSession>>} deps.signalSessions 按 connId 索引的信令会话
89
+ * @param {(remoteNodeHash: string, link: object) => Promise<void>} deps.registerResolvedLink 注册规范链
90
+ * @param {() => Promise<void>} deps.trimToBudget 超预算驱逐
91
+ * @param {(remoteNodeHash: string) => object | null | undefined} deps.getCanonicalLink 读取当前规范链
92
+ * @returns {{ handleIncomingSignal: (bytes: Uint8Array) => Promise<void>, dialOfferAnswer: (provider: import('../link/providers/index.mjs').LinkProvider, remoteNodeHash: string) => Promise<object | null> }} 入站信令与主动拨号
93
+ */
94
+ export function createOfferAnswerDial(deps) {
95
+ const {
96
+ localIdentity,
97
+ iceServers,
98
+ selfTopic,
99
+ signalSessions,
100
+ registerResolvedLink,
101
+ trimToBudget,
102
+ getCanonicalLink,
103
+ } = deps
104
+
105
+ /**
106
+ * 为单条 PC 创建按 connId 标记的信令会话。
107
+ * @param {string} remoteNodeHash 远端节点 64 hex
108
+ * @param {string} connId 连接标识
109
+ * @returns {ReturnType<typeof createBufferedSignalSession>} 信令会话
110
+ */
111
+ function createConnSession(remoteNodeHash, connId) {
112
+ const normalized = normalizeHex64(remoteNodeHash)
113
+ const topic = nodeRendezvousTopic(normalized)
114
+ return createBufferedSignalSession(async message => {
115
+ await sendSignal(topic, normalized, encryptSignalPacket(topic, {
116
+ type: 'signal',
117
+ from: localIdentity.nodeHash,
118
+ connId,
119
+ body: message,
120
+ }))
121
+ })
122
+ }
123
+
124
+ /**
125
+ * Offer/answer provider:为一条 connId 会话建链并走 glare 择一。
126
+ * @param {{ provider: import('../link/providers/index.mjs').LinkProvider, remoteNodeHash: string, connId: string, session: ReturnType<typeof createBufferedSignalSession>, initiator: boolean }} options 建链参数
127
+ * @returns {Promise<object | null>} 当前规范链;失败 null
128
+ */
129
+ async function buildConnLink({ provider, remoteNodeHash, connId, session, initiator }) {
130
+ try {
131
+ if (initiator) await trimToBudget()
132
+ const link = await (initiator ? provider.dial : provider.accept)({
133
+ nodeHash: remoteNodeHash,
134
+ signal: session,
135
+ iceServers,
136
+ localIdentity,
137
+ })
138
+ if (!link) {
139
+ signalSessions.delete(connId)
140
+ return null
141
+ }
142
+ link.onDown(() => signalSessions.delete(connId))
143
+ await link.ready
144
+ await registerResolvedLink(remoteNodeHash, link)
145
+ return getCanonicalLink(remoteNodeHash) ?? null
146
+ }
147
+ catch {
148
+ signalSessions.delete(connId)
149
+ return null
150
+ }
151
+ }
152
+
153
+ /**
154
+ * 处理入站加密信令并可能发起被动建链。
155
+ * @param {Uint8Array} bytes 加密信令字节
156
+ * @returns {Promise<void>}
157
+ */
158
+ async function handleIncomingSignal(bytes) {
159
+ const packet = decryptSignalPacket(selfTopic, bytes)
160
+ if (!packet || packet.type !== 'signal') return
161
+ const remoteNodeHash = normalizeHex64(packet.from)
162
+ const connId = String(packet.connId || '')
163
+ if (!remoteNodeHash || remoteNodeHash === localIdentity.nodeHash || !connId) return
164
+ let session = signalSessions.get(connId)
165
+ if (!session) {
166
+ if (!isOfferSignalBody(packet.body)) return
167
+ const provider = findOfferAnswerProvider()
168
+ if (!provider) return
169
+ session = createConnSession(remoteNodeHash, connId)
170
+ signalSessions.set(connId, session)
171
+ void buildConnLink({ provider, remoteNodeHash, connId, session, initiator: false })
172
+ }
173
+ session.deliver(packet.body)
174
+ }
175
+
176
+ /**
177
+ * 经 offer/answer provider 拨号(discovery signal + glare)。
178
+ * @param {import('../link/providers/index.mjs').LinkProvider} provider 链路提供者
179
+ * @param {string} remoteNodeHash 远端 64 hex
180
+ * @returns {Promise<object | null>} 当前规范链;失败 null
181
+ */
182
+ async function dialOfferAnswer(provider, remoteNodeHash) {
183
+ const connId = randomBytes(16).toString('hex')
184
+ const session = createConnSession(remoteNodeHash, connId)
185
+ signalSessions.set(connId, session)
186
+ return await buildConnLink({ provider, remoteNodeHash, connId, session, initiator: true })
187
+ }
188
+
189
+ return { handleIncomingSignal, dialOfferAnswer }
190
+ }
@@ -0,0 +1,363 @@
1
+ import { normalizeTcpPort } from '../core/tcp_port.mjs'
2
+ import { advertiseTopic, listenSignals, listDiscoveryProviders, registerDiscoveryProvider } from '../discovery/index.mjs'
3
+ import { createMdnsDiscoveryProvider } from '../discovery/mdns.mjs'
4
+ import { mergeSignalingRelayUrls, createNostrDiscoveryProvider } from '../discovery/nostr.mjs'
5
+ import { buildSignedAdvert } from '../link/handshake.mjs'
6
+ import { createBleGattLinkProvider } from '../link/providers/ble_gatt.mjs'
7
+ import {
8
+ listLinkProviders,
9
+ registerLinkProvider,
10
+ unregisterLinkProvider,
11
+ } from '../link/providers/index.mjs'
12
+ import { createLanTcpLinkProvider } from '../link/providers/lan_tcp.mjs'
13
+ import { createWebRtcLinkProvider } from '../link/providers/webrtc.mjs'
14
+ import { getNodeTransportSettings } from '../node/identity.mjs'
15
+ import { getSignalingRuntimeConfig } from '../node/instance.mjs'
16
+
17
+ import { encryptSignalPacket } from './signal_crypto.mjs'
18
+
19
+ /**
20
+ * Provider 是否会在 isAvailable() 调用时触发 native/异步探测(不得在 ensureRuntime 快路径调用)。
21
+ * @param {import('../link/providers/index.mjs').LinkProvider} provider 链路提供者
22
+ * @returns {boolean} `caps.probe === 'native'` 时 true
23
+ */
24
+ export function providerHasNativeProbe(provider) {
25
+ return provider.caps?.probe === 'native'
26
+ }
27
+
28
+ /**
29
+ * 收集快路径应 listen 的 provider(仅 owned lan_tcp + 同步可用者)。
30
+ * 切勿调用 caps.probe==='native' 的 isAvailable(会触发 ble/webrtc 探测)。
31
+ * @param {import('../link/providers/index.mjs').LinkProvider | null} ownedLanTcp 本 registry 持有的 lan_tcp
32
+ * @returns {import('../link/providers/index.mjs').LinkProvider[]} 可在 ensureRuntime 快路径 listen 的 provider
33
+ */
34
+ export function collectFastListenProviders(ownedLanTcp) {
35
+ /** @type {import('../link/providers/index.mjs').LinkProvider[]} */
36
+ const listenProviders = []
37
+ if (ownedLanTcp) listenProviders.push(ownedLanTcp)
38
+ for (const provider of listLinkProviders()) {
39
+ const id = String(provider.id)
40
+ if (id.startsWith('lan_tcp') || id.startsWith('ble_gatt')) continue
41
+ if (typeof provider.ensureListening !== 'function') continue
42
+ if (providerHasNativeProbe(provider)) continue
43
+ if (typeof provider.isAvailable === 'function') {
44
+ try {
45
+ const available = provider.isAvailable()
46
+ // 未标 probe:native 却返回 thenable:跳过,勿 await。
47
+ if (available && typeof available.then === 'function') continue
48
+ if (!available) continue
49
+ }
50
+ catch { continue }
51
+ }
52
+ listenProviders.push(provider)
53
+ }
54
+ return listenProviders
55
+ }
56
+
57
+ /**
58
+ * Discovery + link listen 暖机(ensureRuntime 立即返回;listen/公网/BT 后台渐进)。
59
+ * @param {object} deps 依赖注入
60
+ * @param {{ nodeHash: string, nodePubKey: string, secretKey: Uint8Array }} deps.localIdentity 本地身份
61
+ * @param {string} deps.selfTopic 本机 rendezvous topic
62
+ * @param {boolean} deps.autoRegisterDiscoveryProviders 是否自动注册 discovery provider
63
+ * @param {boolean} deps.autoRegisterLinkProviders 是否自动注册内置 link provider
64
+ * @param {(link: object) => void} deps.onInboundLink 入站链路回调
65
+ * @param {(bytes: Uint8Array) => Promise<void>} deps.handleIncomingSignal 入站加密信令处理
66
+ * @returns {{
67
+ * ensureRuntime: () => Promise<void>,
68
+ * whenListening: () => Promise<void>,
69
+ * whenSignalListening: () => Promise<void>,
70
+ * buildLocalAdvert: (topic: string) => Promise<object>,
71
+ * lanTcpPort: () => number | null,
72
+ * ownedLanTcp: () => object | null,
73
+ * ownedBleGatt: () => object | null,
74
+ * shutdown: () => Promise<void>,
75
+ * }} 运行时暖机句柄
76
+ */
77
+ export function createRuntimeBootstrap(deps) {
78
+ const {
79
+ localIdentity,
80
+ selfTopic,
81
+ autoRegisterDiscoveryProviders,
82
+ autoRegisterLinkProviders,
83
+ onInboundLink,
84
+ handleIncomingSignal,
85
+ } = deps
86
+
87
+ let runtimeStarted = false
88
+ /** @type {Promise<void> | null} */
89
+ let runtimeStart = null
90
+ /** @type {Promise<void> | null} */
91
+ let lanListenReady = null
92
+ /** @type {Promise<void> | null} selfTopic listenSignals 已挂上(offer/answer dial 前必须等) */
93
+ let signalListenReady = null
94
+ /** @type {Promise<void> | null} */
95
+ let runtimeWarm = null
96
+ /** @type {Promise<void> | null} */
97
+ let bluetoothWarm = null
98
+ /** @type {(() => void) | null} */
99
+ let stopAdvert = null
100
+ /** @type {(() => void) | null} */
101
+ let stopSignalListener = null
102
+ /** @type {Array<() => void>} */
103
+ const stopLinkListeners = []
104
+ /** @type {ReturnType<typeof createLanTcpLinkProvider> | null} */
105
+ let ownedLanTcp = null
106
+ /** @type {ReturnType<typeof createBleGattLinkProvider> | null} */
107
+ let ownedBleGatt = null
108
+ /** @type {number} shutdown 时递增,后台任务检查后退出 */
109
+ let generation = 0
110
+
111
+ /**
112
+ * 暖机是否仍在运行(shutdown 后为 false)。
113
+ * @returns {boolean} runtime 已启动且未 shutdown 时 true
114
+ */
115
+ function isLive() {
116
+ return runtimeStarted
117
+ }
118
+
119
+ /**
120
+ * 自动注册默认 LinkProvider(lan_tcp / webrtc / ble_gatt)。
121
+ * @returns {Promise<void>}
122
+ */
123
+ async function ensureLinkProviders() {
124
+ if (!autoRegisterLinkProviders) return
125
+ if (!ownedLanTcp) {
126
+ ownedLanTcp = createLanTcpLinkProvider()
127
+ registerLinkProvider(ownedLanTcp)
128
+ }
129
+ if (!ownedBleGatt) {
130
+ ownedBleGatt = createBleGattLinkProvider()
131
+ registerLinkProvider(ownedBleGatt)
132
+ }
133
+ const ids = new Set(listLinkProviders().map(provider => provider.id))
134
+ if (!ids.has('webrtc'))
135
+ registerLinkProvider(createWebRtcLinkProvider())
136
+ }
137
+
138
+ /**
139
+ * 本 registry 的 LAN TCP 监听端口。
140
+ * @returns {number | null} listen 端口;未 listen 为 null
141
+ */
142
+ function lanTcpPort() {
143
+ const endpoint = typeof ownedLanTcp?.localEndpoint === 'function' ? ownedLanTcp.localEndpoint() : null
144
+ return normalizeTcpPort(endpoint?.port)
145
+ }
146
+
147
+ /**
148
+ * 等待本地 lan_tcp listen 落定。shell / startNode 不应调用。
149
+ * @returns {Promise<void>}
150
+ */
151
+ async function whenListening() {
152
+ if (lanListenReady) await lanListenReady.catch(() => { })
153
+ }
154
+
155
+ /**
156
+ * 等待 selfTopic 信令监听挂上(needsOfferAnswer dial 前调用)。
157
+ * @returns {Promise<void>}
158
+ */
159
+ async function whenSignalListening() {
160
+ if (signalListenReady) await signalListenReady.catch(() => { })
161
+ }
162
+
163
+ /**
164
+ * 构造带本机身份与(若已 listen)tcpPort 的签名 advert。
165
+ * @param {string} topic 广播主题
166
+ * @returns {Promise<object>} 签名 advert
167
+ */
168
+ async function buildLocalAdvert(topic) {
169
+ await whenListening()
170
+ const tcpPort = lanTcpPort()
171
+ return await buildSignedAdvert(topic, Date.now(), {
172
+ ...localIdentity,
173
+ ...tcpPort != null ? { tcpPort } : {},
174
+ })
175
+ }
176
+
177
+ /**
178
+ * 对单个 link provider 调用 ensureListening,收集 stop。
179
+ * @param {import('../link/providers/index.mjs').LinkProvider} provider 链路提供者
180
+ * @returns {Promise<void>}
181
+ */
182
+ async function startProviderListening(provider) {
183
+ if (typeof provider.ensureListening !== 'function') return
184
+ try {
185
+ const stop = await provider.ensureListening({
186
+ localIdentity,
187
+ onInbound: onInboundLink,
188
+ })
189
+ if (typeof stop === 'function') stopLinkListeners.push(stop)
190
+ }
191
+ catch { /* provider listen unavailable — normal degrade */ }
192
+ }
193
+
194
+ /**
195
+ * 把晚注册 provider 的 stop 链到现有 cleanup。
196
+ * @param {'signal' | 'advert'} kind 停止钩子类型
197
+ * @param {() => void} stop 新 provider 的取消函数
198
+ * @returns {void}
199
+ */
200
+ function chainStop(kind, stop) {
201
+ if (kind === 'signal') {
202
+ const prev = stopSignalListener
203
+ stopSignalListener = () => {
204
+ try { stop() } catch { /* ignore */ }
205
+ prev?.()
206
+ }
207
+ return
208
+ }
209
+ const prev = stopAdvert
210
+ stopAdvert = () => {
211
+ try { stop() } catch { /* ignore */ }
212
+ prev?.()
213
+ }
214
+ }
215
+
216
+ /**
217
+ * 后台探测 BT discovery / ble_gatt listen。
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?.canUseBluetoothDiscovery?.()) {
228
+ if (generation !== gen || !isLive()) return
229
+ const provider = bt.createBluetoothDiscoveryProvider()
230
+ registerDiscoveryProvider(provider)
231
+ if (generation !== gen || !isLive()) return
232
+ if (provider.caps?.canSignal && typeof provider.onSignal === 'function')
233
+ try {
234
+ const stop = await provider.onSignal(selfTopic, bytes => {
235
+ void handleIncomingSignal(bytes).catch(() => { })
236
+ })
237
+ if (typeof stop === 'function' && generation === gen && isLive())
238
+ chainStop('signal', stop)
239
+ }
240
+ catch { /* ignore */ }
241
+ if (provider.caps?.canDiscover && typeof provider.advertise === 'function')
242
+ try {
243
+ const stop = await provider.advertise(selfTopic, encryptSignalPacket(selfTopic, {
244
+ type: 'advert',
245
+ body: await buildLocalAdvert(selfTopic),
246
+ }))
247
+ if (typeof stop === 'function' && generation === gen && isLive())
248
+ chainStop('advert', stop)
249
+ }
250
+ catch { /* ignore */ }
251
+ }
252
+ }
253
+ }
254
+ if (generation !== gen || !isLive()) return
255
+ if (ownedBleGatt && await Promise.resolve(ownedBleGatt.isAvailable())) {
256
+ if (generation !== gen || !isLive()) return
257
+ await startProviderListening(ownedBleGatt)
258
+ }
259
+ }
260
+
261
+ /**
262
+ * 后台:lan_tcp listen → selfTopic listenSignals/advertise。
263
+ * @param {number} gen 启动世代(shutdown 时递增以作废后台任务)
264
+ * @returns {Promise<void>}
265
+ */
266
+ function warmListenAndDiscovery(gen) {
267
+ const listenProviders = collectFastListenProviders(ownedLanTcp)
268
+ // 同步挂上 Promise,避免 ensureRuntime 返回后 buildLocalAdvert 看不到 lanListenReady。
269
+ lanListenReady = Promise.all(listenProviders.map(provider => startProviderListening(provider))).then(() => { })
270
+ signalListenReady = (async () => {
271
+ await lanListenReady.catch(() => { })
272
+ if (generation !== gen || !isLive()) return
273
+ if (!listDiscoveryProviders().length) return
274
+ stopSignalListener = await listenSignals(selfTopic, bytes => {
275
+ void handleIncomingSignal(bytes).catch(() => { })
276
+ })
277
+ })()
278
+ return (async () => {
279
+ await signalListenReady.catch(() => { })
280
+ if (generation !== gen || !isLive()) return
281
+ if (!listDiscoveryProviders().length) return
282
+ stopAdvert = await advertiseTopic(selfTopic, encryptSignalPacket(selfTopic, {
283
+ type: 'advert',
284
+ body: await buildLocalAdvert(selfTopic),
285
+ }))
286
+ })()
287
+ }
288
+
289
+ /**
290
+ * 仅注册 provider 并调度后台暖机后立即返回。
291
+ * @returns {Promise<void>}
292
+ */
293
+ async function ensureRuntime() {
294
+ if (runtimeStarted) return
295
+ if (runtimeStart) return await runtimeStart
296
+ runtimeStart = (async () => {
297
+ runtimeStarted = true
298
+ const gen = generation
299
+ await ensureLinkProviders()
300
+ if (autoRegisterDiscoveryProviders) {
301
+ const providerIds = new Set(listDiscoveryProviders().map(provider => provider.id))
302
+ if (!providerIds.has('mdns'))
303
+ registerDiscoveryProvider(createMdnsDiscoveryProvider())
304
+ if (!providerIds.has('nostr'))
305
+ registerDiscoveryProvider(createNostrDiscoveryProvider({
306
+ relayUrls: getSignalingRuntimeConfig().relayOverride
307
+ ?? mergeSignalingRelayUrls(getNodeTransportSettings().relayUrls),
308
+ }))
309
+ }
310
+ bluetoothWarm = warmBluetoothTask(gen).catch(() => { })
311
+ runtimeWarm = warmListenAndDiscovery(gen)
312
+ void runtimeWarm.catch(() => { })
313
+ })()
314
+ try {
315
+ await runtimeStart
316
+ }
317
+ finally {
318
+ runtimeStart = null
319
+ }
320
+ }
321
+
322
+ /**
323
+ * 停止暖机与 owned providers(链路表由 registry 自行清理)。
324
+ * 不 await bluetoothWarm:noble/poweredOn 可能挂死;generation 已作废其后副作用。
325
+ * @returns {Promise<void>}
326
+ */
327
+ async function shutdown() {
328
+ runtimeStarted = false
329
+ generation++
330
+ await runtimeWarm?.catch(() => { })
331
+ stopAdvert?.()
332
+ stopSignalListener?.()
333
+ stopAdvert = null
334
+ stopSignalListener = null
335
+ for (const stop of stopLinkListeners.splice(0))
336
+ try { stop() } catch { /* ignore */ }
337
+ if (ownedLanTcp) {
338
+ unregisterLinkProvider(ownedLanTcp.id)
339
+ ownedLanTcp = null
340
+ }
341
+ if (ownedBleGatt) {
342
+ unregisterLinkProvider(ownedBleGatt.id)
343
+ ownedBleGatt = null
344
+ }
345
+ lanListenReady = null
346
+ signalListenReady = null
347
+ runtimeWarm = null
348
+ bluetoothWarm = null
349
+ }
350
+
351
+ return {
352
+ ensureRuntime,
353
+ whenListening,
354
+ whenSignalListening,
355
+ buildLocalAdvert,
356
+ lanTcpPort,
357
+ /** @returns {ReturnType<typeof createLanTcpLinkProvider> | null} 本 registry 持有的 lan_tcp */
358
+ ownedLanTcp: () => ownedLanTcp,
359
+ /** @returns {ReturnType<typeof createBleGattLinkProvider> | null} 本 registry 持有的 ble_gatt */
360
+ ownedBleGatt: () => ownedBleGatt,
361
+ shutdown,
362
+ }
363
+ }
@@ -0,0 +1,82 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
3
+
4
+ import { normalizeHex64 } from '../core/hexIds.mjs'
5
+ import { sha256Hex } from '../crypto/crypto.mjs'
6
+
7
+ const SIGNAL_DOMAIN = 'fount-signal'
8
+ const NODE_TOPIC_DOMAIN = 'fount-rdv-node:'
9
+ const GROUP_TOPIC_DOMAIN = 'fount-rdv-group:'
10
+
11
+ /**
12
+ * 由 nodeHash 派生节点 rendezvous topic。
13
+ * @param {string} nodeHash 节点 64 hex
14
+ * @returns {string} rendezvous topic 哈希
15
+ */
16
+ export function nodeRendezvousTopic(nodeHash) {
17
+ return sha256Hex(`${NODE_TOPIC_DOMAIN}${normalizeHex64(nodeHash)}`)
18
+ }
19
+
20
+ /**
21
+ * 由房间密钥派生群组 rendezvous topic。
22
+ * @param {string} roomSecret 房间密钥
23
+ * @returns {string} rendezvous topic 哈希
24
+ */
25
+ export function groupRendezvousTopic(roomSecret) {
26
+ return sha256Hex(`${GROUP_TOPIC_DOMAIN}${String(roomSecret || '')}`)
27
+ }
28
+
29
+ /**
30
+ * 由 topic 派生信令 AES 密钥。
31
+ * @param {string} topic rendezvous 主题
32
+ * @returns {Buffer} AES-256 密钥
33
+ */
34
+ function signalKeyForTopic(topic) {
35
+ return createHash('sha256').update(`${SIGNAL_DOMAIN}:${String(topic)}`).digest()
36
+ }
37
+
38
+ /**
39
+ * 加密信令包为 AES-GCM 字节序列。
40
+ * @param {string} topic rendezvous 主题
41
+ * @param {unknown} packet 待加密 JSON 对象
42
+ * @returns {Uint8Array} 加密后的字节
43
+ */
44
+ export function encryptSignalPacket(topic, packet) {
45
+ const iv = randomBytes(12)
46
+ const cipher = createCipheriv('aes-256-gcm', signalKeyForTopic(topic), iv)
47
+ const ciphertext = Buffer.concat([
48
+ cipher.update(Buffer.from(JSON.stringify(packet), 'utf8')),
49
+ cipher.final(),
50
+ ])
51
+ return Buffer.from(JSON.stringify({
52
+ iv: iv.toString('base64'),
53
+ authTag: cipher.getAuthTag().toString('base64'),
54
+ ciphertext: ciphertext.toString('base64'),
55
+ }))
56
+ }
57
+
58
+ /**
59
+ * 解密信令包;失败时返回 null。
60
+ * @param {string} topic rendezvous 主题
61
+ * @param {Uint8Array} bytes 加密字节
62
+ * @returns {object | null} 解密后的 JSON 对象
63
+ */
64
+ export function decryptSignalPacket(topic, bytes) {
65
+ try {
66
+ const payload = JSON.parse(Buffer.from(bytes).toString('utf8'))
67
+ const decipher = createDecipheriv(
68
+ 'aes-256-gcm',
69
+ signalKeyForTopic(topic),
70
+ Buffer.from(payload.iv, 'base64'),
71
+ )
72
+ decipher.setAuthTag(Buffer.from(payload.authTag, 'base64'))
73
+ const plain = Buffer.concat([
74
+ decipher.update(Buffer.from(payload.ciphertext, 'base64')),
75
+ decipher.final(),
76
+ ])
77
+ return JSON.parse(plain.toString('utf8'))
78
+ }
79
+ catch {
80
+ return null
81
+ }
82
+ }