@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,44 @@
1
+ /** @typedef {{ query?: { locales?: string | string[] } }} ExpressLikeRequest */
2
+
3
+ /** @type {((replicaUsername: string, entityHash: string, locales: string[]) => Promise<object>) | null} */
4
+ let infoDefaultsProvider = null
5
+
6
+ /** @type {((req: ExpressLikeRequest, replicaUsername: string) => string[]) | null} */
7
+ let localesFromRequestProvider = null
8
+
9
+ /**
10
+ * @param {(replicaUsername: string, entityHash: string, locales: string[]) => Promise<object>} provider 资料默认值解析器
11
+ * @returns {void}
12
+ */
13
+ export function registerEntityPresentationProvider(provider) {
14
+ infoDefaultsProvider = provider
15
+ }
16
+
17
+ /**
18
+ * @param {(req: ExpressLikeRequest, replicaUsername: string) => string[]} provider 请求区域设置解析器
19
+ * @returns {void}
20
+ */
21
+ export function registerLocalesFromRequestProvider(provider) {
22
+ localesFromRequestProvider = provider
23
+ }
24
+
25
+ /**
26
+ * @param {string} replicaUsername 查看者 replica
27
+ * @param {string} entityHash 目标实体
28
+ * @param {string[]} locales 区域设置优先级
29
+ * @returns {Promise<object | null>} 展示默认值或 null
30
+ */
31
+ export async function resolveInfoDefaultsForEntity(replicaUsername, entityHash, locales) {
32
+ if (!infoDefaultsProvider) return null
33
+ return infoDefaultsProvider(replicaUsername, entityHash, locales)
34
+ }
35
+
36
+ /**
37
+ * @param {ExpressLikeRequest} req HTTP 请求
38
+ * @param {string} replicaUsername 查看者 replica
39
+ * @returns {string[]} 区域设置列表
40
+ */
41
+ export function localesFromRequest(req, replicaUsername) {
42
+ if (localesFromRequestProvider) return localesFromRequestProvider(req, replicaUsername)
43
+ return ['zh-CN', 'en-UK']
44
+ }
@@ -0,0 +1,256 @@
1
+ import { parseEntityHash } from '../core/entity_id.mjs'
2
+ import { getEntityStore } from '../node/instance.mjs'
3
+
4
+ import { profileAvatarFileUrl } from './files/url.mjs'
5
+ import {
6
+ applyAvatarToAllLocales,
7
+ normalizeLocalizedMap,
8
+ resolveProfilePresentation,
9
+ } from './localized_core.mjs'
10
+ import { resolveInfoDefaultsForEntity } from './presentation_registry.mjs'
11
+ import { isWritableLocalEntity } from './replica.mjs'
12
+
13
+ /** 超过该毫秒未心跳则视为离线 */
14
+ const HEARTBEAT_STALE_MS = 120_000
15
+
16
+ const MANUAL_STATUSES = new Set(['online', 'idle', 'dnd', 'invisible', 'away', 'busy', 'offline'])
17
+ const PROFILE_JSON = 'profile.json'
18
+
19
+ /**
20
+ * @param {string} entityHash 128 位 entityHash
21
+ * @param {{ nodeHash: string, subjectHash: string }} parsed parseEntityHash 结果
22
+ * @returns {object} 默认资料
23
+ */
24
+ function getDefaultProfile(entityHash, parsed) {
25
+ return {
26
+ entityHash,
27
+ nodeHash: parsed.nodeHash,
28
+ subjectHash: parsed.subjectHash,
29
+ localized: {},
30
+ status: 'online',
31
+ customStatus: '',
32
+ lastSeenAt: 0,
33
+ stats: {
34
+ joinedAt: Date.now(),
35
+ messageCount: 0,
36
+ groupCount: 0,
37
+ channelCount: 0,
38
+ },
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {object} profileData 原始对象
44
+ * @returns {object} 可写入磁盘的资料
45
+ */
46
+ function toStoredProfile(profileData) {
47
+ return {
48
+ entityHash: profileData.entityHash,
49
+ nodeHash: profileData.nodeHash,
50
+ subjectHash: profileData.subjectHash,
51
+ localized: normalizeLocalizedMap(profileData.localized),
52
+ status: profileData.status || 'online',
53
+ customStatus: String(profileData.customStatus || '').trim(),
54
+ lastSeenAt: profileData.lastSeenAt || 0,
55
+ stats: {
56
+ joinedAt: profileData.stats?.joinedAt || Date.now(),
57
+ messageCount: profileData.stats?.messageCount || 0,
58
+ groupCount: profileData.stats?.groupCount || 0,
59
+ channelCount: profileData.stats?.channelCount || 0,
60
+ },
61
+ }
62
+ }
63
+
64
+ /**
65
+ * @param {object} profile 用户资料
66
+ * @param {string} [viewerEntityHash] 查看者 entityHash
67
+ * @param {{ isSelf?: boolean }} [options] isSelf 为 true 时隐身对本人可见
68
+ * @returns {string} 有效状态
69
+ */
70
+ export function computeEffectiveStatus(profile, viewerEntityHash, options = {}) {
71
+ const stored = String(profile?.status || 'online')
72
+ const isSelf = options.isSelf
73
+ ?? (viewerEntityHash && profile?.entityHash === viewerEntityHash)
74
+ const lastSeen = profile?.lastSeenAt || 0
75
+ const recentlySeen = lastSeen > 0 && Date.now() - lastSeen < HEARTBEAT_STALE_MS
76
+
77
+ if (stored === 'invisible')
78
+ return isSelf ? 'invisible' : 'offline'
79
+
80
+ if (!recentlySeen)
81
+ return 'offline'
82
+
83
+ return stored
84
+ }
85
+
86
+ /**
87
+ * @param {string} entityHash 128 位 entityHash
88
+ * @param {string | null} [replicaUsername] 展示默认字段用
89
+ * @param {{ groupId?: string, skipPresentation?: boolean, locales?: string[], infoDefaults?: object }} [options] 选项
90
+ * @returns {Promise<object>} 资料对象
91
+ */
92
+ export async function getProfile(entityHash, replicaUsername = null, options = {}) {
93
+ const parsed = parseEntityHash(entityHash)
94
+ if (!parsed) throw new Error('invalid entityHash')
95
+
96
+ const store = getEntityStore()
97
+ const defaultProfile = getDefaultProfile(parsed.entityHash, parsed)
98
+ let stored = defaultProfile
99
+
100
+ const onDisk = await store.readEntityJson(parsed.entityHash, PROFILE_JSON)
101
+ if (onDisk)
102
+ stored = toStoredProfile({ ...defaultProfile, ...onDisk })
103
+ else if (isWritableLocalEntity(parsed.entityHash))
104
+ await store.writeEntityJson(parsed.entityHash, PROFILE_JSON, stored)
105
+
106
+ const locales = options.locales || ['zh-CN', 'en-UK']
107
+ const merged = {
108
+ ...stored,
109
+ entityHash: parsed.entityHash,
110
+ nodeHash: parsed.nodeHash,
111
+ subjectHash: parsed.subjectHash,
112
+ }
113
+
114
+ if (options.skipPresentation) return merged
115
+
116
+ let infoDefaults = options.infoDefaults
117
+ if (!infoDefaults && replicaUsername)
118
+ infoDefaults = await resolveInfoDefaultsForEntity(replicaUsername, parsed.entityHash, locales)
119
+ if (!infoDefaults)
120
+ infoDefaults = { name: `${parsed.subjectHash.slice(0, 8)}…${parsed.subjectHash.slice(-4)}`, avatar: '', description: '', description_markdown: '', version: '', author: '', home_page: '', issue_page: '', tags: [], links: [] }
121
+
122
+ const resolved = resolveProfilePresentation(merged, locales, infoDefaults)
123
+ return {
124
+ ...merged,
125
+ ...resolved,
126
+ infoDefaults,
127
+ localeKeys: Object.keys(merged.localized),
128
+ }
129
+ }
130
+
131
+ /**
132
+ * @param {string} replicaUsername 副本用户名 所有者
133
+ * @param {string} entityHash 128 位 entityHash
134
+ * @returns {Promise<void>}
135
+ */
136
+ export async function recordHeartbeat(replicaUsername, entityHash) {
137
+ void replicaUsername
138
+ const profile = await getProfile(entityHash, null, { skipPresentation: true })
139
+ profile.lastSeenAt = Date.now()
140
+ await getEntityStore().writeEntityJson(entityHash, PROFILE_JSON, toStoredProfile(profile))
141
+ return { lastSeenAt: profile.lastSeenAt }
142
+ }
143
+
144
+ /**
145
+ * @param {string} replicaUsername 副本用户名 所有者
146
+ * @param {string} entityHash 128 位 entityHash
147
+ * @param {object} updates 更新内容
148
+ * @param {{ groupId?: string, skipPresentation?: boolean, locales?: string[] }} [options] 选项
149
+ * @returns {Promise<object>} 更新后的资料
150
+ */
151
+ export async function updateProfile(replicaUsername, entityHash, updates, options = {}) {
152
+ if (!isWritableLocalEntity(entityHash))
153
+ throw new Error('entity not writable on this replica')
154
+
155
+ const profile = await getProfile(entityHash, replicaUsername, {
156
+ groupId: options.groupId,
157
+ skipPresentation: true,
158
+ })
159
+ const parsed = parseEntityHash(entityHash)
160
+
161
+ const localized = updates.localized != null
162
+ ? normalizeLocalizedMap(updates.localized)
163
+ : profile.localized
164
+
165
+ const updatedProfile = toStoredProfile({
166
+ ...profile,
167
+ entityHash: parsed.entityHash,
168
+ nodeHash: parsed.nodeHash,
169
+ subjectHash: parsed.subjectHash,
170
+ localized,
171
+ status: updates.status != null ? updates.status : profile.status,
172
+ customStatus: updates.customStatus != null ? updates.customStatus : profile.customStatus,
173
+ lastSeenAt: updates.lastSeenAt != null ? updates.lastSeenAt : profile.lastSeenAt,
174
+ stats: updates.stats ? { ...profile.stats, ...updates.stats } : profile.stats,
175
+ })
176
+
177
+ await getEntityStore().writeEntityJson(entityHash, PROFILE_JSON, updatedProfile)
178
+ if (options.skipPresentation) return updatedProfile
179
+ const locales = options.locales || ['zh-CN', 'en-UK']
180
+ const infoDefaults = await resolveInfoDefaultsForEntity(replicaUsername, entityHash, locales)
181
+ const resolved = resolveProfilePresentation(updatedProfile, locales, infoDefaults)
182
+ return { ...updatedProfile, ...resolved, infoDefaults, localeKeys: Object.keys(updatedProfile.localized) }
183
+ }
184
+
185
+ /**
186
+ * @param {string} replicaUsername 副本用户名 所有者
187
+ * @param {string} entityHash 128 位 entityHash
188
+ * @param {Buffer} fileBuffer 文件缓冲区
189
+ * @param {string} filename 文件名
190
+ * @returns {Promise<string>} 头像 URL
191
+ */
192
+ export async function uploadAvatar(replicaUsername, entityHash, fileBuffer, filename) {
193
+ if (!isWritableLocalEntity(entityHash))
194
+ throw new Error('entity not writable on this replica')
195
+
196
+ const { putFileManifest } = await import('./files/evfs.mjs')
197
+ await putFileManifest({
198
+ ownerEntityHash: entityHash,
199
+ logicalPath: 'profile/avatar',
200
+ plaintext: fileBuffer,
201
+ name: filename || 'avatar',
202
+ mimeType: 'image/png',
203
+ ceMode: 'convergent',
204
+ })
205
+
206
+ const avatarUrl = profileAvatarFileUrl(entityHash)
207
+ const profile = await getProfile(entityHash, replicaUsername, { skipPresentation: true })
208
+ await updateProfile(replicaUsername, entityHash, {
209
+ localized: applyAvatarToAllLocales(profile.localized, avatarUrl),
210
+ }, { skipPresentation: true })
211
+ return avatarUrl
212
+ }
213
+
214
+ /**
215
+ * @param {string} entityHash 128 位 entityHash
216
+ * @returns {Promise<object>} 统计字段
217
+ */
218
+ export async function getStats(entityHash) {
219
+ const profile = await getProfile(entityHash)
220
+ return profile.stats
221
+ }
222
+
223
+ /**
224
+ * @param {string} replicaUsername 副本用户名 所有者
225
+ * @param {string} entityHash 128 位 entityHash
226
+ * @param {string} status 状态
227
+ * @param {string} [customStatus] 自定义状态
228
+ * @returns {Promise<{ status: string, customStatus: string, lastSeenAt: number }>} 更新后的状态字段
229
+ */
230
+ export async function updateStatus(replicaUsername, entityHash, status, customStatus = '') {
231
+ if (!MANUAL_STATUSES.has(status))
232
+ throw new Error('invalid status')
233
+ const updated = await updateProfile(replicaUsername, entityHash, {
234
+ status,
235
+ customStatus,
236
+ lastSeenAt: Date.now(),
237
+ }, { skipPresentation: true })
238
+ return {
239
+ status: updated.status,
240
+ customStatus: updated.customStatus,
241
+ lastSeenAt: updated.lastSeenAt,
242
+ }
243
+ }
244
+
245
+ /**
246
+ * 确保本节点操作者实体目录存在。
247
+ * @param {string} replicaUsername 副本用户名 所有者
248
+ * @param {string} entityHash 128 位 entityHash
249
+ * @returns {Promise<object>} 本节点实体资料
250
+ */
251
+ export async function ensureLocalEntityProfile(replicaUsername, entityHash) {
252
+ void replicaUsername
253
+ if (!isWritableLocalEntity(entityHash))
254
+ throw new Error('entity not on local node')
255
+ return getProfile(entityHash, replicaUsername, { skipPresentation: true })
256
+ }
@@ -0,0 +1,20 @@
1
+ import { parseEntityHash } from '../core/entity_id.mjs'
2
+ import { operatorEntityHashFromKeys, getNodeHash } from '../node/identity.mjs'
3
+
4
+ /**
5
+ * @param {string} recoveryPubKeyHex 64 位十六进制 recovery 公钥
6
+ * @returns {string | null} 本节点操作者 entityHash
7
+ */
8
+ export function resolveLocalOperatorEntityHash(recoveryPubKeyHex) {
9
+ return operatorEntityHashFromKeys(getNodeHash(), recoveryPubKeyHex)
10
+ }
11
+
12
+ /**
13
+ * @param {string} entityHash 目标 entityHash
14
+ * @returns {boolean} 是否为本节点可写实体
15
+ */
16
+ export function isWritableLocalEntity(entityHash) {
17
+ const parsed = parseEntityHash(entityHash)
18
+ if (!parsed) return false
19
+ return parsed.nodeHash === getNodeHash()
20
+ }
@@ -0,0 +1,38 @@
1
+ /** @type {Map<string, (replicaUsername: string, groupId: string) => Promise<object | null>>} */
2
+ const providersByOwner = new Map()
3
+
4
+ /**
5
+ * @param {string} ownerId 注册方
6
+ * @param {(replicaUsername: string, groupId: string) => Promise<object | null>} provider 物化会话
7
+ * @returns {void}
8
+ */
9
+ export function registerMaterializedSessionProvider(ownerId, provider) {
10
+ providersByOwner.set(String(ownerId), provider)
11
+ }
12
+
13
+ /**
14
+ * @param {string} ownerId 注册方
15
+ * @returns {void}
16
+ */
17
+ export function unregisterMaterializedSessionProvider(ownerId) {
18
+ providersByOwner.delete(String(ownerId))
19
+ }
20
+
21
+ /**
22
+ * @param {string} replicaUsername 副本用户名
23
+ * @param {string} groupId 群 ID
24
+ * @param {string} [ownerId] 注册方 id;省略时遍历所有 provider
25
+ * @returns {Promise<object | null>} 物化会话
26
+ */
27
+ export async function getMaterializedSession(replicaUsername, groupId, ownerId) {
28
+ if (ownerId != null) {
29
+ const provider = providersByOwner.get(String(ownerId))
30
+ if (!provider) return null
31
+ return provider(replicaUsername, groupId)
32
+ }
33
+ for (const provider of providersByOwner.values()) {
34
+ const session = await provider(replicaUsername, groupId)
35
+ if (session) return session
36
+ }
37
+ return null
38
+ }
@@ -0,0 +1,128 @@
1
+ import { b64ToU8 } from '../core/bytes_codec.mjs'
2
+ import { verifiedChunkBytes } from '../files/chunk_fetch_verify.mjs'
3
+
4
+ /** @type {Map<string, { expectedHash: string, timer: ReturnType<typeof setTimeout>, resolve: (v: Uint8Array | null) => void, reject?: (e: Error) => void }>} */
5
+ export const pendingChunkFetches = new Map()
6
+
7
+ /**
8
+ *
9
+ */
10
+ export const MAX_PENDING_CHUNK_FETCHES = 2048
11
+
12
+ /**
13
+ * 注册 chunk 拉取等待槽(requestId 或 compositeKey)。
14
+ * @param {string} key 唯一等待键
15
+ * @param {string} expectedHash 期望 64 hex 密文哈希
16
+ * @param {number} timeoutMs 超时毫秒
17
+ * @param {{ rejectOnTimeout?: boolean }} [opts] rejectOnTimeout 时 Promise 以 Error 拒绝
18
+ * @returns {{ done: Promise<Uint8Array | null>, cancel: () => void }} 等待 Promise 与取消函数
19
+ */
20
+ export function registerChunkFetchWait(key, expectedHash, timeoutMs, opts = {}) {
21
+ if (!key || pendingChunkFetches.size >= MAX_PENDING_CHUNK_FETCHES)
22
+ return {
23
+ done: Promise.resolve(null), /**
24
+ *
25
+ */
26
+ cancel: () => { }
27
+ }
28
+
29
+ /** @type {(value: Uint8Array | null | Error) => void} */
30
+ let settle
31
+ const done = new Promise((resolve, reject) => {
32
+ /**
33
+ * @param {Uint8Array | null | Error} value 块数据或错误
34
+ * @returns {void}
35
+ */
36
+ function settleWait(value) {
37
+ if (value instanceof Error) reject(value)
38
+ else resolve(value)
39
+ }
40
+ settle = settleWait
41
+ })
42
+ const timer = setTimeout(() => {
43
+ pendingChunkFetches.delete(key)
44
+ if (opts.rejectOnTimeout)
45
+ settle(new Error('chunk fetch timeout'))
46
+ else
47
+ settle(null)
48
+ }, timeoutMs)
49
+
50
+ /**
51
+ * @param {Uint8Array | null} data 块数据
52
+ * @returns {void}
53
+ */
54
+ function finish(data) {
55
+ clearTimeout(timer)
56
+ pendingChunkFetches.delete(key)
57
+ settle(data)
58
+ }
59
+
60
+ /**
61
+ * @param {Error | unknown} err 拒绝原因
62
+ * @returns {void}
63
+ */
64
+ function rejectWait(err) {
65
+ finish(err instanceof Error ? err : new Error(String(err)))
66
+ }
67
+
68
+ pendingChunkFetches.set(key, {
69
+ expectedHash,
70
+ timer,
71
+ resolve: finish,
72
+ reject: rejectWait,
73
+ })
74
+
75
+ return {
76
+ done,
77
+ /**
78
+ *
79
+ */
80
+ cancel: () => {
81
+ clearTimeout(timer)
82
+ pendingChunkFetches.delete(key)
83
+ settle(null)
84
+ },
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 按等待键解析入站 chunk 响应(校验哈希后 resolve)。
90
+ * @param {string} key 等待键
91
+ * @param {string} expectedHash 期望哈希
92
+ * @param {Uint8Array | null} bytes 密文块
93
+ * @returns {boolean} 是否命中并完成等待
94
+ */
95
+ export function resolveChunkFetchWait(key, expectedHash, bytes) {
96
+ const entry = pendingChunkFetches.get(key)
97
+ if (!entry || entry.expectedHash !== expectedHash) return false
98
+ const verified = bytes ? verifiedChunkBytes(expectedHash, bytes) : null
99
+ if (bytes && !verified) return false
100
+ clearTimeout(entry.timer)
101
+ pendingChunkFetches.delete(key)
102
+ entry.resolve(verified)
103
+ return true
104
+ }
105
+
106
+ /**
107
+ * 处理 fed_chunk_data / 带 requestId 的响应载荷。
108
+ * @param {object} payload 入站载荷
109
+ * @returns {boolean} 是否命中 pending
110
+ */
111
+ export function resolvePendingChunkFetch(payload) {
112
+ const requestId = String(payload?.requestId || '')
113
+ if (!requestId) return false
114
+ const entry = pendingChunkFetches.get(requestId)
115
+ if (!entry) return false
116
+ if (payload?.dataB64) {
117
+ try {
118
+ const bytes = b64ToU8(String(payload.dataB64))
119
+ return resolveChunkFetchWait(requestId, entry.expectedHash, bytes)
120
+ }
121
+ catch { /* keep waiting */ }
122
+ return false
123
+ }
124
+ clearTimeout(entry.timer)
125
+ pendingChunkFetches.delete(requestId)
126
+ entry.resolve(null)
127
+ return true
128
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * 联邦分块拉取调度:按 peer 轮转分配缺失块,支持超时重试与广播兜底。
3
+ */
4
+
5
+ /** @typedef {'pending' | 'inflight' | 'done' | 'failed'} ChunkFetchState */
6
+
7
+ /**
8
+ * @param {string[]} chunkHashes 缺失块哈希(有序)
9
+ * @param {string[]} peerIds 可用 peerId 列表
10
+ * @returns {Map<string, string>} chunkHash → 首选 peerId
11
+ */
12
+ export function assignChunksToPeers(chunkHashes, peerIds) {
13
+ /** @type {Map<string, string>} */
14
+ const out = new Map()
15
+ if (!peerIds.length) return out
16
+ const list = peerIds.filter(Boolean)
17
+ for (let i = 0; i < chunkHashes.length; i++)
18
+ out.set(chunkHashes[i], list[i % list.length])
19
+ return out
20
+ }
21
+
22
+ /**
23
+ * @param {Map<string, { state: ChunkFetchState, peerId?: string, attempts: number }>} table 状态表
24
+ * @param {string[]} chunkHashes 待拉取块
25
+ * @param {string[]} peerIds 可用 peer
26
+ * @param {{ maxAttempts?: number }} [opts] 选项
27
+ * @returns {{ assignments: Map<string, string>, broadcast: string[] }} 分配与需广播块
28
+ */
29
+ export function planChunkFetches(table, chunkHashes, peerIds, opts = {}) {
30
+ const maxAttempts = Math.max(1, Number(opts.maxAttempts) || 3)
31
+ const assignments = assignChunksToPeers(chunkHashes, peerIds)
32
+ /** @type {string[]} */
33
+ const broadcast = []
34
+ for (const hash of chunkHashes) {
35
+ const row = table.get(hash) || { state: 'pending', attempts: 0 }
36
+ if (row.state === 'done') continue
37
+ if (row.state === 'inflight') continue
38
+ if (row.attempts >= maxAttempts) {
39
+ broadcast.push(hash)
40
+ continue
41
+ }
42
+ const peerId = assignments.get(hash)
43
+ if (!peerId) broadcast.push(hash)
44
+ }
45
+ return { assignments, broadcast }
46
+ }
47
+
48
+ /**
49
+ * @param {Map<string, { state: ChunkFetchState, peerId?: string, attempts: number }>} table 状态表
50
+ * @param {string} chunkHash 块哈希
51
+ * @param {string} peerId 目标 peer
52
+ * @returns {void}
53
+ */
54
+ export function markChunkInflight(table, chunkHash, peerId) {
55
+ const prev = table.get(chunkHash) || { state: 'pending', attempts: 0 }
56
+ table.set(chunkHash, {
57
+ state: 'inflight',
58
+ peerId,
59
+ attempts: prev.attempts + 1,
60
+ })
61
+ }
62
+
63
+ /**
64
+ * @param {Map<string, { state: ChunkFetchState, peerId?: string, attempts: number }>} table 状态表
65
+ * @param {string} chunkHash 块哈希
66
+ * @returns {void}
67
+ */
68
+ export function markChunkDone(table, chunkHash) {
69
+ table.set(chunkHash, { state: 'done', attempts: 0 })
70
+ }
71
+
72
+ /**
73
+ * @param {Map<string, { state: ChunkFetchState, peerId?: string, attempts: number }>} table 状态表
74
+ * @param {string} chunkHash 块哈希
75
+ * @returns {void}
76
+ */
77
+ export function markChunkFailed(table, chunkHash) {
78
+ const prev = table.get(chunkHash) || { state: 'pending', attempts: 0 }
79
+ table.set(chunkHash, { state: 'failed', attempts: prev.attempts })
80
+ }
81
+
82
+ /**
83
+ * @param {Map<string, { state: ChunkFetchState, peerId?: string, attempts: number }>} table 状态表
84
+ * @returns {{ done: number, pending: number, inflight: number, failed: number, total: number }} 进度
85
+ */
86
+ export function chunkFetchProgress(table) {
87
+ let done = 0
88
+ let pending = 0
89
+ let inflight = 0
90
+ let failed = 0
91
+ for (const row of table.values())
92
+ if (row.state === 'done') done++
93
+ else if (row.state === 'inflight') inflight++
94
+ else if (row.state === 'failed') failed++
95
+ else pending++
96
+ return { done, pending, inflight, failed, total: table.size }
97
+ }