@steve02081504/fount-p2p 0.0.0

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 (154) hide show
  1. package/README.md +62 -0
  2. package/core/bytes_codec.mjs +51 -0
  3. package/core/canonical_json.mjs +21 -0
  4. package/core/composite_key.mjs +91 -0
  5. package/core/constants.mjs +53 -0
  6. package/core/entity_id.mjs +53 -0
  7. package/core/entity_id_parse.mjs +40 -0
  8. package/core/evfs_logical_path.mjs +15 -0
  9. package/core/hexIds.mjs +73 -0
  10. package/core/hlc.mjs +114 -0
  11. package/core/random_id.mjs +9 -0
  12. package/crypto/channel.mjs +101 -0
  13. package/crypto/checkpoint_sign.mjs +67 -0
  14. package/crypto/crypto.mjs +158 -0
  15. package/crypto/key.mjs +431 -0
  16. package/dag/canonicalize_row.mjs +65 -0
  17. package/dag/event_query.mjs +15 -0
  18. package/dag/index.mjs +335 -0
  19. package/dag/storage.mjs +333 -0
  20. package/dag/strip_extensions.mjs +18 -0
  21. package/dag/tunables.json +6 -0
  22. package/discovery/bt.mjs +260 -0
  23. package/discovery/index.mjs +134 -0
  24. package/discovery/mdns.mjs +138 -0
  25. package/discovery/nostr.mjs +273 -0
  26. package/entity/files/acl.mjs +38 -0
  27. package/entity/files/evfs.mjs +189 -0
  28. package/entity/files/evfs_ref.mjs +37 -0
  29. package/entity/files/manifest_acl_registry.mjs +93 -0
  30. package/entity/files/replica_host_cache.mjs +46 -0
  31. package/entity/files/url.mjs +19 -0
  32. package/entity/hosting_registry.mjs +47 -0
  33. package/entity/localized_core.mjs +141 -0
  34. package/entity/logical_entity.mjs +25 -0
  35. package/entity/logical_entity_id_registry.mjs +40 -0
  36. package/entity/node_hash.mjs +15 -0
  37. package/entity/presentation_registry.mjs +44 -0
  38. package/entity/profile.mjs +256 -0
  39. package/entity/replica.mjs +20 -0
  40. package/entity/session_snapshot_registry.mjs +38 -0
  41. package/federation/chunk_fetch_pending.mjs +128 -0
  42. package/federation/chunk_fetch_scheduler.mjs +97 -0
  43. package/federation/dag_order_cache.mjs +124 -0
  44. package/federation/dedupe_slot.mjs +33 -0
  45. package/federation/message_rate_limit.mjs +23 -0
  46. package/federation/operator_key_chain.mjs +200 -0
  47. package/federation/topo_order_memo.mjs +44 -0
  48. package/federation/volatile_streams.mjs +52 -0
  49. package/federation/want_ids.mjs +158 -0
  50. package/files/assemble.mjs +220 -0
  51. package/files/assemble_stream.mjs +129 -0
  52. package/files/chunk_fetch.mjs +113 -0
  53. package/files/chunk_fetch_verify.mjs +23 -0
  54. package/files/chunk_provider_registry.mjs +70 -0
  55. package/files/chunk_responder.mjs +79 -0
  56. package/files/chunk_store.mjs +87 -0
  57. package/files/manifest.mjs +99 -0
  58. package/files/transfer_key.mjs +87 -0
  59. package/files/transfer_key_registry.mjs +108 -0
  60. package/governance/branch.mjs +230 -0
  61. package/governance/join_pow.mjs +165 -0
  62. package/governance/owner_succession_ballot.mjs +57 -0
  63. package/governance/tunables.json +7 -0
  64. package/index.mjs +39 -0
  65. package/link/channel_mux.mjs +222 -0
  66. package/link/frame.mjs +246 -0
  67. package/link/handshake.mjs +175 -0
  68. package/link/link.mjs +617 -0
  69. package/link/rtc.mjs +109 -0
  70. package/link/sdp_fingerprint.mjs +25 -0
  71. package/mailbox/consumer_registry.mjs +71 -0
  72. package/mailbox/deliver_or_store.mjs +190 -0
  73. package/mailbox/importance.mjs +72 -0
  74. package/mailbox/parse.mjs +73 -0
  75. package/mailbox/prune.mjs +106 -0
  76. package/mailbox/rate.mjs +86 -0
  77. package/mailbox/settings.mjs +53 -0
  78. package/mailbox/store.mjs +256 -0
  79. package/mailbox/tunables.json +12 -0
  80. package/mailbox/wire.mjs +40 -0
  81. package/node/denylist.mjs +309 -0
  82. package/node/entity_store.mjs +214 -0
  83. package/node/identity.mjs +99 -0
  84. package/node/instance.mjs +115 -0
  85. package/node/network.mjs +306 -0
  86. package/node/personal_block.mjs +286 -0
  87. package/node/reputation_store.mjs +326 -0
  88. package/node/retention_policy.mjs +82 -0
  89. package/node/signaling_config.mjs +32 -0
  90. package/node/storage.mjs +30 -0
  91. package/node/storage_plugins.mjs +71 -0
  92. package/node/user_paths.mjs +10 -0
  93. package/overlay/index.mjs +191 -0
  94. package/package.json +73 -0
  95. package/permissions/bitmask.mjs +35 -0
  96. package/permissions/evaluator.mjs +90 -0
  97. package/permissions/index.mjs +12 -0
  98. package/permissions/layered.mjs +39 -0
  99. package/registries/action.mjs +94 -0
  100. package/registries/event_type.mjs +75 -0
  101. package/registries/inbound.mjs +90 -0
  102. package/registries/p2p_viewer.mjs +35 -0
  103. package/registries/part_path.mjs +35 -0
  104. package/registries/room_provider.mjs +47 -0
  105. package/reputation/engine.mjs +458 -0
  106. package/reputation/math.mjs +73 -0
  107. package/reputation/pick_score.mjs +11 -0
  108. package/reputation/relay_dedupe.mjs +22 -0
  109. package/reputation/tunables.json +36 -0
  110. package/rooms/scoped_link.mjs +185 -0
  111. package/schemas/discovery.mjs +110 -0
  112. package/schemas/federation_pull.mjs +102 -0
  113. package/schemas/mailbox.mjs +37 -0
  114. package/schemas/remote_event.mjs +17 -0
  115. package/timeline/append_core.mjs +54 -0
  116. package/timeline/materialize_runner.mjs +26 -0
  117. package/timeline/prune.mjs +39 -0
  118. package/timeline/retention.mjs +54 -0
  119. package/timeline/retention_runner.mjs +35 -0
  120. package/timeline/verify_remote.mjs +25 -0
  121. package/transport/group_link_set.mjs +324 -0
  122. package/transport/ice_servers.mjs +72 -0
  123. package/transport/link_registry.mjs +749 -0
  124. package/transport/peer_identity_maps.mjs +94 -0
  125. package/transport/peer_pool.mjs +355 -0
  126. package/transport/remote_user_room.mjs +109 -0
  127. package/transport/room_scopes.mjs +2 -0
  128. package/transport/room_wire_action.mjs +19 -0
  129. package/transport/rtc_connection_budget.mjs +180 -0
  130. package/transport/rtc_mdns_filter.mjs +118 -0
  131. package/transport/stale_peer_log.mjs +50 -0
  132. package/transport/user_room.mjs +263 -0
  133. package/trust_graph/build.mjs +99 -0
  134. package/trust_graph/cache.mjs +44 -0
  135. package/trust_graph/engine.mjs +184 -0
  136. package/trust_graph/registry.mjs +38 -0
  137. package/trust_graph/resolve.mjs +105 -0
  138. package/trust_graph/send.mjs +88 -0
  139. package/trust_graph/tunables.json +13 -0
  140. package/utils/async_mutex.mjs +69 -0
  141. package/utils/debug_log.mjs +16 -0
  142. package/utils/duration.mjs +28 -0
  143. package/utils/json_io.mjs +57 -0
  144. package/utils/lru.mjs +22 -0
  145. package/utils/map_pool.mjs +27 -0
  146. package/wire/group_part.mjs +43 -0
  147. package/wire/ingress.mjs +58 -0
  148. package/wire/part_common.mjs +55 -0
  149. package/wire/part_fanout.mjs +71 -0
  150. package/wire/part_ingress.mjs +172 -0
  151. package/wire/part_invoke.mjs +81 -0
  152. package/wire/rate_bucket.mjs +49 -0
  153. package/wire/volatile_signature.mjs +76 -0
  154. package/wire/wait.mjs +127 -0
