@steve02081504/fount-p2p 0.0.10 → 0.0.12
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 +25 -7
- package/core/bytes_codec.mjs +65 -10
- package/core/tcp_port.mjs +1 -1
- package/crypto/checkpoint_sign.mjs +2 -2
- package/dag/canonicalize_row.mjs +3 -3
- package/dag/index.mjs +2 -3
- package/discovery/bt/index.mjs +5 -5
- package/discovery/bt/probe_child.mjs +20 -0
- package/discovery/bt/runtime.mjs +59 -15
- package/discovery/index.mjs +45 -48
- package/discovery/mdns.mjs +72 -17
- package/discovery/nostr.mjs +165 -129
- package/federation/chunk_fetch_pending.mjs +4 -5
- package/federation/dag_order_cache.mjs +1 -1
- package/federation/entity_key_chain.mjs +3 -4
- package/federation/topo_order_memo.mjs +11 -4
- package/files/chunk_fetch.mjs +2 -2
- package/files/evfs.mjs +2 -2
- package/link/channel_mux.mjs +10 -10
- package/link/frame.mjs +76 -124
- package/link/handshake.mjs +5 -5
- package/link/pipe.mjs +49 -75
- package/link/providers/ble_gatt.mjs +15 -27
- package/link/providers/index.mjs +7 -9
- package/link/providers/lan_tcp.mjs +18 -33
- package/link/providers/link_id_pipe.mjs +46 -0
- package/link/providers/webrtc.mjs +43 -52
- package/link/rtc.mjs +21 -9
- package/mailbox/deliver_or_store.mjs +1 -1
- package/node/identity.mjs +4 -4
- package/node/network.mjs +9 -9
- package/overlay/index.mjs +13 -13
- package/package.json +3 -2
- package/rooms/scoped_link.mjs +22 -38
- package/schemas/discovery.mjs +1 -2
- package/schemas/federation_pull.mjs +2 -2
- package/schemas/part_query.mjs +1 -1
- package/schemas/remote_event.mjs +1 -1
- package/transport/advert_ingest.mjs +20 -0
- package/transport/group_link_set.mjs +26 -45
- package/transport/ice_servers.mjs +12 -3
- package/transport/link_registry.mjs +181 -523
- package/transport/offer_answer.mjs +194 -0
- package/transport/peer_pool.mjs +53 -64
- package/transport/remote_user_room.mjs +13 -15
- package/transport/rtc_connection_budget.mjs +18 -4
- package/transport/runtime_bootstrap.mjs +369 -0
- package/transport/signal_crypto.mjs +104 -0
- package/transport/user_room.mjs +22 -15
- package/trust_graph/send.mjs +1 -1
- package/utils/emit_safe.mjs +11 -0
- package/utils/shuffle.mjs +13 -0
- package/utils/ttl_map.mjs +29 -4
- package/wire/ingress.mjs +1 -1
- package/wire/part_ingress.mjs +6 -8
- package/wire/part_invoke.mjs +4 -4
- package/wire/part_query.mjs +2 -3
- package/wire/part_query.tunables.json +6 -1
- package/wire/part_query_cache.mjs +1 -1
- package/wire/volatile_signature.mjs +1 -1
|
@@ -0,0 +1,369 @@
|
|
|
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
|
+
/**
|
|
204
|
+
*
|
|
205
|
+
*/
|
|
206
|
+
stopSignalListener = () => {
|
|
207
|
+
try { stop() } catch { /* ignore */ }
|
|
208
|
+
prev?.()
|
|
209
|
+
}
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
const prev = stopAdvert
|
|
213
|
+
/**
|
|
214
|
+
*
|
|
215
|
+
*/
|
|
216
|
+
stopAdvert = () => {
|
|
217
|
+
try { stop() } catch { /* ignore */ }
|
|
218
|
+
prev?.()
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 后台探测 BT discovery / ble_gatt listen。
|
|
224
|
+
* @param {number} gen 启动世代
|
|
225
|
+
* @returns {Promise<void>}
|
|
226
|
+
*/
|
|
227
|
+
async function warmBluetoothTask(gen) {
|
|
228
|
+
if (autoRegisterDiscoveryProviders) {
|
|
229
|
+
const providerIds = new Set(listDiscoveryProviders().map(provider => provider.id))
|
|
230
|
+
if (!providerIds.has('bt')) {
|
|
231
|
+
const bt = await import('../discovery/bt/index.mjs').catch(() => null)
|
|
232
|
+
if (generation !== gen || !isLive()) return
|
|
233
|
+
if (await bt?.canUseBluetoothDiscovery?.()) {
|
|
234
|
+
if (generation !== gen || !isLive()) return
|
|
235
|
+
const provider = bt.createBluetoothDiscoveryProvider()
|
|
236
|
+
registerDiscoveryProvider(provider)
|
|
237
|
+
if (generation !== gen || !isLive()) return
|
|
238
|
+
if (provider.caps?.canSignal && typeof provider.onSignal === 'function')
|
|
239
|
+
try {
|
|
240
|
+
const stop = await provider.onSignal(selfTopic, bytes => {
|
|
241
|
+
void handleIncomingSignal(bytes).catch(() => { })
|
|
242
|
+
})
|
|
243
|
+
if (typeof stop === 'function' && generation === gen && isLive())
|
|
244
|
+
chainStop('signal', stop)
|
|
245
|
+
}
|
|
246
|
+
catch { /* ignore */ }
|
|
247
|
+
if (provider.caps?.canDiscover && typeof provider.advertise === 'function')
|
|
248
|
+
try {
|
|
249
|
+
const stop = await provider.advertise(selfTopic, encryptSignalPacket(selfTopic, {
|
|
250
|
+
type: 'advert',
|
|
251
|
+
body: await buildLocalAdvert(selfTopic),
|
|
252
|
+
}))
|
|
253
|
+
if (typeof stop === 'function' && generation === gen && isLive())
|
|
254
|
+
chainStop('advert', stop)
|
|
255
|
+
}
|
|
256
|
+
catch { /* ignore */ }
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (generation !== gen || !isLive()) return
|
|
261
|
+
if (ownedBleGatt && await Promise.resolve(ownedBleGatt.isAvailable())) {
|
|
262
|
+
if (generation !== gen || !isLive()) return
|
|
263
|
+
await startProviderListening(ownedBleGatt)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 后台:lan_tcp listen → selfTopic listenSignals/advertise。
|
|
269
|
+
* @param {number} gen 启动世代(shutdown 时递增以作废后台任务)
|
|
270
|
+
* @returns {Promise<void>}
|
|
271
|
+
*/
|
|
272
|
+
function warmListenAndDiscovery(gen) {
|
|
273
|
+
const listenProviders = collectFastListenProviders(ownedLanTcp)
|
|
274
|
+
// 同步挂上 Promise,避免 ensureRuntime 返回后 buildLocalAdvert 看不到 lanListenReady。
|
|
275
|
+
lanListenReady = Promise.all(listenProviders.map(provider => startProviderListening(provider))).then(() => { })
|
|
276
|
+
signalListenReady = (async () => {
|
|
277
|
+
await lanListenReady.catch(() => { })
|
|
278
|
+
if (generation !== gen || !isLive()) return
|
|
279
|
+
if (!listDiscoveryProviders().length) return
|
|
280
|
+
stopSignalListener = await listenSignals(selfTopic, bytes => {
|
|
281
|
+
void handleIncomingSignal(bytes).catch(() => { })
|
|
282
|
+
})
|
|
283
|
+
})()
|
|
284
|
+
return (async () => {
|
|
285
|
+
await signalListenReady.catch(() => { })
|
|
286
|
+
if (generation !== gen || !isLive()) return
|
|
287
|
+
if (!listDiscoveryProviders().length) return
|
|
288
|
+
stopAdvert = await advertiseTopic(selfTopic, encryptSignalPacket(selfTopic, {
|
|
289
|
+
type: 'advert',
|
|
290
|
+
body: await buildLocalAdvert(selfTopic),
|
|
291
|
+
}))
|
|
292
|
+
})()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 仅注册 provider 并调度后台暖机后立即返回。
|
|
297
|
+
* @returns {Promise<void>}
|
|
298
|
+
*/
|
|
299
|
+
async function ensureRuntime() {
|
|
300
|
+
if (runtimeStarted) return
|
|
301
|
+
if (runtimeStart) return await runtimeStart
|
|
302
|
+
runtimeStart = (async () => {
|
|
303
|
+
runtimeStarted = true
|
|
304
|
+
const gen = generation
|
|
305
|
+
await ensureLinkProviders()
|
|
306
|
+
if (autoRegisterDiscoveryProviders) {
|
|
307
|
+
const providerIds = new Set(listDiscoveryProviders().map(provider => provider.id))
|
|
308
|
+
if (!providerIds.has('mdns'))
|
|
309
|
+
registerDiscoveryProvider(createMdnsDiscoveryProvider())
|
|
310
|
+
if (!providerIds.has('nostr'))
|
|
311
|
+
registerDiscoveryProvider(createNostrDiscoveryProvider({
|
|
312
|
+
relayUrls: getSignalingRuntimeConfig().relayOverride
|
|
313
|
+
?? mergeSignalingRelayUrls(getNodeTransportSettings().relayUrls),
|
|
314
|
+
}))
|
|
315
|
+
}
|
|
316
|
+
bluetoothWarm = warmBluetoothTask(gen).catch(() => { })
|
|
317
|
+
runtimeWarm = warmListenAndDiscovery(gen)
|
|
318
|
+
void runtimeWarm.catch(() => { })
|
|
319
|
+
})()
|
|
320
|
+
try {
|
|
321
|
+
await runtimeStart
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
runtimeStart = null
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 停止暖机与 owned providers(链路表由 registry 自行清理)。
|
|
330
|
+
* 不 await bluetoothWarm:noble/poweredOn 可能挂死;generation 已作废其后副作用。
|
|
331
|
+
* @returns {Promise<void>}
|
|
332
|
+
*/
|
|
333
|
+
async function shutdown() {
|
|
334
|
+
runtimeStarted = false
|
|
335
|
+
generation++
|
|
336
|
+
await runtimeWarm?.catch(() => { })
|
|
337
|
+
stopAdvert?.()
|
|
338
|
+
stopSignalListener?.()
|
|
339
|
+
stopAdvert = null
|
|
340
|
+
stopSignalListener = null
|
|
341
|
+
for (const stop of stopLinkListeners.splice(0))
|
|
342
|
+
try { stop() } catch { /* ignore */ }
|
|
343
|
+
if (ownedLanTcp) {
|
|
344
|
+
unregisterLinkProvider(ownedLanTcp.id)
|
|
345
|
+
ownedLanTcp = null
|
|
346
|
+
}
|
|
347
|
+
if (ownedBleGatt) {
|
|
348
|
+
unregisterLinkProvider(ownedBleGatt.id)
|
|
349
|
+
ownedBleGatt = null
|
|
350
|
+
}
|
|
351
|
+
lanListenReady = null
|
|
352
|
+
signalListenReady = null
|
|
353
|
+
runtimeWarm = null
|
|
354
|
+
bluetoothWarm = null
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
ensureRuntime,
|
|
359
|
+
whenListening,
|
|
360
|
+
whenSignalListening,
|
|
361
|
+
buildLocalAdvert,
|
|
362
|
+
lanTcpPort,
|
|
363
|
+
/** @returns {ReturnType<typeof createLanTcpLinkProvider> | null} 本 registry 持有的 lan_tcp */
|
|
364
|
+
ownedLanTcp: () => ownedLanTcp,
|
|
365
|
+
/** @returns {ReturnType<typeof createBleGattLinkProvider> | null} 本 registry 持有的 ble_gatt */
|
|
366
|
+
ownedBleGatt: () => ownedBleGatt,
|
|
367
|
+
shutdown,
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
import { createLruMap } from '../utils/lru.mjs'
|
|
7
|
+
|
|
8
|
+
const SIGNAL_DOMAIN = 'fount-signal'
|
|
9
|
+
const NODE_TOPIC_DOMAIN = 'fount-rdv-node:'
|
|
10
|
+
const GROUP_TOPIC_DOMAIN = 'fount-rdv-group:'
|
|
11
|
+
|
|
12
|
+
/** topic → AES-256 密钥缓存上限(群/节点 topic 会随时间增长,须有界) */
|
|
13
|
+
export const SIGNAL_KEY_CACHE_MAX = 512
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 由 nodeHash 派生节点 rendezvous topic。
|
|
17
|
+
* @param {string} nodeHash 节点 64 hex
|
|
18
|
+
* @returns {string} rendezvous topic 哈希
|
|
19
|
+
*/
|
|
20
|
+
export function nodeRendezvousTopic(nodeHash) {
|
|
21
|
+
return sha256Hex(`${NODE_TOPIC_DOMAIN}${normalizeHex64(nodeHash)}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 由房间密钥派生群组 rendezvous topic。
|
|
26
|
+
* @param {string} roomSecret 房间密钥
|
|
27
|
+
* @returns {string} rendezvous topic 哈希
|
|
28
|
+
*/
|
|
29
|
+
export function groupRendezvousTopic(roomSecret) {
|
|
30
|
+
return sha256Hex(`${GROUP_TOPIC_DOMAIN}${roomSecret}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** topic → AES-256 密钥 LRU */
|
|
34
|
+
const signalKeysByTopic = createLruMap(SIGNAL_KEY_CACHE_MAX)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 由 topic 派生信令 AES 密钥(同 topic 复用,避免每包 SHA-256)。
|
|
38
|
+
* @param {string} topic rendezvous 主题
|
|
39
|
+
* @returns {Buffer} AES-256 密钥
|
|
40
|
+
*/
|
|
41
|
+
function signalKeyForTopic(topic) {
|
|
42
|
+
let cached = signalKeysByTopic.get(topic)
|
|
43
|
+
if (cached) {
|
|
44
|
+
signalKeysByTopic.touch(topic, cached)
|
|
45
|
+
return cached
|
|
46
|
+
}
|
|
47
|
+
cached = createHash('sha256').update(`${SIGNAL_DOMAIN}:${topic}`).digest()
|
|
48
|
+
signalKeysByTopic.touch(topic, cached)
|
|
49
|
+
return cached
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 当前密钥缓存条目数(测试用)。
|
|
54
|
+
* @returns {number} 缓存大小
|
|
55
|
+
*/
|
|
56
|
+
export function signalKeyCacheSize() {
|
|
57
|
+
return signalKeysByTopic.size
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 加密信令包为 AES-GCM 字节序列。
|
|
62
|
+
* @param {string} topic rendezvous 主题
|
|
63
|
+
* @param {unknown} packet 待加密 JSON 对象
|
|
64
|
+
* @returns {Uint8Array} 加密后的字节
|
|
65
|
+
*/
|
|
66
|
+
export function encryptSignalPacket(topic, packet) {
|
|
67
|
+
const iv = randomBytes(12)
|
|
68
|
+
const cipher = createCipheriv('aes-256-gcm', signalKeyForTopic(topic), iv)
|
|
69
|
+
const ciphertext = Buffer.concat([
|
|
70
|
+
cipher.update(Buffer.from(JSON.stringify(packet), 'utf8')),
|
|
71
|
+
cipher.final(),
|
|
72
|
+
])
|
|
73
|
+
return Buffer.from(JSON.stringify({
|
|
74
|
+
iv: iv.toString('base64'),
|
|
75
|
+
authTag: cipher.getAuthTag().toString('base64'),
|
|
76
|
+
ciphertext: ciphertext.toString('base64'),
|
|
77
|
+
}))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 解密信令包;失败时返回 null。
|
|
82
|
+
* @param {string} topic rendezvous 主题
|
|
83
|
+
* @param {Uint8Array} bytes 加密字节
|
|
84
|
+
* @returns {object | null} 解密后的 JSON 对象
|
|
85
|
+
*/
|
|
86
|
+
export function decryptSignalPacket(topic, bytes) {
|
|
87
|
+
try {
|
|
88
|
+
const payload = JSON.parse(Buffer.from(bytes).toString('utf8'))
|
|
89
|
+
const decipher = createDecipheriv(
|
|
90
|
+
'aes-256-gcm',
|
|
91
|
+
signalKeyForTopic(topic),
|
|
92
|
+
Buffer.from(payload.iv, 'base64'),
|
|
93
|
+
)
|
|
94
|
+
decipher.setAuthTag(Buffer.from(payload.authTag, 'base64'))
|
|
95
|
+
const plain = Buffer.concat([
|
|
96
|
+
decipher.update(Buffer.from(payload.ciphertext, 'base64')),
|
|
97
|
+
decipher.final(),
|
|
98
|
+
])
|
|
99
|
+
return JSON.parse(plain.toString('utf8'))
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
}
|
package/transport/user_room.mjs
CHANGED
|
@@ -3,12 +3,24 @@ import { createHash } from 'node:crypto'
|
|
|
3
3
|
import { attachMailboxWire } from '../mailbox/wire.mjs'
|
|
4
4
|
import { ensureNodeDefaults, getNodeHash } from '../node/identity.mjs'
|
|
5
5
|
import { registerFederationRoomProvider } from '../registries/room_provider.mjs'
|
|
6
|
+
import { shuffleInPlace } from '../utils/shuffle.mjs'
|
|
6
7
|
import { attachPartWire } from '../wire/part_ingress.mjs'
|
|
7
8
|
import { attachPartQueryWire } from '../wire/part_query.mjs'
|
|
8
9
|
|
|
9
10
|
import { listLinks, sendToNodeLink, subscribeScope, getLinkRegistry } from './link_registry.mjs'
|
|
10
11
|
import { USER_ROOM_SCOPE } from './room_scopes.mjs'
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* 经 node scope 向对端发 action。
|
|
15
|
+
* @param {string} peerId 对端 nodeHash
|
|
16
|
+
* @param {string} action 动作名
|
|
17
|
+
* @param {unknown} payload 载荷
|
|
18
|
+
* @returns {Promise<boolean>} 是否发出
|
|
19
|
+
*/
|
|
20
|
+
const sendNodeAction = (peerId, action, payload) =>
|
|
21
|
+
sendToNodeLink(peerId, { scope: 'node', action, payload })
|
|
22
|
+
|
|
23
|
+
|
|
12
24
|
/**
|
|
13
25
|
* 在 node scope action 表上注册 fed_chunk_get / fed_chunk_data handler,
|
|
14
26
|
* 供用户房间(本地 + 远端)双向 chunk 传输使用。
|
|
@@ -63,9 +75,8 @@ function createNodeScopeWire() {
|
|
|
63
75
|
* @returns {void}
|
|
64
76
|
*/
|
|
65
77
|
on(name, handler) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
nodeActionHandlers.get(key).add(handler)
|
|
78
|
+
if (!nodeActionHandlers.has(name)) nodeActionHandlers.set(name, new Set())
|
|
79
|
+
nodeActionHandlers.get(name).add(handler)
|
|
69
80
|
},
|
|
70
81
|
/**
|
|
71
82
|
* 向指定 peer 发送 node scope action。
|
|
@@ -76,7 +87,7 @@ function createNodeScopeWire() {
|
|
|
76
87
|
*/
|
|
77
88
|
send(name, payload, peerId) {
|
|
78
89
|
if (!peerId) return
|
|
79
|
-
void
|
|
90
|
+
void sendNodeAction(peerId, name, payload).catch(() => { })
|
|
80
91
|
},
|
|
81
92
|
}
|
|
82
93
|
}
|
|
@@ -96,9 +107,9 @@ function activeLinkRoster() {
|
|
|
96
107
|
*/
|
|
97
108
|
async function ensureNodeScopeRuntime(wireContext) {
|
|
98
109
|
if (nodeScopeCleanup) return
|
|
99
|
-
nodeScopeReplicaUsername =
|
|
110
|
+
nodeScopeReplicaUsername = wireContext.replicaUsername || nodeScopeReplicaUsername || ''
|
|
100
111
|
nodeScopeCleanup = subscribeScope('node', (senderNodeHash, envelope) => {
|
|
101
|
-
const handlers = nodeActionHandlers.get(
|
|
112
|
+
const handlers = nodeActionHandlers.get(envelope.action)
|
|
102
113
|
if (!handlers?.size) return
|
|
103
114
|
for (const handler of handlers)
|
|
104
115
|
try { handler(envelope.payload, senderNodeHash) } catch { /* ignore */ }
|
|
@@ -188,7 +199,7 @@ export async function ensureUserRoom(wireContext = {}) {
|
|
|
188
199
|
* @returns {void}
|
|
189
200
|
*/
|
|
190
201
|
sendToPeer(peerId, actionName, payload) {
|
|
191
|
-
void
|
|
202
|
+
void sendNodeAction(peerId, actionName, payload).catch(() => { })
|
|
192
203
|
},
|
|
193
204
|
/**
|
|
194
205
|
* 返回当前活跃链路 roster。
|
|
@@ -201,7 +212,7 @@ export async function ensureUserRoom(wireContext = {}) {
|
|
|
201
212
|
* @returns {string | null} 对端 id;无链路时为 null
|
|
202
213
|
*/
|
|
203
214
|
getPeerIdByNodeHash(nodeHash) {
|
|
204
|
-
return getLinkRegistry().getLink(nodeHash) ?
|
|
215
|
+
return getLinkRegistry().getLink(nodeHash) ? nodeHash : null
|
|
205
216
|
},
|
|
206
217
|
}
|
|
207
218
|
return userRoomSlot
|
|
@@ -234,15 +245,11 @@ export async function deliverToUserRoomPeers(username, actionName, payload, exce
|
|
|
234
245
|
if (!slot) return 0
|
|
235
246
|
const body = { ...payload, nodeHash: getNodeHash() }
|
|
236
247
|
let sent = 0
|
|
237
|
-
const peers =
|
|
238
|
-
.filter(({ peerId }) => peerId && peerId !== exceptPeerId)
|
|
239
|
-
for (let swapIndex = peers.length - 1; swapIndex > 0; swapIndex--) {
|
|
240
|
-
const pickIndex = Math.floor(Math.random() * (swapIndex + 1))
|
|
241
|
-
;[peers[swapIndex], peers[pickIndex]] = [peers[pickIndex], peers[swapIndex]]
|
|
242
|
-
}
|
|
248
|
+
const peers = shuffleInPlace(slot.getRoster()
|
|
249
|
+
.filter(({ peerId }) => peerId && peerId !== exceptPeerId))
|
|
243
250
|
for (const { peerId } of peers)
|
|
244
251
|
try {
|
|
245
|
-
if (await
|
|
252
|
+
if (await sendNodeAction(peerId, actionName, body))
|
|
246
253
|
sent++
|
|
247
254
|
if (sent >= fanoutLimit) break
|
|
248
255
|
}
|
package/trust_graph/send.mjs
CHANGED
|
@@ -26,7 +26,7 @@ export async function sendToNode(username, targetNodeHash, actionName, payload,
|
|
|
26
26
|
await ensureUserRoom({ replicaUsername: username })
|
|
27
27
|
|
|
28
28
|
// 已直连 peer 不经 trust-graph scope 也应能收发 node scope action(非成员 CAS chunk / follow hint 等)
|
|
29
|
-
if (await sendToNodeLink(target, { scope: 'node', action:
|
|
29
|
+
if (await sendToNodeLink(target, { scope: 'node', action: actionName, payload }))
|
|
30
30
|
return true
|
|
31
31
|
|
|
32
32
|
const merged = graph ?? await buildMergedGraph(username)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 调用一组 listener,单次抛错不影响其余。
|
|
3
|
+
* @param {Iterable<Function>} listeners 回调集合
|
|
4
|
+
* @param {...unknown} args 传给每个 listener 的参数
|
|
5
|
+
* @returns {void}
|
|
6
|
+
*/
|
|
7
|
+
export function emitSafe(listeners, ...args) {
|
|
8
|
+
for (const listener of listeners)
|
|
9
|
+
try { listener(...args) }
|
|
10
|
+
catch { /* ignore */ }
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fisher–Yates 原地洗牌。
|
|
3
|
+
* @template T
|
|
4
|
+
* @param {T[]} arr 待洗牌数组(原地修改)
|
|
5
|
+
* @returns {T[]} 同一数组引用
|
|
6
|
+
*/
|
|
7
|
+
export function shuffleInPlace(arr) {
|
|
8
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
9
|
+
const j = Math.floor(Math.random() * (i + 1))
|
|
10
|
+
;[arr[i], arr[j]] = [arr[j], arr[i]]
|
|
11
|
+
}
|
|
12
|
+
return arr
|
|
13
|
+
}
|
package/utils/ttl_map.mjs
CHANGED
|
@@ -1,21 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 带 TTL 的 Map:get
|
|
2
|
+
* 带 TTL 的 Map:get 时惰性过期;set 时在超上限下先清过期再 LRU 驱逐。
|
|
3
3
|
* @template T
|
|
4
4
|
* @param {number} ttlMs 存活毫秒
|
|
5
|
-
* @
|
|
5
|
+
* @param {number} [maxSize=4096] 最大条目(防只写不读时过期项堆积)
|
|
6
|
+
* @returns {{ ttlMs: number, maxSize: number, size: () => number, set: (key: string, value: T) => void, get: (key: string, now?: number) => T | null, clear: () => void }} TTL Map 句柄
|
|
6
7
|
*/
|
|
7
|
-
export function createTtlMap(ttlMs) {
|
|
8
|
+
export function createTtlMap(ttlMs, maxSize = 4096) {
|
|
9
|
+
const cap = Math.max(1, Math.floor(Number(maxSize) || 4096))
|
|
8
10
|
/** @type {Map<string, { value: T, seenAt: number }>} */
|
|
9
11
|
const map = new Map()
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 删除过期项;若仍满则按插入序驱逐最旧。
|
|
15
|
+
* @param {number} now 当前时间
|
|
16
|
+
* @returns {void}
|
|
17
|
+
*/
|
|
18
|
+
function prune(now) {
|
|
19
|
+
for (const [key, entry] of map)
|
|
20
|
+
if (now - entry.seenAt > ttlMs) map.delete(key)
|
|
21
|
+
while (map.size >= cap) {
|
|
22
|
+
const oldest = map.keys().next().value
|
|
23
|
+
map.delete(oldest)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
10
27
|
return {
|
|
11
28
|
ttlMs,
|
|
29
|
+
maxSize: cap,
|
|
30
|
+
/**
|
|
31
|
+
* @returns {number} 当前条目数(含未 get 的过期项)
|
|
32
|
+
*/
|
|
33
|
+
size: () => map.size,
|
|
12
34
|
/**
|
|
13
35
|
* @param {string} key 键
|
|
14
36
|
* @param {T} value 值
|
|
15
37
|
* @returns {void}
|
|
16
38
|
*/
|
|
17
39
|
set(key, value) {
|
|
18
|
-
|
|
40
|
+
const now = Date.now()
|
|
41
|
+
if (map.has(key)) map.delete(key)
|
|
42
|
+
if (map.size >= cap) prune(now)
|
|
43
|
+
map.set(key, { value, seenAt: now })
|
|
19
44
|
},
|
|
20
45
|
/**
|
|
21
46
|
* @param {string} key 键
|
package/wire/ingress.mjs
CHANGED
|
@@ -19,7 +19,7 @@ export function isPlainObject(value) {
|
|
|
19
19
|
* @returns {Record<string, unknown> | null} 解析失败或非对象时为 null
|
|
20
20
|
*/
|
|
21
21
|
export function parseInboundJson(raw) {
|
|
22
|
-
if (raw
|
|
22
|
+
if (!raw) return null
|
|
23
23
|
const text = typeof raw === 'string'
|
|
24
24
|
? raw
|
|
25
25
|
: Buffer.isBuffer(raw)
|