@beam-network/sdk 0.2.16 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/client.d.ts +6 -3
- package/dist/client.js +240 -403
- package/dist/models.d.ts +1 -39
- package/dist/nats-control.d.ts +37 -1
- package/dist/nats-control.js +90 -7
- package/dist/provider-signing.d.ts +0 -16
- package/dist/provider-signing.js +0 -301
- package/package.json +36 -36
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortMultipartUpload,
|
|
1
|
+
import { abortMultipartUpload, createMultipartUpload, nowIso, prepareProviderDestination, prepareProviderSource, listPartsPageMarker, listPartsVerifyMarker, signAbortMultipartUpload, signCompleteMultipartUpload, signDeleteObject, signDestinationRoute, signListMultipartUpload, signListObjects, signUploadPartCopy } from "./provider-signing.js";
|
|
2
2
|
import { BeamTransferControl, BEAM_DEFAULT_NATS_URL } from "./nats-control.js";
|
|
3
3
|
export { BEAM_DEFAULT_NATS_URL };
|
|
4
4
|
export class BeamApiError extends Error {
|
|
@@ -16,6 +16,7 @@ export class BeamClient {
|
|
|
16
16
|
natsUrl;
|
|
17
17
|
fetchImpl;
|
|
18
18
|
control;
|
|
19
|
+
recoverySigners = new Map();
|
|
19
20
|
constructor(options = {}) {
|
|
20
21
|
if (!options.apiKey?.trim()) {
|
|
21
22
|
throw new Error("apiKey is required.");
|
|
@@ -37,7 +38,8 @@ export class BeamClient {
|
|
|
37
38
|
throw new Error("A fetch implementation is required.");
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
|
-
close() {
|
|
41
|
+
async close() {
|
|
42
|
+
this.stopAllRecoverySigners();
|
|
41
43
|
return this.control.close();
|
|
42
44
|
}
|
|
43
45
|
createRawTransfer(input) {
|
|
@@ -52,7 +54,6 @@ export class BeamClient {
|
|
|
52
54
|
chunk_hashes: input.chunkHashes,
|
|
53
55
|
callbacks: input.callbacks,
|
|
54
56
|
test_mode: input.testMode || undefined,
|
|
55
|
-
experimental: input.experimental || undefined,
|
|
56
57
|
progressive_mode: input.progressiveMode || undefined,
|
|
57
58
|
signed_url_flow: input.signedUrlFlow
|
|
58
59
|
});
|
|
@@ -69,9 +70,14 @@ export class BeamClient {
|
|
|
69
70
|
validateId(transferId, "transferId");
|
|
70
71
|
return this.control.request("transfer.distribute", { transfer_id: transferId }, transferId);
|
|
71
72
|
}
|
|
72
|
-
cancelTransfer(transferId) {
|
|
73
|
+
async cancelTransfer(transferId) {
|
|
73
74
|
validateId(transferId, "transferId");
|
|
74
|
-
|
|
75
|
+
try {
|
|
76
|
+
return await this.control.request("transfer.cancel", { transfer_id: transferId }, transferId);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
this.stopRecoverySigner(transferId);
|
|
80
|
+
}
|
|
75
81
|
}
|
|
76
82
|
planTransfer(input) {
|
|
77
83
|
return this.control.request("transfer.plan", compact({
|
|
@@ -79,7 +85,6 @@ export class BeamClient {
|
|
|
79
85
|
destinations: input.destinations,
|
|
80
86
|
name: input.name,
|
|
81
87
|
test_mode: input.testMode || undefined,
|
|
82
|
-
experimental: input.experimental || undefined,
|
|
83
88
|
urls_expires_at: input.urlsExpiresAt,
|
|
84
89
|
signed_url_flow: input.signedUrlFlow
|
|
85
90
|
}));
|
|
@@ -92,36 +97,25 @@ export class BeamClient {
|
|
|
92
97
|
destinations: input.destinations,
|
|
93
98
|
name: input.name,
|
|
94
99
|
test_mode: input.testMode || undefined,
|
|
95
|
-
experimental: input.experimental || undefined,
|
|
96
100
|
urls_expires_at: input.urlsExpiresAt,
|
|
97
101
|
signed_url_flow: input.signedUrlFlow
|
|
98
102
|
}), transferId);
|
|
99
103
|
}
|
|
100
104
|
async attachSignedUrls(transferId, input) {
|
|
101
105
|
validateId(transferId, "transferId");
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
|
|
114
|
-
await this.control.request("transfer.attach_signed_urls.chunk", {
|
|
115
|
-
transfer_id: transferId,
|
|
116
|
-
attachment_id: attachmentId,
|
|
117
|
-
chunk_index: chunkIndex,
|
|
118
|
-
chunk_routes: chunks[chunkIndex]
|
|
119
|
-
}, transferId);
|
|
106
|
+
const stream = new RouteStreamSender(this.control, {
|
|
107
|
+
transferId,
|
|
108
|
+
totalRoutes: input.chunkRoutes.length,
|
|
109
|
+
totalChunks: countDistinctRouteChunks(input.chunkRoutes),
|
|
110
|
+
urlsExpiresAt: input.urlsExpiresAt,
|
|
111
|
+
signedUrlFlow: input.signedUrlFlow ?? "signed_url_v1",
|
|
112
|
+
autoDistribute: input.autoDistribute ?? true
|
|
113
|
+
});
|
|
114
|
+
await stream.begin();
|
|
115
|
+
for (const route of input.chunkRoutes) {
|
|
116
|
+
await stream.addRoute(route);
|
|
120
117
|
}
|
|
121
|
-
return
|
|
122
|
-
transfer_id: transferId,
|
|
123
|
-
attachment_id: attachmentId
|
|
124
|
-
}, transferId);
|
|
118
|
+
return stream.complete();
|
|
125
119
|
}
|
|
126
120
|
async prepareProviderTransfer(input) {
|
|
127
121
|
const expiresIn = input.expiresIn ?? 3600;
|
|
@@ -135,93 +129,19 @@ export class BeamClient {
|
|
|
135
129
|
}
|
|
136
130
|
destinationsById.set(preparedDestination.destination_id, destination);
|
|
137
131
|
});
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
132
|
+
await input.throwIfCancelled?.();
|
|
133
|
+
const preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
|
|
134
|
+
await input.throwIfCancelled?.();
|
|
135
|
+
await input.onBeforeTransferPrepare?.();
|
|
136
|
+
const prepared = await this.prepareTransfer({
|
|
137
|
+
sources: preparedSources,
|
|
138
|
+
destinations: preparedDestinations,
|
|
139
|
+
name: input.name,
|
|
140
|
+
testMode: input.testMode,
|
|
141
|
+
signedUrlFlow: requestedSignedUrlFlow
|
|
147
142
|
});
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (requestedSignedUrlFlow === "signed_url_v1") {
|
|
151
|
-
await input.throwIfCancelled?.();
|
|
152
|
-
const plannedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSourceForPlan(source, { index, fetchImpl: this.fetchImpl })));
|
|
153
|
-
const planned = await this.planTransfer({
|
|
154
|
-
sources: plannedSources,
|
|
155
|
-
destinations: preparedDestinations,
|
|
156
|
-
name: input.name,
|
|
157
|
-
testMode: input.testMode,
|
|
158
|
-
experimental: input.experimental,
|
|
159
|
-
signedUrlFlow: requestedSignedUrlFlow
|
|
160
|
-
});
|
|
161
|
-
if (!planned.success) {
|
|
162
|
-
return failedPlanAsPrepareResponse(planned);
|
|
163
|
-
}
|
|
164
|
-
await input.onPlanned?.(planned);
|
|
165
|
-
await input.throwIfCancelled?.();
|
|
166
|
-
const plannedSourcesById = mapSourcesByPreparedId(plannedSources, input.sources);
|
|
167
|
-
const chunksBySource = checksumChunksBySource(planned.chunk_plan, destinationsById);
|
|
168
|
-
const totalWarmupChunks = [...chunksBySource.values()].reduce((sum, chunks) => sum + chunks.length, 0);
|
|
169
|
-
const totalWarmupBytes = [...chunksBySource.values()].reduce((sum, chunks) => sum + chunks.reduce((chunkSum, chunk) => chunkSum + chunk.chunk_size, 0), 0);
|
|
170
|
-
if (totalWarmupChunks > 0) {
|
|
171
|
-
const startedAt = Date.now();
|
|
172
|
-
await emitChecksumEvent(input, {
|
|
173
|
-
event: "checksum_warmup_started",
|
|
174
|
-
sourceId: "all",
|
|
175
|
-
chunkCount: totalWarmupChunks,
|
|
176
|
-
bytes: totalWarmupBytes
|
|
177
|
-
});
|
|
178
|
-
for (const [sourceId, chunks] of chunksBySource) {
|
|
179
|
-
await input.throwIfCancelled?.();
|
|
180
|
-
const source = plannedSourcesById.get(sourceId);
|
|
181
|
-
if (!source) {
|
|
182
|
-
throw new Error(`BeamCore planned unknown source_id: ${sourceId}`);
|
|
183
|
-
}
|
|
184
|
-
await checksumCache.ensureMany(source, chunks);
|
|
185
|
-
}
|
|
186
|
-
await emitChecksumEvent(input, {
|
|
187
|
-
event: "checksum_warmup_completed",
|
|
188
|
-
sourceId: "all",
|
|
189
|
-
chunkCount: totalWarmupChunks,
|
|
190
|
-
bytes: totalWarmupBytes,
|
|
191
|
-
durationMs: Date.now() - startedAt
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
await input.throwIfCancelled?.();
|
|
195
|
-
checksumCache.seal();
|
|
196
|
-
preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
|
|
197
|
-
await input.onBeforeTransferPrepare?.();
|
|
198
|
-
prepared = await this.prepareTransfer({
|
|
199
|
-
sources: preparedSources,
|
|
200
|
-
destinations: preparedDestinations,
|
|
201
|
-
name: input.name,
|
|
202
|
-
testMode: input.testMode,
|
|
203
|
-
experimental: input.experimental,
|
|
204
|
-
signedUrlFlow: requestedSignedUrlFlow
|
|
205
|
-
});
|
|
206
|
-
if (!prepared.success) {
|
|
207
|
-
return prepared;
|
|
208
|
-
}
|
|
209
|
-
assertPreparedTransferMatchesPlan(planned, prepared);
|
|
210
|
-
}
|
|
211
|
-
else {
|
|
212
|
-
preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
|
|
213
|
-
await input.onBeforeTransferPrepare?.();
|
|
214
|
-
prepared = await this.prepareTransfer({
|
|
215
|
-
sources: preparedSources,
|
|
216
|
-
destinations: preparedDestinations,
|
|
217
|
-
name: input.name,
|
|
218
|
-
testMode: input.testMode,
|
|
219
|
-
experimental: input.experimental,
|
|
220
|
-
signedUrlFlow: requestedSignedUrlFlow
|
|
221
|
-
});
|
|
222
|
-
if (!prepared.success) {
|
|
223
|
-
return prepared;
|
|
224
|
-
}
|
|
143
|
+
if (!prepared.success) {
|
|
144
|
+
return prepared;
|
|
225
145
|
}
|
|
226
146
|
await input.onPrepared?.(prepared);
|
|
227
147
|
await input.throwIfCancelled?.(prepared.transfer_id);
|
|
@@ -234,13 +154,26 @@ export class BeamClient {
|
|
|
234
154
|
sourcesById.set(preparedSource.source_id, source);
|
|
235
155
|
});
|
|
236
156
|
const signedUrlFlow = prepared.signed_url_flow ?? requestedSignedUrlFlow;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
157
|
+
const routeStream = new RouteStreamSender(this.control, {
|
|
158
|
+
transferId: prepared.transfer_id,
|
|
159
|
+
totalRoutes: countRoutesInChunkPlan(prepared.chunk_plan),
|
|
160
|
+
totalChunks: prepared.logical_chunks ?? prepared.total_chunks ?? prepared.chunk_plan.length,
|
|
161
|
+
signedUrlFlow,
|
|
162
|
+
autoDistribute: input.distribute !== false
|
|
163
|
+
});
|
|
242
164
|
const multipartUploads = new Map();
|
|
165
|
+
await this.startProviderRouteRecoverySigner({
|
|
166
|
+
prepared,
|
|
167
|
+
sourcesById,
|
|
168
|
+
destinationsById,
|
|
169
|
+
multipartUploads,
|
|
170
|
+
expiresIn,
|
|
171
|
+
signedUrlFlow
|
|
172
|
+
});
|
|
173
|
+
let routeStreamStarted = false;
|
|
243
174
|
try {
|
|
175
|
+
await routeStream.begin();
|
|
176
|
+
routeStreamStarted = true;
|
|
244
177
|
for (const chunk of prepared.chunk_plan) {
|
|
245
178
|
await input.throwIfCancelled?.(prepared.transfer_id);
|
|
246
179
|
for (const target of chunk.destinations) {
|
|
@@ -290,7 +223,7 @@ export class BeamClient {
|
|
|
290
223
|
}
|
|
291
224
|
multipartUploads.set(multipartKey, upload);
|
|
292
225
|
}
|
|
293
|
-
|
|
226
|
+
await routeStream.addRoute(await this.signProviderRoute({
|
|
294
227
|
chunk,
|
|
295
228
|
target,
|
|
296
229
|
source,
|
|
@@ -301,35 +234,24 @@ export class BeamClient {
|
|
|
301
234
|
finalObjectKey,
|
|
302
235
|
partNumber: typeof metadata.part_number === "number"
|
|
303
236
|
? metadata.part_number
|
|
304
|
-
: chunk.
|
|
305
|
-
signedUrlFlow
|
|
306
|
-
checksumCache
|
|
237
|
+
: chunk.chunk_index * 3 + 1,
|
|
238
|
+
signedUrlFlow
|
|
307
239
|
}));
|
|
308
240
|
}
|
|
309
241
|
}
|
|
310
|
-
|
|
311
|
-
catch (error) {
|
|
312
|
-
await abortCreatedUploads(multipartUploads);
|
|
313
|
-
throw error;
|
|
314
|
-
}
|
|
315
|
-
try {
|
|
316
|
-
await input.throwIfCancelled?.(prepared.transfer_id);
|
|
317
|
-
const attached = await this.attachSignedUrls(prepared.transfer_id, {
|
|
318
|
-
chunkRoutes: routes,
|
|
319
|
-
transferKey: prepared.transfer_key
|
|
320
|
-
});
|
|
242
|
+
const attached = await routeStream.complete();
|
|
321
243
|
if (!attached.success) {
|
|
322
|
-
throw new Error(attached.error ?? attached.message ?? "
|
|
244
|
+
throw new Error(attached.error ?? attached.message ?? "route stream failed");
|
|
323
245
|
}
|
|
324
246
|
}
|
|
325
247
|
catch (error) {
|
|
248
|
+
this.stopRecoverySigner(prepared.transfer_id);
|
|
326
249
|
await abortCreatedUploads(multipartUploads);
|
|
250
|
+
if (routeStreamStarted) {
|
|
251
|
+
await cancelTransferAfterRouteStreamFailure(this, prepared.transfer_id, error);
|
|
252
|
+
}
|
|
327
253
|
throw error;
|
|
328
254
|
}
|
|
329
|
-
if (input.distribute !== false) {
|
|
330
|
-
await input.throwIfCancelled?.(prepared.transfer_id);
|
|
331
|
-
await this.distributeTransfer(prepared.transfer_id);
|
|
332
|
-
}
|
|
333
255
|
return prepared;
|
|
334
256
|
}
|
|
335
257
|
async signProviderRoute(input) {
|
|
@@ -350,7 +272,6 @@ export class BeamClient {
|
|
|
350
272
|
throw new Error("destination signing target is missing object_key");
|
|
351
273
|
}
|
|
352
274
|
if (input.signedUrlFlow === "signed_url_v1") {
|
|
353
|
-
const contentMd5 = await input.checksumCache.get(input.source, input.chunk);
|
|
354
275
|
const partVerifyUrl = await signListMultipartUpload(input.destination, input.finalObjectKey, input.upload.uploadId, input.expiresIn, { maxParts: 1, partNumberMarker: listPartsVerifyMarker(input.partNumber) });
|
|
355
276
|
const listPageUrl = await signListMultipartUpload(input.destination, input.finalObjectKey, input.upload.uploadId, input.expiresIn, { maxParts: 1000, partNumberMarker: listPartsPageMarker(input.partNumber) });
|
|
356
277
|
return signDestinationRoute({
|
|
@@ -367,7 +288,6 @@ export class BeamClient {
|
|
|
367
288
|
listUrl: input.upload.listUrl,
|
|
368
289
|
partVerifyUrl,
|
|
369
290
|
listPageUrl,
|
|
370
|
-
contentMd5,
|
|
371
291
|
transferId: input.transferId,
|
|
372
292
|
multipartGroupId: input.upload.multipartGroupId,
|
|
373
293
|
multipartCreatedAt: input.upload.multipartCreatedAt,
|
|
@@ -414,6 +334,80 @@ export class BeamClient {
|
|
|
414
334
|
fetchImpl: this.fetchImpl
|
|
415
335
|
});
|
|
416
336
|
}
|
|
337
|
+
async startProviderRouteRecoverySigner(input) {
|
|
338
|
+
this.stopRecoverySigner(input.prepared.transfer_id);
|
|
339
|
+
const stop = await this.control.serveRouteRecoverySigner(input.prepared.transfer_id, async (payload) => {
|
|
340
|
+
const routes = [];
|
|
341
|
+
for (const requested of payload.chunks) {
|
|
342
|
+
const chunk = input.prepared.chunk_plan.find((candidate) => candidate.chunk_index === requested.chunk_index && candidate.source_id === requested.source_id);
|
|
343
|
+
if (!chunk) {
|
|
344
|
+
throw new Error(`recovery signing chunk not found: ${requested.source_id}:${requested.chunk_index}`);
|
|
345
|
+
}
|
|
346
|
+
const target = chunk.destinations.find((candidate) => candidate.destination_id === requested.destination_id);
|
|
347
|
+
if (!target) {
|
|
348
|
+
throw new Error(`recovery signing destination not found: ${requested.destination_id}`);
|
|
349
|
+
}
|
|
350
|
+
const source = input.sourcesById.get(requested.source_id);
|
|
351
|
+
const destination = input.destinationsById.get(requested.destination_id);
|
|
352
|
+
if (!source || !destination) {
|
|
353
|
+
throw new Error("recovery signing provider configuration missing");
|
|
354
|
+
}
|
|
355
|
+
const upload = input.multipartUploads.get(requested.multipart_group_id) ?? input.multipartUploads.get(`${input.prepared.transfer_id}:${requested.destination_id}:${requested.source_id}:${requested.final_object_key}`);
|
|
356
|
+
if (!upload) {
|
|
357
|
+
throw new Error(`recovery signing multipart upload state missing for ${requested.multipart_group_id}`);
|
|
358
|
+
}
|
|
359
|
+
const metadata = {
|
|
360
|
+
...(target.metadata ?? {}),
|
|
361
|
+
...(requested.destination_metadata ?? {}),
|
|
362
|
+
transfer_id: input.prepared.transfer_id,
|
|
363
|
+
final_object_key: requested.final_object_key,
|
|
364
|
+
upload_id: requested.upload_id ?? upload.uploadId,
|
|
365
|
+
multipart_group_id: requested.multipart_group_id,
|
|
366
|
+
multipart_created_at: requested.multipart_created_at ?? upload.multipartCreatedAt,
|
|
367
|
+
delivery_index: requested.delivery_index,
|
|
368
|
+
part_number: requested.part_number,
|
|
369
|
+
logical_attempt_index: requested.logical_attempt_index,
|
|
370
|
+
attempt_slot: requested.attempt_slot,
|
|
371
|
+
route_generation_id: requested.route_generation_id
|
|
372
|
+
};
|
|
373
|
+
routes.push(await this.signProviderRoute({
|
|
374
|
+
chunk,
|
|
375
|
+
target: {
|
|
376
|
+
...target,
|
|
377
|
+
object_key: target.object_key ?? requested.final_object_key,
|
|
378
|
+
metadata
|
|
379
|
+
},
|
|
380
|
+
source,
|
|
381
|
+
destination,
|
|
382
|
+
expiresIn: input.expiresIn,
|
|
383
|
+
upload,
|
|
384
|
+
transferId: input.prepared.transfer_id,
|
|
385
|
+
finalObjectKey: requested.final_object_key,
|
|
386
|
+
partNumber: requested.part_number,
|
|
387
|
+
signedUrlFlow: input.signedUrlFlow
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
transfer_id: payload.transfer_id,
|
|
392
|
+
route_generation_id: payload.route_generation_id,
|
|
393
|
+
signed_at: nowIso(),
|
|
394
|
+
chunk_routes: routes
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
this.recoverySigners.set(input.prepared.transfer_id, stop);
|
|
398
|
+
}
|
|
399
|
+
stopRecoverySigner(transferId) {
|
|
400
|
+
const stop = this.recoverySigners.get(transferId);
|
|
401
|
+
if (!stop)
|
|
402
|
+
return;
|
|
403
|
+
stop();
|
|
404
|
+
this.recoverySigners.delete(transferId);
|
|
405
|
+
}
|
|
406
|
+
stopAllRecoverySigners() {
|
|
407
|
+
for (const transferId of [...this.recoverySigners.keys()]) {
|
|
408
|
+
this.stopRecoverySigner(transferId);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
417
411
|
async createAndDistribute(input) {
|
|
418
412
|
const transfer = await this.createRawTransfer(input);
|
|
419
413
|
if (transfer.success) {
|
|
@@ -428,12 +422,15 @@ export class BeamClient {
|
|
|
428
422
|
while (true) {
|
|
429
423
|
const status = await this.transferStatus(transferId);
|
|
430
424
|
if (status.status === "completed") {
|
|
425
|
+
this.stopRecoverySigner(transferId);
|
|
431
426
|
return status;
|
|
432
427
|
}
|
|
433
428
|
if (status.status === "failed") {
|
|
429
|
+
this.stopRecoverySigner(transferId);
|
|
434
430
|
throw new Error(`Transfer failed: ${status.error_message ?? "unknown error"}`);
|
|
435
431
|
}
|
|
436
432
|
if (status.status === "cancelled") {
|
|
433
|
+
this.stopRecoverySigner(transferId);
|
|
437
434
|
throw new Error("Transfer cancelled");
|
|
438
435
|
}
|
|
439
436
|
if (Date.now() - startedAt >= timeoutMs) {
|
|
@@ -443,284 +440,124 @@ export class BeamClient {
|
|
|
443
440
|
}
|
|
444
441
|
}
|
|
445
442
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
return destination ? isS3CompatibleProvider(destination) : false;
|
|
452
|
-
});
|
|
453
|
-
if (!needsChecksum)
|
|
454
|
-
continue;
|
|
455
|
-
const chunks = chunksBySource.get(chunk.source_id) ?? [];
|
|
456
|
-
chunks.push(chunk);
|
|
457
|
-
chunksBySource.set(chunk.source_id, chunks);
|
|
458
|
-
}
|
|
459
|
-
return chunksBySource;
|
|
460
|
-
}
|
|
461
|
-
function mapSourcesByPreparedId(preparedSources, providerSources) {
|
|
462
|
-
const sourcesById = new Map();
|
|
463
|
-
preparedSources.forEach((preparedSource, index) => {
|
|
464
|
-
const source = providerSources[index];
|
|
465
|
-
if (!source) {
|
|
466
|
-
throw new Error(`missing provider source config for planned source ${preparedSource.source_id}`);
|
|
467
|
-
}
|
|
468
|
-
sourcesById.set(preparedSource.source_id, source);
|
|
469
|
-
});
|
|
470
|
-
return sourcesById;
|
|
471
|
-
}
|
|
472
|
-
function failedPlanAsPrepareResponse(plan) {
|
|
473
|
-
return {
|
|
474
|
-
success: false,
|
|
475
|
-
transfer_id: "",
|
|
476
|
-
chunk_size: plan.chunk_size,
|
|
477
|
-
total_size: plan.total_size,
|
|
478
|
-
total_sources: plan.total_sources,
|
|
479
|
-
total_destinations: plan.total_destinations,
|
|
480
|
-
logical_chunks: plan.logical_chunks,
|
|
481
|
-
total_chunks: plan.total_chunks,
|
|
482
|
-
chunk_plan: plan.chunk_plan ?? [],
|
|
483
|
-
signed_url_flow: plan.signed_url_flow,
|
|
484
|
-
error: plan.error,
|
|
485
|
-
message: plan.message
|
|
486
|
-
};
|
|
487
|
-
}
|
|
488
|
-
function assertPreparedTransferMatchesPlan(plan, prepared) {
|
|
489
|
-
const summaryMatches = plan.chunk_size === prepared.chunk_size &&
|
|
490
|
-
plan.total_size === prepared.total_size &&
|
|
491
|
-
plan.total_sources === prepared.total_sources &&
|
|
492
|
-
plan.total_destinations === prepared.total_destinations &&
|
|
493
|
-
plan.logical_chunks === prepared.logical_chunks &&
|
|
494
|
-
plan.total_chunks === prepared.total_chunks;
|
|
495
|
-
if (!summaryMatches || planSignature(plan.chunk_plan) !== planSignature(prepared.chunk_plan)) {
|
|
496
|
-
throw new Error("prepared transfer chunk plan does not match preflight checksum plan");
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
function planSignature(chunks) {
|
|
500
|
-
return JSON.stringify(chunks.map((chunk) => ({
|
|
501
|
-
chunk_index: chunk.chunk_index,
|
|
502
|
-
source_id: chunk.source_id,
|
|
503
|
-
source_chunk_index: chunk.source_chunk_index,
|
|
504
|
-
source_offset: chunk.source_offset,
|
|
505
|
-
chunk_size: chunk.chunk_size,
|
|
506
|
-
destinations: chunk.destinations.map((target) => {
|
|
507
|
-
const metadata = target.metadata ?? {};
|
|
508
|
-
return {
|
|
509
|
-
destination_id: target.destination_id,
|
|
510
|
-
provider: target.provider,
|
|
511
|
-
final_object_key: typeof metadata.final_object_key === "string" ? metadata.final_object_key : target.object_key,
|
|
512
|
-
part_number: typeof metadata.part_number === "number" ? metadata.part_number : undefined,
|
|
513
|
-
delivery_index: typeof metadata.delivery_index === "number" ? metadata.delivery_index : undefined
|
|
514
|
-
};
|
|
515
|
-
})
|
|
516
|
-
})));
|
|
517
|
-
}
|
|
518
|
-
async function emitChecksumEvent(input, event) {
|
|
519
|
-
if (!input.onChecksumEvent)
|
|
520
|
-
return;
|
|
521
|
-
await input.onChecksumEvent(event);
|
|
522
|
-
}
|
|
523
|
-
class ChunkChecksumCache {
|
|
443
|
+
// Target route-stream batches at 512 signed routes. The transport still splits
|
|
444
|
+
// earlier when the encoded NATS request would exceed maxPayloadBytes.
|
|
445
|
+
const ROUTE_STREAM_TARGET_BATCH_ROUTES = 512;
|
|
446
|
+
class RouteStreamSender {
|
|
447
|
+
control;
|
|
524
448
|
options;
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
constructor(options) {
|
|
449
|
+
streamId = newTransferId();
|
|
450
|
+
checksum = new RouteKeysChecksum();
|
|
451
|
+
batch = [];
|
|
452
|
+
batchIndex = 0;
|
|
453
|
+
constructor(control, options) {
|
|
454
|
+
this.control = control;
|
|
530
455
|
this.options = options;
|
|
531
456
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
async ensureMany(source, chunks) {
|
|
549
|
-
const unique = this.uniqueChunks(chunks);
|
|
550
|
-
this.preloadMany(source, unique);
|
|
551
|
-
await Promise.all(unique.map((chunk) => this.get(source, chunk)));
|
|
552
|
-
}
|
|
553
|
-
get(source, chunk) {
|
|
554
|
-
const key = this.key(chunk);
|
|
555
|
-
let existing = this.cache.get(key);
|
|
556
|
-
if (!existing) {
|
|
557
|
-
if (this.sealed) {
|
|
558
|
-
return Promise.reject(new Error(`checksum cache missing ${key} after preflight warmup; refusing post-prepare source read`));
|
|
559
|
-
}
|
|
560
|
-
existing = deferredChecksum();
|
|
561
|
-
this.cache.set(key, existing);
|
|
562
|
-
void this.run(() => this.fillGroup(source, [chunk])).catch(() => undefined);
|
|
563
|
-
}
|
|
564
|
-
return existing.promise;
|
|
565
|
-
}
|
|
566
|
-
async fillGroup(source, chunks) {
|
|
567
|
-
const unresolved = new Set(chunks.map((chunk) => this.key(chunk)));
|
|
568
|
-
try {
|
|
569
|
-
await this.options.throwIfCancelled?.();
|
|
570
|
-
const misses = [];
|
|
571
|
-
for (const chunk of chunks) {
|
|
572
|
-
const key = this.key(chunk);
|
|
573
|
-
const deferred = this.cache.get(key);
|
|
574
|
-
if (!deferred)
|
|
575
|
-
continue;
|
|
576
|
-
const cached = await this.options.provider?.getContentMd5({ source, chunk, algorithm: "content-md5" }).catch(() => undefined);
|
|
577
|
-
if (cached) {
|
|
578
|
-
await this.emit({ event: "checksum_cache_hit", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
|
|
579
|
-
deferred.resolve(cached);
|
|
580
|
-
unresolved.delete(key);
|
|
581
|
-
}
|
|
582
|
-
else {
|
|
583
|
-
await this.emit({ event: "checksum_cache_miss", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
|
|
584
|
-
misses.push(chunk);
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
if (!misses.length)
|
|
588
|
-
return;
|
|
589
|
-
await this.options.throwIfCancelled?.();
|
|
590
|
-
const startedAt = Date.now();
|
|
591
|
-
const checksums = await computeChunksContentMd5({
|
|
592
|
-
source,
|
|
593
|
-
chunks: misses,
|
|
594
|
-
expiresIn: this.options.expiresIn,
|
|
595
|
-
fetchImpl: this.options.fetchImpl,
|
|
596
|
-
groupSizeBytes: this.options.groupSizeBytes,
|
|
597
|
-
readTimeoutMs: this.options.readTimeoutMs
|
|
598
|
-
});
|
|
599
|
-
for (const chunk of misses) {
|
|
600
|
-
await this.options.throwIfCancelled?.();
|
|
601
|
-
const checksum = checksums.get(chunk.source_chunk_index);
|
|
602
|
-
if (!checksum)
|
|
603
|
-
throw new Error(`checksum missing for source chunk ${chunk.source_chunk_index}`);
|
|
604
|
-
await this.options.provider?.setContentMd5?.({ source, chunk, algorithm: "content-md5", contentMd5: checksum });
|
|
605
|
-
const deferred = this.cache.get(this.key(chunk));
|
|
606
|
-
deferred?.resolve(checksum);
|
|
607
|
-
unresolved.delete(this.key(chunk));
|
|
608
|
-
await this.emit({ event: "checksum_cold_chunk_cached", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
|
|
609
|
-
}
|
|
610
|
-
await this.emit({
|
|
611
|
-
event: "checksum_cold_group_completed",
|
|
612
|
-
sourceId: misses[0]?.source_id ?? source.id ?? "source",
|
|
613
|
-
chunkCount: misses.length,
|
|
614
|
-
bytes: misses.reduce((sum, chunk) => sum + chunk.chunk_size, 0),
|
|
615
|
-
durationMs: Date.now() - startedAt
|
|
616
|
-
});
|
|
617
|
-
}
|
|
618
|
-
catch (error) {
|
|
619
|
-
for (const key of unresolved) {
|
|
620
|
-
this.cache.get(key)?.reject(error);
|
|
621
|
-
}
|
|
622
|
-
throw error;
|
|
457
|
+
async begin() {
|
|
458
|
+
await this.control.request("transfer.route_stream.begin", compact({
|
|
459
|
+
transfer_id: this.options.transferId,
|
|
460
|
+
stream_id: this.streamId,
|
|
461
|
+
total_routes: this.options.totalRoutes,
|
|
462
|
+
total_chunks: this.options.totalChunks,
|
|
463
|
+
urls_expires_at: this.options.urlsExpiresAt,
|
|
464
|
+
signed_url_flow: this.options.signedUrlFlow,
|
|
465
|
+
auto_distribute: this.options.autoDistribute
|
|
466
|
+
}), this.options.transferId);
|
|
467
|
+
}
|
|
468
|
+
async addRoute(route) {
|
|
469
|
+
await this.checksum.add(routeKeyForSignedRoute(route));
|
|
470
|
+
this.batch.push(route);
|
|
471
|
+
if (this.batch.length >= ROUTE_STREAM_TARGET_BATCH_ROUTES) {
|
|
472
|
+
await this.flush();
|
|
623
473
|
}
|
|
624
474
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
475
|
+
async complete() {
|
|
476
|
+
await this.flush();
|
|
477
|
+
return this.control.request("transfer.route_stream.complete", {
|
|
478
|
+
transfer_id: this.options.transferId,
|
|
479
|
+
stream_id: this.streamId,
|
|
480
|
+
expected_batches: this.batchIndex,
|
|
481
|
+
expected_routes: this.options.totalRoutes,
|
|
482
|
+
route_keys_checksum: this.checksum.value()
|
|
483
|
+
}, this.options.transferId);
|
|
484
|
+
}
|
|
485
|
+
async flush() {
|
|
486
|
+
if (!this.batch.length)
|
|
487
|
+
return;
|
|
488
|
+
const routes = this.batch.splice(0, this.batch.length);
|
|
489
|
+
const chunks = this.control.splitRoutesForPayload("transfer.route_stream.batch", {
|
|
490
|
+
transfer_id: this.options.transferId,
|
|
491
|
+
stream_id: this.streamId,
|
|
492
|
+
batch_id: `${this.streamId}:estimate`,
|
|
493
|
+
batch_index: this.batchIndex
|
|
494
|
+
}, routes);
|
|
628
495
|
for (const chunk of chunks) {
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
496
|
+
const batchIndex = this.batchIndex;
|
|
497
|
+
this.batchIndex += 1;
|
|
498
|
+
await this.control.request("transfer.route_stream.batch", {
|
|
499
|
+
transfer_id: this.options.transferId,
|
|
500
|
+
stream_id: this.streamId,
|
|
501
|
+
batch_id: `${this.streamId}:${batchIndex}`,
|
|
502
|
+
batch_index: batchIndex,
|
|
503
|
+
chunk_routes: chunk,
|
|
504
|
+
route_count: chunk.length,
|
|
505
|
+
route_keys_checksum: await routeKeysChecksum(chunk.map(routeKeyForSignedRoute))
|
|
506
|
+
}, this.options.transferId);
|
|
634
507
|
}
|
|
635
|
-
return output.sort((a, b) => a.source_offset - b.source_offset || a.source_chunk_index - b.source_chunk_index);
|
|
636
508
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
const
|
|
643
|
-
for (
|
|
644
|
-
|
|
645
|
-
const chunkEnd = chunk.source_offset + chunk.chunk_size;
|
|
646
|
-
const startsNewGroup = current.length === 0 || chunkStart !== groupEnd || chunkEnd - groupStart > maxBytes;
|
|
647
|
-
if (startsNewGroup) {
|
|
648
|
-
if (current.length)
|
|
649
|
-
groups.push(current);
|
|
650
|
-
current = [chunk];
|
|
651
|
-
groupStart = chunkStart;
|
|
652
|
-
groupEnd = chunkEnd;
|
|
653
|
-
}
|
|
654
|
-
else {
|
|
655
|
-
current.push(chunk);
|
|
656
|
-
groupEnd = chunkEnd;
|
|
657
|
-
}
|
|
509
|
+
}
|
|
510
|
+
class RouteKeysChecksum {
|
|
511
|
+
bytes = new Uint8Array(32);
|
|
512
|
+
count = 0;
|
|
513
|
+
async add(routeKeyValue) {
|
|
514
|
+
const digest = await sha256Bytes(routeKeyValue);
|
|
515
|
+
for (let index = 0; index < this.bytes.length; index += 1) {
|
|
516
|
+
this.bytes[index] ^= digest[index] ?? 0;
|
|
658
517
|
}
|
|
659
|
-
|
|
660
|
-
groups.push(current);
|
|
661
|
-
return groups;
|
|
662
|
-
}
|
|
663
|
-
key(chunk) {
|
|
664
|
-
return `${chunk.source_id}:${chunk.source_chunk_index}`;
|
|
518
|
+
this.count += 1;
|
|
665
519
|
}
|
|
666
|
-
|
|
667
|
-
this.
|
|
520
|
+
value() {
|
|
521
|
+
return `sha256-xor-v1:${this.count}:${hex(this.bytes)}`;
|
|
668
522
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
missing.push(this.key(chunk));
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
if (missing.length) {
|
|
678
|
-
throw new Error(`checksum cache missing ${missing.length} chunk(s) after preflight warmup; refusing post-prepare source reads`);
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
run(task) {
|
|
682
|
-
return new Promise((resolve, reject) => {
|
|
683
|
-
const start = () => {
|
|
684
|
-
this.active += 1;
|
|
685
|
-
task().then(resolve, reject).finally(() => {
|
|
686
|
-
this.active -= 1;
|
|
687
|
-
this.queue.shift()?.();
|
|
688
|
-
});
|
|
689
|
-
};
|
|
690
|
-
if (this.active < Math.max(1, this.options.concurrency))
|
|
691
|
-
start();
|
|
692
|
-
else
|
|
693
|
-
this.queue.push(start);
|
|
694
|
-
});
|
|
523
|
+
}
|
|
524
|
+
async function routeKeysChecksum(routeKeys) {
|
|
525
|
+
const checksum = new RouteKeysChecksum();
|
|
526
|
+
for (const routeKeyValue of routeKeys) {
|
|
527
|
+
await checksum.add(routeKeyValue);
|
|
695
528
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
529
|
+
return checksum.value();
|
|
530
|
+
}
|
|
531
|
+
function routeKeyForSignedRoute(route) {
|
|
532
|
+
return `${route.source_id}:${route.destination_id}:${route.chunk_index}`;
|
|
533
|
+
}
|
|
534
|
+
function countDistinctRouteChunks(routes) {
|
|
535
|
+
return new Set(routes.map((route) => `${route.source_id}:${route.chunk_index}`)).size;
|
|
536
|
+
}
|
|
537
|
+
function countRoutesInChunkPlan(chunks) {
|
|
538
|
+
return chunks.reduce((sum, chunk) => sum + chunk.destinations.length, 0);
|
|
539
|
+
}
|
|
540
|
+
async function sha256Bytes(value) {
|
|
541
|
+
const input = new TextEncoder().encode(value);
|
|
542
|
+
if (globalThis.crypto?.subtle) {
|
|
543
|
+
return new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", input));
|
|
700
544
|
}
|
|
545
|
+
const { createHash } = await import("node:crypto");
|
|
546
|
+
return new Uint8Array(createHash("sha256").update(value).digest());
|
|
701
547
|
}
|
|
702
|
-
function
|
|
703
|
-
|
|
704
|
-
let reject;
|
|
705
|
-
const promise = new Promise((res, rej) => {
|
|
706
|
-
resolve = res;
|
|
707
|
-
reject = rej;
|
|
708
|
-
});
|
|
709
|
-
return { promise, resolve, reject };
|
|
548
|
+
function hex(bytes) {
|
|
549
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
710
550
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
const minChunk = 5 * mb;
|
|
715
|
-
const maxChunk = 5 * mb;
|
|
716
|
-
const targetChunks = 10;
|
|
717
|
-
if (totalSize <= minChunk) {
|
|
718
|
-
return totalSize;
|
|
551
|
+
async function cancelTransferAfterRouteStreamFailure(client, transferId, cause) {
|
|
552
|
+
try {
|
|
553
|
+
await client.cancelTransfer(transferId);
|
|
719
554
|
}
|
|
720
|
-
|
|
721
|
-
|
|
555
|
+
catch (cancelError) {
|
|
556
|
+
throw new Error(`route streaming failed (${errorMessage(cause)}) and transfer cancellation failed (${errorMessage(cancelError)})`);
|
|
722
557
|
}
|
|
723
|
-
|
|
558
|
+
}
|
|
559
|
+
function errorMessage(error) {
|
|
560
|
+
return error instanceof Error ? error.message : String(error);
|
|
724
561
|
}
|
|
725
562
|
function newTransferId() {
|
|
726
563
|
const cryptoApi = globalThis.crypto;
|