@@ -0,0 +1,39 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+
4
+ import { topologicalCanonicalOrder } from '../dag/index.mjs'
5
+ import { readJsonl, writeJsonlSynced } from '../dag/storage.mjs'
6
+ import { invalidateTopologicalOrderMemo } from '../federation/topo_order_memo.mjs'
7
+ import { descendantClosureFromTip } from '../governance/branch.mjs'
8
+
9
+ /**
10
+ * 将 events.jsonl 裁剪为 checkpoint 起后代闭包(连通子图,非拓扑下标切片)。
11
+ * @param {string} eventsFilePath events.jsonl 路径
12
+ * @param {object | null} checkpoint 含 checkpoint_event_id 的快照
13
+ * @param {(row: object) => object} [sanitize] 行规范化
14
+ * @returns {Promise<{ pruned: boolean, kept: number, dropped: number }>} 裁剪统计
15
+ */
16
+ export async function pruneEventsJsonlAfterCheckpoint(eventsFilePath, checkpoint, sanitize = row => row) {
17
+ const tipId = checkpoint?.checkpoint_event_id
18
+ if (!tipId) return { pruned: false, kept: 0, dropped: 0 }
19
+ const events = await readJsonl(eventsFilePath, { sanitize })
20
+ if (!events.length) return { pruned: false, kept: 0, dropped: 0 }
21
+ const byId = new Map(events.map(dagEvent => [dagEvent.id, dagEvent]))
22
+ if (!byId.has(tipId)) return { pruned: false, kept: events.length, dropped: 0 }
23
+
24
+ const keepIds = descendantClosureFromTip(tipId, byId)
25
+ const order = topologicalCanonicalOrder(events.map(dagEvent => ({
26
+ id: dagEvent.id,
27
+ prev_event_ids: dagEvent.prev_event_ids,
28
+ hlc: dagEvent.hlc,
29
+ node_id: dagEvent.node_id,
30
+ sender: dagEvent.sender,
31
+ })))
32
+ const kept = order.map(id => byId.get(id)).filter(ev => ev && keepIds.has(ev.id))
33
+ const dropped = events.length - kept.length
34
+ if (dropped <= 0) return { pruned: false, kept: kept.length, dropped: 0 }
35
+ await mkdir(dirname(eventsFilePath), { recursive: true })
36
+ await writeJsonlSynced(eventsFilePath, kept)
37
+ invalidateTopologicalOrderMemo(eventsFilePath)
38
+ return { pruned: true, kept: kept.length, dropped }
39
+ }
@@ -0,0 +1,54 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+
4
+ import { topologicalCanonicalOrder } from '../dag/index.mjs'
5
+ import { readJsonl, writeJsonlSynced } from '../dag/storage.mjs'
6
+ import { invalidateTopologicalOrderMemo } from '../federation/topo_order_memo.mjs'
7
+ import { computeDagTipIdsFromEvents, selectConsensusBranchTip } from '../governance/branch.mjs'
8
+ import { computeRetentionKeepIds } from '../node/retention_policy.mjs'
9
+
10
+ /**
11
+ * @param {string} eventsFilePath events.jsonl 路径
12
+ * @param {object | null} checkpointHint 检查点提示
13
+ * @param {{ maxDepth: number, maxMs: number, anchorTypes: Set<string> }} policy 保留策略
14
+ * @param {(row: object) => object} sanitize 行规范化
15
+ * @returns {Promise<{ pruned: boolean, kept: number, dropped: number }>} 裁剪统计
16
+ */
17
+ export async function enforceTimelineEventRetention(
18
+ eventsFilePath,
19
+ checkpointHint,
20
+ policy,
21
+ sanitize = row => row,
22
+ ) {
23
+ const events = await readJsonl(eventsFilePath, { sanitize })
24
+ if (!events.length) return { pruned: false, kept: 0, dropped: 0 }
25
+ const maxDepth = Math.max(256, Number(policy.maxDepth) || 200_000)
26
+ const maxMs = Math.max(3_600_000, Number(policy.maxMs) || 365 * 24 * 3600 * 1000)
27
+ const cutoffWall = Date.now() - maxMs
28
+ const byId = new Map(events.map(e => [e.id, e]))
29
+ const order = topologicalCanonicalOrder(events.map(e => ({
30
+ id: e.id,
31
+ prev_event_ids: e.prev_event_ids,
32
+ hlc: e.hlc,
33
+ node_id: e.node_id,
34
+ sender: e.sender,
35
+ })))
36
+ const tips = computeDagTipIdsFromEvents(events)
37
+ const branchTipId = selectConsensusBranchTip(tips, byId)
38
+ const checkpointTipId = checkpointHint?.checkpoint_event_id || null
39
+ const keepIds = computeRetentionKeepIds(order, byId, {
40
+ maxDepth,
41
+ cutoffWall,
42
+ anchorTypes: policy.anchorTypes,
43
+ checkpointTipId,
44
+ branchTipId,
45
+ })
46
+ if (keepIds.size >= events.length) return { pruned: false, kept: events.length, dropped: 0 }
47
+ const kept = order.map(id => byId.get(id)).filter(ev => ev && keepIds.has(ev.id))
48
+ const dropped = events.length - kept.length
49
+ if (dropped <= 0) return { pruned: false, kept: kept.length, dropped: 0 }
50
+ await mkdir(dirname(eventsFilePath), { recursive: true })
51
+ await writeJsonlSynced(eventsFilePath, kept)
52
+ invalidateTopologicalOrderMemo(eventsFilePath)
53
+ return { pruned: true, kept: kept.length, dropped }
54
+ }
@@ -0,0 +1,35 @@
1
+ import { readJsonl } from '../dag/storage.mjs'
2
+
3
+ import { pruneEventsJsonlAfterCheckpoint } from './prune.mjs'
4
+ import { enforceTimelineEventRetention } from './retention.mjs'
5
+
6
+ /**
7
+ * @param {{
8
+ * eventsFilePath: string,
9
+ * readCheckpoint: () => Promise<object | null>,
10
+ * policy: { maxDepth: number, maxMs: number, anchorTypes: Set<string> },
11
+ * sanitize?: (row: object) => object,
12
+ * }} args 保留参数
13
+ * @returns {Promise<{ pruned: boolean, kept: number, dropped: number }>} 裁剪统计
14
+ */
15
+ export async function enforceDagRetention({ eventsFilePath, readCheckpoint, policy, sanitize = row => row }) {
16
+ const checkpoint = await readCheckpoint()
17
+ return enforceTimelineEventRetention(eventsFilePath, checkpoint, policy, sanitize)
18
+ }
19
+
20
+ /**
21
+ * @param {{
22
+ * eventsFilePath: string,
23
+ * checkpoint: object | null,
24
+ * policy: { maxDepth: number, maxMs: number, anchorTypes: Set<string> },
25
+ * sanitize: (row: object) => object,
26
+ * compactTrigger: number,
27
+ * }} args 维护参数
28
+ * @returns {Promise<void>}
29
+ */
30
+ export async function runTimelineMaintenance({ eventsFilePath, checkpoint, policy, sanitize, compactTrigger }) {
31
+ await enforceTimelineEventRetention(eventsFilePath, checkpoint, policy, sanitize)
32
+ const count = (await readJsonl(eventsFilePath, { sanitize })).length
33
+ if (count > compactTrigger && checkpoint?.checkpoint_event_id)
34
+ await pruneEventsJsonlAfterCheckpoint(eventsFilePath, checkpoint, sanitize)
35
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Social / 时间线远程入站 ed25519 验签(sender 为 pubKeyHash,公钥来自 senderPubKey)。
3
+ */
4
+ import { Buffer } from 'node:buffer'
5
+
6
+ import { isHex64, isSignatureHex128 } from '../core/hexIds.mjs'
7
+ import { pubKeyHash, verify } from '../crypto/crypto.mjs'
8
+ import { eventBodyForSign, signPayloadBytes } from '../dag/index.mjs'
9
+
10
+ /**
11
+ * @param {object} event 含 signature、sender、senderPubKey 的签名事件
12
+ * @returns {Promise<boolean>} 验签是否通过
13
+ */
14
+ export async function verifyTimelineRemoteSignature(event) {
15
+ const sender = String(event?.sender || '').trim().toLowerCase()
16
+ if (!isHex64(sender)) return false
17
+ const signatureHex = String(event?.signature || '').trim()
18
+ if (!isSignatureHex128(signatureHex)) return false
19
+ const pubKeyHex = String(event?.senderPubKey || '').trim().toLowerCase()
20
+ if (!isHex64(pubKeyHex)) return false
21
+ const publicKeyBytes = new Uint8Array(Buffer.from(pubKeyHex, 'hex'))
22
+ if (pubKeyHash(publicKeyBytes) !== sender) return false
23
+ const signatureBytes = new Uint8Array(Buffer.from(signatureHex, 'hex'))
24
+ return await verify(signatureBytes, signPayloadBytes(eventBodyForSign(event)), publicKeyBytes)
25
+ }
@@ -0,0 +1,324 @@
1
+ import { advertiseTopic, subscribeTopic } from '../discovery/index.mjs'
2
+ import { buildSignedAdvert, verifySignedAdvert } from '../link/handshake.mjs'
3
+ import { loadPeerPoolView } from '../node/network.mjs'
4
+ import { loadReputation } from '../node/reputation_store.mjs'
5
+
6
+ import {
7
+ groupRendezvousTopic,
8
+ decryptSignalPacket,
9
+ encryptSignalPacket,
10
+ getLinkRegistry,
11
+ } from './link_registry.mjs'
12
+ import { resolveFederationPoolLimits, selectLinkTargetsFromMembers } from './peer_pool.mjs'
13
+
14
+
15
+ /**
16
+ * 创建基于 link registry 的群组联邦房间。
17
+ * @param {object} opts 选项
18
+ * @param {string} opts.groupId 群组 id
19
+ * @param {string} opts.roomSecret 房间密钥(用于 rendezvous topic)
20
+ * @param {string[]} opts.members 初始成员 nodeHash 列表
21
+ * @param {object} [opts.groupSettings] 群设置(连接预算:trustedPeerSlots/explorePeerSlots/maxPeers 等)
22
+ * @returns {{ groupId: string, scope: string, start: () => Promise<void>, leave: () => Promise<void>, getRoster: () => Array<{ peerId: string, remoteNodeHash: string }>, getPeerIdByNodeHash: (nodeHash: string) => string | null, sendToPeer: (peerId: string, actionName: string, payload: unknown) => Promise<boolean>, send: (actionName: string, payload: unknown, peerId?: string | null) => Promise<number>, onEnvelope: (cb: (senderNodeHash: string, envelope: object) => void) => () => void, onPeerJoin: (cb: (peerId: string) => void) => () => void, onPeerLeave: (cb: (peerId: string) => void) => () => void, getPeers: () => Record<string, true>, makeAction: (name: string) => [(payload: unknown, peerId?: string | string[] | null) => Promise<void>, (handler: (payload: unknown, peerId: string) => void) => void], registerCleanup: (fn: () => void) => void, isActive: () => boolean }} 群组 link set 接口
23
+ */
24
+ export function createGroupLinkSet(opts) {
25
+ const registry = opts.registry ?? getLinkRegistry()
26
+ const autoconnect = opts.autoconnect !== false
27
+ const groupId = String(opts.groupId)
28
+ const scope = `group:${groupId}`
29
+ const topic = groupRendezvousTopic(opts.roomSecret)
30
+ const members = new Set((Array.isArray(opts.members) ? opts.members : []).map(String))
31
+ const selfNodeHash = registry.localIdentity.nodeHash
32
+ const groupSettings = opts.groupSettings ?? {}
33
+ // 初始成员是调用方明确知道的引导集合(如 introducer/creator/seed),作为必连锚点保证引导期连通。
34
+ const initialAnchors = new Set(members)
35
+ /** @type {ReturnType<typeof setTimeout> | null} */
36
+ let dialTimer = null
37
+ /** @type {Set<Function>} */
38
+ const cleanups = new Set()
39
+ /** @type {Set<Function>} */
40
+ const envelopeListeners = new Set()
41
+ /** @type {Set<(peerId: string) => void>} */
42
+ const peerJoinListeners = new Set()
43
+ /** @type {Set<(peerId: string) => void>} */
44
+ const peerLeaveListeners = new Set()
45
+ /** @type {Set<string>} */
46
+ const announcedPeers = new Set()
47
+ /** @type {Map<string, { handler: ((payload: unknown, peerId: string) => void) | null, backlog: Array<{ payload: unknown, peerId: string }> }>} */
48
+ const actionEntries = new Map()
49
+ let active = true
50
+
51
+ /**
52
+ * 注册 leave 时执行的清理回调。
53
+ * @param {() => void} fn 清理函数
54
+ * @returns {void}
55
+ */
56
+ function registerCleanup(fn) {
57
+ if (typeof fn !== 'function') return
58
+ cleanups.add(fn)
59
+ }
60
+
61
+ /**
62
+ * 向 peer 事件监听器集合广播。
63
+ * @param {Set<(peerId: string) => void>} listeners 监听器集合
64
+ * @param {string} peerId 目标 peer id
65
+ * @returns {void}
66
+ */
67
+ function emitPeerListeners(listeners, peerId) {
68
+ for (const listener of listeners)
69
+ try { listener(peerId) } catch { /* ignore */ }
70
+ }
71
+
72
+ /**
73
+ * 记录 peer 加入并通知监听器。
74
+ * @param {string} peerId 加入的 peer id
75
+ * @returns {void}
76
+ */
77
+ function notePeerJoin(peerId) {
78
+ if (!peerId || announcedPeers.has(peerId)) return
79
+ announcedPeers.add(peerId)
80
+ emitPeerListeners(peerJoinListeners, peerId)
81
+ }
82
+
83
+ /**
84
+ * 记录 peer 离开并通知监听器。
85
+ * @param {string} peerId 离开的 peer id
86
+ * @returns {void}
87
+ */
88
+ function notePeerLeave(peerId) {
89
+ if (!peerId || !announcedPeers.has(peerId)) return
90
+ announcedPeers.delete(peerId)
91
+ emitPeerListeners(peerLeaveListeners, peerId)
92
+ }
93
+
94
+ /**
95
+ * 将新发现的 nodeHash 加入成员集合并更新 scope 兴趣。
96
+ * @param {string} nodeHash 候选节点 64 hex
97
+ * @returns {void}
98
+ */
99
+ function notePeerCandidate(nodeHash) {
100
+ const normalized = String(nodeHash || '')
101
+ if (!normalized || normalized === selfNodeHash || members.has(normalized)) return
102
+ members.add(normalized)
103
+ registry.registerScopeInterest(scope, [...members])
104
+ // 若链路先于"得知其群成员身份"建立(如 warmup/连节点先连上,之后才收到其群 advert/envelope),
105
+ // onLinkUp 当时因 !members.has 被跳过,这里补发 peer-join,触发 bootstrap flush(member_join 自证推送)。
106
+ if (registry.getLink(normalized)) notePeerJoin(normalized)
107
+ scheduleDial()
108
+ }
109
+
110
+ /**
111
+ * 依信任稀疏池从当前成员选出建链目标(top-K 信任 + 随机 explore + 初始锚点必连),拨号未连接者。
112
+ * 不主动断连(超预算由 registry 全局 trimToBudget 兜底)。
113
+ * @returns {void}
114
+ */
115
+ function selectAndDial() {
116
+ if (!autoconnect || !active) return
117
+ const targets = selectLinkTargetsFromMembers({
118
+ members,
119
+ selfNodeHash,
120
+ rep: loadReputation(),
121
+ peers: loadPeerPoolView(groupId),
122
+ limits: resolveFederationPoolLimits(groupSettings),
123
+ anchors: initialAnchors,
124
+ })
125
+ for (const nodeHash of targets)
126
+ if (nodeHash !== selfNodeHash && !registry.getLink(nodeHash))
127
+ void registry.ensureLinkToNode(nodeHash).catch(() => null)
128
+ }
129
+
130
+ /**
131
+ * 去抖触发一次建链目标重算(成员变化时调用,避免频繁重算)。
132
+ * @returns {void}
133
+ */
134
+ function scheduleDial() {
135
+ if (!autoconnect || !active || dialTimer) return
136
+ dialTimer = setTimeout(() => { dialTimer = null; selectAndDial() }, 200)
137
+ }
138
+
139
+ /**
140
+ * 获取或创建指定 action 的 handler/backlog 条目。
141
+ * @param {string} name action 名称
142
+ * @returns {{ handler: ((payload: unknown, peerId: string) => void) | null, backlog: Array<{ payload: unknown, peerId: string }> }} action 条目
143
+ */
144
+ function getActionEntry(name) {
145
+ const key = String(name)
146
+ if (!actionEntries.has(key))
147
+ actionEntries.set(key, { handler: null, backlog: [] })
148
+ return actionEntries.get(key)
149
+ }
150
+
151
+ /**
152
+ * 返回当前已连接且非自身的成员 roster。
153
+ * @returns {Array<{ peerId: string, remoteNodeHash: string }>} 在线成员列表
154
+ */
155
+ function activeRoster() {
156
+ return [...members]
157
+ .filter(nodeHash => nodeHash !== selfNodeHash && registry.getLink(nodeHash))
158
+ .map(nodeHash => ({ peerId: nodeHash, remoteNodeHash: nodeHash }))
159
+ }
160
+
161
+ /**
162
+ * 启动 discovery、scope 订阅与成员自动连接。
163
+ * @returns {Promise<void>}
164
+ */
165
+ async function start() {
166
+ // 房间就绪本身就意味着本节点应当可被发现;不能等到首次外拨 ensureLinkToNode 才懒启动 runtime,
167
+ // 否则“只有自己一个成员”的创建者房间会永远不监听 node topic,后来的 joiner 也就永远拨不进来。
168
+ if (typeof registry.ensureRuntime === 'function')
169
+ await registry.ensureRuntime()
170
+ registry.registerScopeInterest(scope, [...members])
171
+ registerCleanup(registry.subscribeScope(scope, (senderNodeHash, envelope) => {
172
+ notePeerCandidate(senderNodeHash)
173
+ const entry = actionEntries.get(String(envelope?.action || ''))
174
+ if (entry)
175
+ if (entry.handler) entry.handler(envelope.payload, senderNodeHash)
176
+ else entry.backlog.push({ payload: envelope.payload, peerId: senderNodeHash })
177
+
178
+ for (const listener of envelopeListeners)
179
+ listener(senderNodeHash, envelope)
180
+ }))
181
+ registerCleanup(registry.onLinkUp(nodeHash => {
182
+ if (!members.has(nodeHash) || nodeHash === selfNodeHash) return
183
+ notePeerJoin(nodeHash)
184
+ }))
185
+ registerCleanup(registry.onLinkDown(nodeHash => {
186
+ if (!members.has(nodeHash) || nodeHash === selfNodeHash) return
187
+ notePeerLeave(nodeHash)
188
+ }))
189
+ registerCleanup(await subscribeTopic(topic, async bytes => {
190
+ const packet = decryptSignalPacket(topic, bytes)
191
+ if (packet?.type !== 'advert' || !packet.body) return
192
+ const verifiedNodeHash = await verifySignedAdvert(topic, packet.body)
193
+ if (!verifiedNodeHash || verifiedNodeHash === selfNodeHash) return
194
+ // 发现新成员:并入成员集并去抖重算稀疏建链目标(notePeerCandidate 内部会 scheduleDial)。
195
+ notePeerCandidate(verifiedNodeHash)
196
+ }))
197
+ registerCleanup(await advertiseTopic(topic, encryptSignalPacket(topic, {
198
+ type: 'advert',
199
+ body: await buildSignedAdvert(topic, Date.now(), registry.localIdentity),
200
+ })))
201
+ if (autoconnect) selectAndDial()
202
+ for (const { peerId } of activeRoster())
203
+ notePeerJoin(peerId)
204
+ }
205
+
206
+ return {
207
+ groupId,
208
+ scope,
209
+ start,
210
+ /**
211
+ * 停止房间并执行所有已注册清理。
212
+ * @returns {Promise<void>}
213
+ */
214
+ async leave() {
215
+ if (!active) return
216
+ active = false
217
+ if (dialTimer) { clearTimeout(dialTimer); dialTimer = null }
218
+ registry.releaseScopeInterest(scope)
219
+ for (const cleanup of cleanups)
220
+ try { cleanup() } catch { /* ignore */ }
221
+ cleanups.clear()
222
+ },
223
+ getRoster: activeRoster,
224
+ /**
225
+ * 按 nodeHash 查找已连接 peer id。
226
+ * @param {string} nodeHash 目标节点 64 hex
227
+ * @returns {string | null} 对端 id;无链路时为 null
228
+ */
229
+ getPeerIdByNodeHash(nodeHash) {
230
+ return registry.getLink(nodeHash) ? String(nodeHash) : null
231
+ },
232
+ /**
233
+ * 向单个 peer 发送 scope action。
234
+ * @param {string} peerId 目标 peer id
235
+ * @param {string} actionName action 名称
236
+ * @param {unknown} payload 载荷
237
+ * @returns {Promise<boolean>} 是否发送成功
238
+ */
239
+ async sendToPeer(peerId, actionName, payload) {
240
+ return await registry.sendToNodeLink(peerId, { scope, action: String(actionName), payload })
241
+ },
242
+ /**
243
+ * 向单个或全部在线成员发送 action。
244
+ * @param {string} actionName action 名称
245
+ * @param {unknown} payload 载荷
246
+ * @param {string | null} [peerId] 目标 peer;null 时广播
247
+ * @returns {Promise<number>} 成功发送的 peer 数
248
+ */
249
+ async send(actionName, payload, peerId = null) {
250
+ if (peerId) return await this.sendToPeer(peerId, actionName, payload) ? 1 : 0
251
+ let sent = 0
252
+ for (const { peerId: targetPeerId } of activeRoster())
253
+ if (await registry.sendToNodeLink(targetPeerId, { scope, action: String(actionName), payload })) sent++
254
+ return sent
255
+ },
256
+ /**
257
+ * 订阅入站 envelope。
258
+ * @param {(senderNodeHash: string, envelope: object) => void} cb 回调
259
+ * @returns {() => void} 取消订阅函数
260
+ */
261
+ onEnvelope(cb) {
262
+ envelopeListeners.add(cb)
263
+ return () => envelopeListeners.delete(cb)
264
+ },
265
+ /**
266
+ * 订阅 peer 加入事件(含当前已在线 peer 的即时回调)。
267
+ * @param {(peerId: string) => void} cb 回调
268
+ * @returns {() => void} 取消订阅函数
269
+ */
270
+ onPeerJoin(cb) {
271
+ peerJoinListeners.add(cb)
272
+ for (const { peerId } of activeRoster())
273
+ if (peerId) announcedPeers.add(peerId)
274
+ for (const peerId of announcedPeers)
275
+ try { cb(peerId) } catch { /* ignore */ }
276
+ return () => peerJoinListeners.delete(cb)
277
+ },
278
+ /**
279
+ * 订阅 peer 离开事件。
280
+ * @param {(peerId: string) => void} cb 回调
281
+ * @returns {() => void} 取消订阅函数
282
+ */
283
+ onPeerLeave(cb) {
284
+ peerLeaveListeners.add(cb)
285
+ return () => peerLeaveListeners.delete(cb)
286
+ },
287
+ /**
288
+ * 返回当前在线 peer 的 Record 映射。
289
+ * @returns {Record<string, true>} peerId → true
290
+ */
291
+ getPeers() {
292
+ return Object.fromEntries(activeRoster().map(({ peerId }) => [peerId, true]))
293
+ },
294
+ /**
295
+ * 创建兼容 room.makeAction 的 [send, on] 函数对。
296
+ * @param {string} name action 名称
297
+ * @returns {[(payload: unknown, peerId?: string | string[] | null) => Promise<void>, (handler: (payload: unknown, peerId: string) => void) => void]} send 与 on 函数对
298
+ */
299
+ makeAction(name) {
300
+ const actionName = String(name)
301
+ return [
302
+ async (payload, peerId = null) => {
303
+ if (Array.isArray(peerId)) {
304
+ await Promise.all(peerId.map(targetPeerId => this.sendToPeer(targetPeerId, actionName, payload)))
305
+ return
306
+ }
307
+ await this.send(actionName, payload, peerId)
308
+ },
309
+ handler => {
310
+ const entry = getActionEntry(actionName)
311
+ entry.handler = handler
312
+ for (const pending of entry.backlog.splice(0))
313
+ handler(pending.payload, pending.peerId)
314
+ },
315
+ ]
316
+ },
317
+ registerCleanup,
318
+ /**
319
+ * 房间是否仍处于活跃状态。
320
+ * @returns {boolean} 是否活跃
321
+ */
322
+ isActive() { return active },
323
+ }
324
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 群级 ICE/TURN 配置解析(groupSettings.iceServers → RTCPeerConnection / Trystero)。
3
+ */
4
+
5
+ const ICE_URL_RE = /^(stun|turn|turns):/iu
6
+ const MAX_ICE_SERVERS = 8
7
+
8
+ /** @type {{ urls: string, username?: string, credential?: string }[]} */
9
+ export const DEFAULT_ICE_SERVERS = [
10
+ { urls: 'stun:stun.l.google.com:19302' },
11
+ ]
12
+
13
+ /**
14
+ * @param {unknown} raw 单条 ICE 配置
15
+ * @returns {{ urls: string, username?: string, credential?: string } | null} 合法条目或 null
16
+ */
17
+ function normalizeIceEntry(raw) {
18
+ if (!raw || typeof raw !== 'object') return null
19
+ const urlsRaw = raw.urls
20
+ const urlsList = Array.isArray(urlsRaw)
21
+ ? urlsRaw.map(u => String(u).trim()).filter(Boolean)
22
+ : [String(urlsRaw || '').trim()].filter(Boolean)
23
+ if (!urlsList.length) return null
24
+ for (const u of urlsList)
25
+ if (!ICE_URL_RE.test(u)) return null
26
+ const username = raw.username != null ? String(raw.username) : undefined
27
+ const credential = raw.credential != null ? String(raw.credential) : undefined
28
+ if ((username && !credential) || (!username && credential)) return null
29
+ return {
30
+ urls: urlsList.length === 1 ? urlsList[0] : urlsList,
31
+ ...username ? { username, credential } : {},
32
+ }
33
+ }
34
+
35
+ /**
36
+ * @param {unknown} groupSettings 物化群设置
37
+ * @returns {{ urls: string, username?: string, credential?: string }[]} 合法 ICE 列表
38
+ */
39
+ export function resolveIceServers(groupSettings) {
40
+ const fromSettings = Array.isArray(groupSettings?.iceServers) ? groupSettings.iceServers : []
41
+ const out = []
42
+ for (const raw of fromSettings) {
43
+ const entry = normalizeIceEntry(raw)
44
+ if (entry) out.push(entry)
45
+ if (out.length >= MAX_ICE_SERVERS) break
46
+ }
47
+ return out.length ? out : [...DEFAULT_ICE_SERVERS]
48
+ }
49
+
50
+ /**
51
+ * @param {unknown} groupSettings 物化群设置
52
+ * @returns {{ iceServers: ReturnType<typeof resolveIceServers> }} Trystero rtcConfig 片段
53
+ */
54
+ export function resolveIceServersForTrystero(groupSettings) {
55
+ return { iceServers: resolveIceServers(groupSettings) }
56
+ }
57
+
58
+ /**
59
+ * 校验并规范化待写入 DAG 的 iceServers 数组。
60
+ * @param {unknown} raw 请求体字段
61
+ * @returns {{ urls: string, username?: string, credential?: string }[]} 校验后的 ICE 列表
62
+ */
63
+ export function sanitizeIceServersForSettings(raw) {
64
+ if (!Array.isArray(raw)) return [...DEFAULT_ICE_SERVERS]
65
+ const out = []
66
+ for (const item of raw) {
67
+ const entry = normalizeIceEntry(item)
68
+ if (entry) out.push(entry)
69
+ if (out.length >= MAX_ICE_SERVERS) break
70
+ }
71
+ return out.length ? out : [...DEFAULT_ICE_SERVERS]
72
+ }