@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,194 @@
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
+ /** accept/dial 挂起期间 ICE 信令 backlog 上限,防无 handler 时无限堆积 */
10
+ const SIGNAL_BACKLOG_MAX = 64
11
+
12
+ /**
13
+ * 创建带 backlog 的缓冲信令会话。
14
+ * @param {(message: unknown) => Promise<void>} sendRemote 远端发送回调
15
+ * @returns {{ send: (message: unknown) => Promise<void>, onRemote: (handler: (message: unknown) => void) => () => void, deliver: (message: unknown) => void, clear: () => void }} 信令会话
16
+ */
17
+ export function createBufferedSignalSession(sendRemote) {
18
+ /** @type {Set<(message: unknown) => void>} */
19
+ const handlers = new Set()
20
+ /** @type {unknown[]} */
21
+ const backlog = []
22
+ return {
23
+ /**
24
+ * 发送信令消息到远端。
25
+ * @param {unknown} message 信令消息
26
+ * @returns {Promise<void>}
27
+ */
28
+ async send(message) {
29
+ await sendRemote(message)
30
+ },
31
+ /**
32
+ * 注册远端信令 handler(含 backlog 回放)。
33
+ * @param {(message: unknown) => void} handler 入站回调
34
+ * @returns {() => void} 取消订阅函数
35
+ */
36
+ onRemote(handler) {
37
+ handlers.add(handler)
38
+ for (const pending of backlog.splice(0))
39
+ handler(pending)
40
+ return () => handlers.delete(handler)
41
+ },
42
+ /**
43
+ * 投递信令消息;无 handler 时入 backlog(有界)。
44
+ * @param {unknown} message 信令消息
45
+ * @returns {void}
46
+ */
47
+ deliver(message) {
48
+ if (!handlers.size) {
49
+ if (backlog.length >= SIGNAL_BACKLOG_MAX) backlog.shift()
50
+ backlog.push(message)
51
+ return
52
+ }
53
+ for (const handler of handlers)
54
+ handler(message)
55
+ },
56
+ /**
57
+ * 清空 backlog 与 handler。
58
+ * @returns {void}
59
+ */
60
+ clear() {
61
+ backlog.length = 0
62
+ handlers.clear()
63
+ },
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 判断信令 body 是否为发起方 offer(据此决定入站是否新建应答 PC)。
69
+ * @param {unknown} body 信令 body
70
+ * @returns {boolean} 是 offer 则 true
71
+ */
72
+ function isOfferSignalBody(body) {
73
+ return !!body && typeof body === 'object'
74
+ && body.type === 'description'
75
+ && body.description?.type === 'offer'
76
+ }
77
+
78
+ /**
79
+ * 找已注册的 offer/answer provider(按 level 降序的第一个)。
80
+ * @returns {import('../link/providers/index.mjs').LinkProvider | null} 命中的 provider;无则 null
81
+ */
82
+ export function findOfferAnswerProvider() {
83
+ return listLinkProviders().find(provider => provider.caps?.needsOfferAnswer) ?? null
84
+ }
85
+
86
+ /**
87
+ * Offer/answer glare 拨号控制器(WebRTC 等 needsOfferAnswer provider)。
88
+ * @param {object} deps 依赖注入
89
+ * @param {{ nodeHash: string, nodePubKey: string, secretKey: Uint8Array }} deps.localIdentity 本地身份
90
+ * @param {RTCConfiguration['iceServers']} deps.iceServers ICE 服务器列表
91
+ * @param {string} deps.selfTopic 本机 rendezvous topic
92
+ * @param {Map<string, ReturnType<typeof createBufferedSignalSession>>} deps.signalSessions 按 connId 索引的信令会话
93
+ * @param {(remoteNodeHash: string, link: object) => Promise<void>} deps.registerResolvedLink 注册规范链
94
+ * @param {() => Promise<void>} deps.trimToBudget 超预算驱逐
95
+ * @param {(remoteNodeHash: string) => object | null | undefined} deps.getCanonicalLink 读取当前规范链
96
+ * @returns {{ handleIncomingSignal: (bytes: Uint8Array) => Promise<void>, dialOfferAnswer: (provider: import('../link/providers/index.mjs').LinkProvider, remoteNodeHash: string) => Promise<object | null> }} 入站信令与主动拨号
97
+ */
98
+ export function createOfferAnswerDial(deps) {
99
+ const {
100
+ localIdentity,
101
+ iceServers,
102
+ selfTopic,
103
+ signalSessions,
104
+ registerResolvedLink,
105
+ trimToBudget,
106
+ getCanonicalLink,
107
+ } = deps
108
+
109
+ /**
110
+ * 为单条 PC 创建按 connId 标记的信令会话。
111
+ * @param {string} remoteNodeHash 远端节点 64 hex
112
+ * @param {string} connId 连接标识
113
+ * @returns {ReturnType<typeof createBufferedSignalSession>} 信令会话
114
+ */
115
+ function createConnSession(remoteNodeHash, connId) {
116
+ const normalized = normalizeHex64(remoteNodeHash)
117
+ const topic = nodeRendezvousTopic(normalized)
118
+ return createBufferedSignalSession(async message => {
119
+ await sendSignal(topic, normalized, encryptSignalPacket(topic, {
120
+ type: 'signal',
121
+ from: localIdentity.nodeHash,
122
+ connId,
123
+ body: message,
124
+ }))
125
+ })
126
+ }
127
+
128
+ /**
129
+ * Offer/answer provider:为一条 connId 会话建链并走 glare 择一。
130
+ * @param {{ provider: import('../link/providers/index.mjs').LinkProvider, remoteNodeHash: string, connId: string, session: ReturnType<typeof createBufferedSignalSession>, initiator: boolean }} options 建链参数
131
+ * @returns {Promise<object | null>} 当前规范链;失败 null
132
+ */
133
+ async function buildConnLink({ provider, remoteNodeHash, connId, session, initiator }) {
134
+ try {
135
+ if (initiator) await trimToBudget()
136
+ const link = await (initiator ? provider.dial : provider.accept)({
137
+ nodeHash: remoteNodeHash,
138
+ signal: session,
139
+ iceServers,
140
+ localIdentity,
141
+ })
142
+ if (!link) {
143
+ signalSessions.delete(connId)
144
+ return null
145
+ }
146
+ link.onDown(() => signalSessions.delete(connId))
147
+ await link.ready
148
+ await registerResolvedLink(remoteNodeHash, link)
149
+ return getCanonicalLink(remoteNodeHash) ?? null
150
+ }
151
+ catch {
152
+ signalSessions.delete(connId)
153
+ return null
154
+ }
155
+ }
156
+
157
+ /**
158
+ * 处理入站加密信令并可能发起被动建链。
159
+ * @param {Uint8Array} bytes 加密信令字节
160
+ * @returns {Promise<void>}
161
+ */
162
+ async function handleIncomingSignal(bytes) {
163
+ const packet = decryptSignalPacket(selfTopic, bytes)
164
+ if (packet?.type !== 'signal') return
165
+ const remoteNodeHash = normalizeHex64(packet.from)
166
+ const connId = String(packet.connId || '')
167
+ if (!remoteNodeHash || remoteNodeHash === localIdentity.nodeHash || !connId) return
168
+ let session = signalSessions.get(connId)
169
+ if (!session) {
170
+ if (!isOfferSignalBody(packet.body)) return
171
+ const provider = findOfferAnswerProvider()
172
+ if (!provider) return
173
+ session = createConnSession(remoteNodeHash, connId)
174
+ signalSessions.set(connId, session)
175
+ void buildConnLink({ provider, remoteNodeHash, connId, session, initiator: false })
176
+ }
177
+ session.deliver(packet.body)
178
+ }
179
+
180
+ /**
181
+ * 经 offer/answer provider 拨号(discovery signal + glare)。
182
+ * @param {import('../link/providers/index.mjs').LinkProvider} provider 链路提供者
183
+ * @param {string} remoteNodeHash 远端 64 hex
184
+ * @returns {Promise<object | null>} 当前规范链;失败 null
185
+ */
186
+ async function dialOfferAnswer(provider, remoteNodeHash) {
187
+ const connId = randomBytes(16).toString('hex')
188
+ const session = createConnSession(remoteNodeHash, connId)
189
+ signalSessions.set(connId, session)
190
+ return await buildConnLink({ provider, remoteNodeHash, connId, session, initiator: true })
191
+ }
192
+
193
+ return { handleIncomingSignal, dialOfferAnswer }
194
+ }
@@ -8,6 +8,7 @@ import { loadPeerPoolView, mergeNetworkPeerPools } from '../node/network.mjs'
8
8
  import { loadReputation } from '../node/reputation_store.mjs'
9
9
  import { isQuarantinedPure } from '../reputation/engine.mjs'
10
10
  import { clampReputationScore } from '../reputation/math.mjs'
11
+ import { shuffleInPlace } from '../utils/shuffle.mjs'
11
12
 
12
13
  /**
13
14
  * 解析联邦池槽位参数(从 groupSettings 读取,含低功耗缩减)。
@@ -91,14 +92,8 @@ export function mergeTrustedWithAnchors(existingTrusted, rankedCandidates, limit
91
92
  */
92
93
  export function selectExploreWithSourceQuota(exploreIds, exploreSources, k, maxPerSource = EXPLORE_MAX_PER_SOURCE) {
93
94
  if (k <= 0 || !exploreIds.length) return []
94
- if (!exploreSources?.size) {
95
- const copy = [...exploreIds]
96
- for (let i = copy.length - 1; i > 0; i--) {
97
- const j = Math.floor(Math.random() * (i + 1))
98
- ;[copy[i], copy[j]] = [copy[j], copy[i]]
99
- }
100
- return copy.slice(0, k)
101
- }
95
+ if (!exploreSources?.size)
96
+ return shuffleInPlace([...exploreIds]).slice(0, k)
102
97
  /** @type {Map<string, string[]>} */
103
98
  const bySource = new Map()
104
99
  for (const id of exploreIds) {
@@ -106,11 +101,7 @@ export function selectExploreWithSourceQuota(exploreIds, exploreSources, k, maxP
106
101
  if (!bySource.has(src)) bySource.set(src, [])
107
102
  bySource.get(src).push(id)
108
103
  }
109
- for (const ids of bySource.values())
110
- for (let i = ids.length - 1; i > 0; i--) {
111
- const j = Math.floor(Math.random() * (i + 1))
112
- ;[ids[i], ids[j]] = [ids[j], ids[i]]
113
- }
104
+ for (const ids of bySource.values()) shuffleInPlace(ids)
114
105
  const out = []
115
106
  /** @type {Map<string, number>} */
116
107
  const picked = new Map()
@@ -148,7 +139,7 @@ export function selectPeerIdsFromPool({ roster, peers, rep, limits, selfNodeHash
148
139
  const blocked = new Set(peers.blockedPeers)
149
140
  const roomSet = inRoomNodeHashes instanceof Set
150
141
  ? inRoomNodeHashes
151
- : new Set(Array.isArray(inRoomNodeHashes) ? inRoomNodeHashes : [])
142
+ : new Set(inRoomNodeHashes || [])
152
143
  const onlineAll = roster.filter(
153
144
  rosterEntry => rosterEntry.peerId
154
145
  && rosterEntry.remoteNodeHash
@@ -175,10 +166,11 @@ export function selectPeerIdsFromPool({ roster, peers, rep, limits, selfNodeHash
175
166
  }
176
167
 
177
168
  const anchoredTrusted = peers.trustedPeers.filter(nodeHash => trustedSet.has(nodeHash))
178
- const extraTrusted = [...trustedSet]
179
- .filter(nodeHash => !anchoredTrusted.includes(nodeHash))
180
- .sort((a, b) => repScore(b, rep) - repScore(a, rep))
181
- for (const nodeId of [...anchoredTrusted, ...extraTrusted].slice(0, limits.trustedSlots)) {
169
+ for (const nodeId of mergeTrustedWithAnchors(
170
+ anchoredTrusted,
171
+ [...trustedSet].sort((a, b) => repScore(b, rep) - repScore(a, rep)),
172
+ limits,
173
+ )) {
182
174
  if (outPeerIds.size >= limits.maxPeers) break
183
175
  pushNode(nodeId)
184
176
  }
@@ -216,15 +208,14 @@ export function selectPeerIdsFromPool({ roster, peers, rep, limits, selfNodeHash
216
208
  * @returns {string[]} 应建链的 nodeHash 列表(去重)
217
209
  */
218
210
  export function selectLinkTargetsFromMembers({ members, selfNodeHash, rep, peers, limits, anchors = [] }) {
219
- const self = String(selfNodeHash || '')
220
211
  const blocked = new Set(peers?.blockedPeers || [])
221
212
  const now = Date.now()
222
- const candidates = [...new Set([...members].map(id => String(id)))]
223
- .filter(id => id && id !== self && !blocked.has(id) && !isQuarantinedPure(rep, id, now))
213
+ const candidates = [...new Set(members)]
214
+ .filter(id => id && id !== selfNodeHash && !blocked.has(id) && !isQuarantinedPure(rep, id, now))
224
215
  const ranked = candidates.slice().sort((a, b) => repScore(b, rep) - repScore(a, rep))
225
216
  const candidateSet = new Set(candidates)
226
217
  // 锚点(如 introducer/creator/seed)必连、且不占 trustedSlots——保证引导期连通。
227
- const forced = [...new Set([...anchors].map(id => String(id)))].filter(id => candidateSet.has(id))
218
+ const forced = [...new Set(anchors)].filter(id => candidateSet.has(id))
228
219
  const chosen = new Set(forced)
229
220
  // trusted 槽只从非锚点候选填:既有 trusted 优先保留,再按信誉补至 trustedSlots。
230
221
  const nonForced = ranked.filter(id => !chosen.has(id))
@@ -237,29 +228,24 @@ export function selectLinkTargetsFromMembers({ members, selfNodeHash, rep, peers
237
228
  }
238
229
 
239
230
  /**
240
- * 将新 PEX 节点线索合并进 explore 池,并按信誉重新填充 trusted 槽。
241
- * 纯计算版本,不含 I/O:接受 peers 对象,返回新对象。
242
- *
231
+ * 将候选 id 并入 explore,并按信誉重填 trusted
243
232
  * @param {{
244
233
  * peers: { trustedPeers: string[], explorePeers: string[], blockedPeers: string[] },
245
234
  * rep: { byNodeHash?: Record<string, { score?: number }> },
246
- * hints: string[],
235
+ * addIds: Iterable<string>,
247
236
  * limits: ReturnType<typeof resolveFederationPoolLimits>,
248
- * }} options 合并参数(peers、rep、hints、limits)
249
- * @returns {{ trustedPeers: string[], explorePeers: string[] }} 更新后的 trusted/explore 列表
237
+ * }} options 池状态与增量
238
+ * @returns {{ trustedPeers: string[], explorePeers: string[] }} 重算后的 trusted/explore
250
239
  */
251
- export function applyPexHints({ peers, rep, hints, limits }) {
252
- const ids = [...new Set(
253
- (Array.isArray(hints) ? hints : [])
254
- .map(id => String(id).trim())
255
- .filter(Boolean),
256
- )]
240
+ function rebuildExploreAndTrusted(options) {
241
+ const { peers, rep, addIds, limits } = options
242
+ const blocked = new Set(peers.blockedPeers)
257
243
  const explore = new Set(peers.explorePeers)
258
- for (const id of ids)
259
- if (!peers.blockedPeers.includes(id)) explore.add(id)
260
- const newExplorePeers = [...explore].slice(-500)
244
+ for (const id of addIds)
245
+ if (id && !blocked.has(id)) explore.add(id)
246
+ const newExplorePeers = [...explore].filter(id => !blocked.has(id)).slice(-500)
261
247
  const ranked = [...new Set([...peers.trustedPeers, ...newExplorePeers])]
262
- .filter(id => !peers.blockedPeers.includes(id))
248
+ .filter(id => !blocked.has(id))
263
249
  .sort((a, b) => repScore(b, rep) - repScore(a, rep))
264
250
  return {
265
251
  trustedPeers: mergeTrustedWithAnchors(peers.trustedPeers, ranked, limits, peers.blockedPeers),
@@ -268,32 +254,39 @@ export function applyPexHints({ peers, rep, hints, limits }) {
268
254
  }
269
255
 
270
256
  /**
271
- * roster 观测中更新 explore 池并重新填充 trusted 槽(纯计算版本)。
272
- *
257
+ * PEX 线索并入 explore 并重填 trusted(纯计算)。
273
258
  * @param {{
274
259
  * peers: { trustedPeers: string[], explorePeers: string[], blockedPeers: string[] },
275
260
  * rep: { byNodeHash?: Record<string, { score?: number }> },
276
- * roster: Array<{ remoteNodeHash?: string }>,
261
+ * hints: string[],
277
262
  * limits: ReturnType<typeof resolveFederationPoolLimits>,
278
- * }} options roster 更新参数(peers、rep、roster、limits)
279
- * @returns {{ trustedPeers: string[], explorePeers: string[] }} 更新后的 trusted/explore 列表
263
+ * }} options 池状态与 PEX hints
264
+ * @returns {{ trustedPeers: string[], explorePeers: string[] }} 重算后的 trusted/explore
280
265
  */
281
- export function applyRosterToPeerPool({ peers, rep, roster, limits }) {
282
- const explore = new Set(peers.explorePeers)
283
- for (const rosterEntry of roster) {
284
- const nodeId = rosterEntry.remoteNodeHash?.trim()
285
- if (nodeId && !peers.blockedPeers.includes(nodeId)) explore.add(nodeId)
286
- }
287
- const newExplorePeers = [...explore]
288
- .filter(id => !peers.blockedPeers.includes(id))
289
- .slice(-500)
290
- const candidates = [...new Set([...peers.trustedPeers, ...newExplorePeers])]
291
- .filter(id => !peers.blockedPeers.includes(id))
292
- .sort((a, b) => repScore(b, rep) - repScore(a, rep))
293
- return {
294
- trustedPeers: mergeTrustedWithAnchors(peers.trustedPeers, candidates, limits, peers.blockedPeers),
295
- explorePeers: newExplorePeers,
296
- }
266
+ export function applyPexHints(options) {
267
+ const { peers, rep, hints, limits } = options
268
+ return rebuildExploreAndTrusted({
269
+ peers, rep, limits,
270
+ addIds: hints || [],
271
+ })
272
+ }
273
+
274
+ /**
275
+ * roster 观测并入 explore 并重填 trusted(纯计算)。
276
+ * @param {{
277
+ * peers: { trustedPeers: string[], explorePeers: string[], blockedPeers: string[] },
278
+ * rep: { byNodeHash?: Record<string, { score?: number }> },
279
+ * roster: { remoteNodeHash?: string }[],
280
+ * limits: ReturnType<typeof resolveFederationPoolLimits>,
281
+ * }} options 池状态与 roster
282
+ * @returns {{ trustedPeers: string[], explorePeers: string[] }} 重算后的 trusted/explore
283
+ */
284
+ export function applyRosterToPeerPool(options) {
285
+ const { peers, rep, roster, limits } = options
286
+ return rebuildExploreAndTrusted({
287
+ peers, rep, limits,
288
+ addIds: roster.map(entry => entry.remoteNodeHash).filter(Boolean),
289
+ })
297
290
  }
298
291
 
299
292
  /**
@@ -308,17 +301,13 @@ export function pickFederationTargetPeerIds(groupId, roster, groupSettings, self
308
301
  const limits = resolveFederationPoolLimits(groupSettings)
309
302
  const peers = loadPeerPoolView(groupId)
310
303
  const rep = loadReputation()
311
- const inRoomNodeHashes = roster
312
- .map(p => p.remoteNodeHash)
313
- .map(id => String(id).trim())
314
- .filter(Boolean)
315
304
  return selectPeerIdsFromPool({
316
305
  roster,
317
306
  peers,
318
307
  rep,
319
308
  limits,
320
309
  selfNodeHash,
321
- inRoomNodeHashes,
310
+ inRoomNodeHashes: roster.map(entry => entry.remoteNodeHash).filter(Boolean),
322
311
  hintSources: peers.hintSources,
323
312
  })
324
313
  }
@@ -12,15 +12,13 @@ import { USER_ROOM_SCOPE } from './room_scopes.mjs'
12
12
  * }} RemoteUserRoomSlot
13
13
  */
14
14
 
15
- /** @type {Map<string, RemoteUserRoomSlot | null>} nodeHash → slot(null 表示加入失败/进行中) */
15
+ /** @type {Map<string, RemoteUserRoomSlot>} nodeHash → slot */
16
16
  const slots = new Map()
17
17
  /** @type {Map<string, Promise<RemoteUserRoomSlot | null>>} nodeHash → 进行中的 promise */
18
18
  const inflights = new Map()
19
19
 
20
20
  registerFederationRoomProvider('remote-user-room', () => {
21
- return [...slots.values()]
22
- .filter(Boolean)
23
- .map(s => s.roomSlot)
21
+ return [...slots.values()].map(s => s.roomSlot)
24
22
  })
25
23
 
26
24
  /**
@@ -32,16 +30,14 @@ registerFederationRoomProvider('remote-user-room', () => {
32
30
  export async function ensureRemoteUserRoom(username, targetNodeHash) {
33
31
  void username
34
32
  const key = targetNodeHash.toLowerCase()
35
- if (slots.has(key)) return slots.get(key) || null
36
- const existing = inflights.get(key)
37
- if (existing) return await existing
33
+ const existing = slots.get(key)
34
+ if (existing) return existing
35
+ const inflight = inflights.get(key)
36
+ if (inflight) return await inflight
38
37
 
39
38
  const task = (async () => {
40
39
  try {
41
- if (!await ensureLinkToNode(key)) {
42
- slots.set(key, null)
43
- return null
44
- }
40
+ if (!await ensureLinkToNode(key)) return null
45
41
 
46
42
  /** @type {import('../registries/room_provider.mjs').FederationRoomSlot} */
47
43
  const roomSlot = {
@@ -66,17 +62,20 @@ export async function ensureRemoteUserRoom(username, targetNodeHash) {
66
62
  * @returns {void}
67
63
  */
68
64
  sendToPeer(peerId, actionName, payload) {
69
- void getLink(key)?.send({ scope: 'node', action: String(actionName), payload }).catch(() => { })
65
+ void getLink(key)?.send({ scope: 'node', action: actionName, payload }).catch(() => { })
70
66
  },
71
67
  }
72
68
 
73
69
  const slot = {
74
70
  roomSlot,
75
71
  /**
76
- * 关闭远端用户房间链路。
72
+ * 关闭远端用户房间链路并释放槽位。
77
73
  * @returns {Promise<void>} 关闭完成
78
74
  */
79
- leave() { return closeLink(key, 'remote-user-room-release') },
75
+ leave() {
76
+ slots.delete(key)
77
+ return closeLink(key, 'remote-user-room-release')
78
+ },
80
79
  }
81
80
  slots.set(key, slot)
82
81
  invalidateTrustGraphCache()
@@ -84,7 +83,6 @@ export async function ensureRemoteUserRoom(username, targetNodeHash) {
84
83
  }
85
84
  catch (error) {
86
85
  console.error('p2p: failed to join remote user room', key, error)
87
- slots.set(key, null)
88
86
  return null
89
87
  }
90
88
  finally {
@@ -23,7 +23,7 @@ export function resolveRtcBudgetLimits(limits = {}) {
23
23
  maxActive: Math.max(4, Math.min(128, Number(limits.maxActive) || 32)),
24
24
  maxJoinsPerMin: Math.max(1, Math.min(120, Number(limits.maxJoinsPerMin) || 12)),
25
25
  overloadCooldownMs: Math.max(1000, Number(limits.overloadCooldownMs) || 15_000),
26
- trustedPeers: Array.isArray(limits.trustedPeers) ? limits.trustedPeers.map(String) : [],
26
+ trustedPeers: limits.trustedPeers || [],
27
27
  trustedReserveFraction: Math.max(0.1, Math.min(0.5, Number(limits.trustedReserveFraction) || DEFAULT_TRUSTED_RESERVE_FRACTION)),
28
28
  minTrustedReserved: Math.max(1, Math.floor(Number(limits.minTrustedReserved) || DEFAULT_MIN_TRUSTED_RESERVED)),
29
29
  }
@@ -85,10 +85,14 @@ export function takeRtcJoinSlot(roomKey, peerId, limits = {}, sourceId = 'peer')
85
85
  const isTrusted = peerId && bucket.trustedPeers.has(peerId)
86
86
  const trustedReserved = Math.max(minTrustedReserved, Math.floor(maxActive * trustedReserveFraction))
87
87
  const maxNonTrusted = Math.max(1, maxActive - trustedReserved)
88
- const nonTrustedCount = [...bucket.active].filter(id => !bucket.trustedPeers.has(id)).length
88
+ let nonTrustedCount = 0
89
+ for (const id of bucket.active)
90
+ if (!bucket.trustedPeers.has(id)) nonTrustedCount++
89
91
  if (!isTrusted) {
90
92
  const source = String(sourceId || 'peer')
91
- const sameSource = [...bucket.sourceByPeer.entries()].filter(([, s]) => s === source).length
93
+ let sameSource = 0
94
+ for (const peerSource of bucket.sourceByPeer.values())
95
+ if (peerSource === source) sameSource++
92
96
  const sourceCap = Math.max(1, Math.floor(maxActive * MAX_SOURCE_SLOT_FRACTION))
93
97
  if (sameSource >= sourceCap) return false
94
98
  if (nonTrustedCount >= maxNonTrusted && bucket.active.size >= maxActive) {
@@ -149,6 +153,16 @@ export function releaseRtcPeer(roomKey, peerId) {
149
153
  bucket.active.delete(peerId)
150
154
  bucket.sourceByPeer.delete(peerId)
151
155
  bucket.peerNodeHash.delete(peerId)
156
+ // 房间空闲后丢掉桶,避免 leave 过的 roomKey 永久占内存
157
+ if (!bucket.active.size) budgets.delete(roomKey)
158
+ }
159
+
160
+ /**
161
+ * 当前预算桶数量(测试用)。
162
+ * @returns {number} 房间桶数
163
+ */
164
+ export function rtcBudgetRoomCount() {
165
+ return budgets.size
152
166
  }
153
167
 
154
168
  /** RTC 过载时跳过的非关键联邦 action */
@@ -180,5 +194,5 @@ const NON_CRITICAL_FED_ACTIONS = new Set([
180
194
  */
181
195
  export function isFederationActionAllowedUnderLoad(roomKey, actionName, limits = {}) {
182
196
  if (!isRtcRoomOverloaded(roomKey, limits)) return true
183
- return !NON_CRITICAL_FED_ACTIONS.has(String(actionName || '').trim())
197
+ return !NON_CRITICAL_FED_ACTIONS.has(actionName)
184
198
  }