@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.
Files changed (60) hide show
  1. package/README.md +25 -7
  2. package/core/bytes_codec.mjs +65 -10
  3. package/core/tcp_port.mjs +1 -1
  4. package/crypto/checkpoint_sign.mjs +2 -2
  5. package/dag/canonicalize_row.mjs +3 -3
  6. package/dag/index.mjs +2 -3
  7. package/discovery/bt/index.mjs +5 -5
  8. package/discovery/bt/probe_child.mjs +20 -0
  9. package/discovery/bt/runtime.mjs +59 -15
  10. package/discovery/index.mjs +45 -48
  11. package/discovery/mdns.mjs +72 -17
  12. package/discovery/nostr.mjs +165 -129
  13. package/federation/chunk_fetch_pending.mjs +4 -5
  14. package/federation/dag_order_cache.mjs +1 -1
  15. package/federation/entity_key_chain.mjs +3 -4
  16. package/federation/topo_order_memo.mjs +11 -4
  17. package/files/chunk_fetch.mjs +2 -2
  18. package/files/evfs.mjs +2 -2
  19. package/link/channel_mux.mjs +10 -10
  20. package/link/frame.mjs +76 -124
  21. package/link/handshake.mjs +5 -5
  22. package/link/pipe.mjs +49 -75
  23. package/link/providers/ble_gatt.mjs +15 -27
  24. package/link/providers/index.mjs +7 -9
  25. package/link/providers/lan_tcp.mjs +18 -33
  26. package/link/providers/link_id_pipe.mjs +46 -0
  27. package/link/providers/webrtc.mjs +43 -52
  28. package/link/rtc.mjs +21 -9
  29. package/mailbox/deliver_or_store.mjs +1 -1
  30. package/node/identity.mjs +4 -4
  31. package/node/network.mjs +9 -9
  32. package/overlay/index.mjs +13 -13
  33. package/package.json +3 -2
  34. package/rooms/scoped_link.mjs +22 -38
  35. package/schemas/discovery.mjs +1 -2
  36. package/schemas/federation_pull.mjs +2 -2
  37. package/schemas/part_query.mjs +1 -1
  38. package/schemas/remote_event.mjs +1 -1
  39. package/transport/advert_ingest.mjs +20 -0
  40. package/transport/group_link_set.mjs +26 -45
  41. package/transport/ice_servers.mjs +12 -3
  42. package/transport/link_registry.mjs +181 -523
  43. package/transport/offer_answer.mjs +194 -0
  44. package/transport/peer_pool.mjs +53 -64
  45. package/transport/remote_user_room.mjs +13 -15
  46. package/transport/rtc_connection_budget.mjs +18 -4
  47. package/transport/runtime_bootstrap.mjs +369 -0
  48. package/transport/signal_crypto.mjs +104 -0
  49. package/transport/user_room.mjs +22 -15
  50. package/trust_graph/send.mjs +1 -1
  51. package/utils/emit_safe.mjs +11 -0
  52. package/utils/shuffle.mjs +13 -0
  53. package/utils/ttl_map.mjs +29 -4
  54. package/wire/ingress.mjs +1 -1
  55. package/wire/part_ingress.mjs +6 -8
  56. package/wire/part_invoke.mjs +4 -4
  57. package/wire/part_query.mjs +2 -3
  58. package/wire/part_query.tunables.json +6 -1
  59. package/wire/part_query_cache.mjs +1 -1
  60. 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
+ }
@@ -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
- const key = String(name)
67
- if (!nodeActionHandlers.has(key)) nodeActionHandlers.set(key, new Set())
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 sendToNodeLink(peerId, { scope: 'node', action: String(name), payload }).catch(() => { })
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 = String(wireContext.replicaUsername || nodeScopeReplicaUsername || '')
110
+ nodeScopeReplicaUsername = wireContext.replicaUsername || nodeScopeReplicaUsername || ''
100
111
  nodeScopeCleanup = subscribeScope('node', (senderNodeHash, envelope) => {
101
- const handlers = nodeActionHandlers.get(String(envelope?.action || ''))
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 sendToNodeLink(peerId, { scope: 'node', action: String(actionName), payload }).catch(() => { })
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) ? String(nodeHash) : null
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 = [...slot.getRoster()
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 sendToNodeLink(peerId, { scope: 'node', action: String(actionName), payload: body }))
252
+ if (await sendNodeAction(peerId, actionName, body))
246
253
  sent++
247
254
  if (sent >= fanoutLimit) break
248
255
  }
@@ -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: String(actionName), payload }))
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
- * @returns {{ ttlMs: number, set: (key: string, value: T) => void, get: (key: string, now?: number) => T | null, clear: () => void }} TTL Map 句柄
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
- map.set(key, { value, seenAt: Date.now() })
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 == null) return null
22
+ if (!raw) return null
23
23
  const text = typeof raw === 'string'
24
24
  ? raw
25
25
  : Buffer.isBuffer(raw)