@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/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { abortMultipartUpload, computeChunksContentMd5, createMultipartUpload, nowIso, prepareProviderDestination, prepareProviderSource, prepareProviderSourceForPlan, isS3CompatibleProvider, listPartsPageMarker, listPartsVerifyMarker, signAbortMultipartUpload, signCompleteMultipartUpload, signDeleteObject, signDestinationRoute, signListMultipartUpload, signListObjects, signUploadPartCopy } from "./provider-signing.js";
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
- return this.control.request("transfer.cancel", { transfer_id: transferId }, transferId);
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 attachmentId = newTransferId();
103
- await this.control.request("transfer.attach_signed_urls.begin", compact({
104
- transfer_id: transferId,
105
- attachment_id: attachmentId,
106
- total_routes: input.chunkRoutes.length,
107
- urls_expires_at: input.urlsExpiresAt
108
- }), transferId);
109
- const chunks = this.control.splitRoutesForPayload("transfer.attach_signed_urls.chunk", {
110
- transfer_id: transferId,
111
- attachment_id: attachmentId
112
- }, input.chunkRoutes);
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 this.control.request("transfer.attach_signed_urls.commit", {
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
- const checksumCache = new ChunkChecksumCache({
139
- concurrency: input.checksumReadConcurrency ?? 8,
140
- expiresIn,
141
- fetchImpl: this.fetchImpl,
142
- provider: input.checksumProvider,
143
- groupSizeBytes: input.checksumGroupSizeBytes ?? 256 * 1024 * 1024,
144
- readTimeoutMs: input.checksumReadTimeoutMs,
145
- onEvent: input.onChecksumEvent,
146
- throwIfCancelled: () => input.throwIfCancelled?.()
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
- let preparedSources;
149
- let prepared;
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
- if (signedUrlFlow === "signed_url_v1") {
238
- const chunksBySource = checksumChunksBySource(prepared.chunk_plan, destinationsById);
239
- checksumCache.assertManyWarmed(chunksBySource);
240
- }
241
- const routes = [];
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
- routes.push(await this.signProviderRoute({
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.source_chunk_index + 1,
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 ?? "attach signed URLs failed");
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
- function checksumChunksBySource(chunkPlan, destinationsById) {
447
- const chunksBySource = new Map();
448
- for (const chunk of chunkPlan) {
449
- const needsChecksum = chunk.destinations.some((target) => {
450
- const destination = destinationsById.get(target.destination_id);
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
- queue = [];
526
- cache = new Map();
527
- active = 0;
528
- sealed = false;
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
- preloadMany(source, chunks) {
533
- if (this.sealed) {
534
- const missing = this.uniqueChunks(chunks).filter((chunk) => !this.cache.has(this.key(chunk)));
535
- if (missing.length) {
536
- throw new Error(`checksum cache missing ${missing.length} chunk(s) after preflight warmup; refusing post-prepare source reads`);
537
- }
538
- return;
539
- }
540
- const pending = this.uniqueChunks(chunks).filter((chunk) => !this.cache.has(this.key(chunk)));
541
- for (const chunk of pending) {
542
- this.cache.set(this.key(chunk), deferredChecksum());
543
- }
544
- for (const group of this.groupChunks(pending)) {
545
- void this.run(() => this.fillGroup(source, group)).catch(() => undefined);
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
- uniqueChunks(chunks) {
626
- const seen = new Set();
627
- const output = [];
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 key = this.key(chunk);
630
- if (seen.has(key))
631
- continue;
632
- seen.add(key);
633
- output.push(chunk);
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
- groupChunks(chunks) {
638
- const groups = [];
639
- let current = [];
640
- let groupStart = 0;
641
- let groupEnd = 0;
642
- const maxBytes = Math.max(1, Math.floor(this.options.groupSizeBytes));
643
- for (const chunk of chunks) {
644
- const chunkStart = chunk.source_offset;
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
- if (current.length)
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
- seal() {
667
- this.sealed = true;
520
+ value() {
521
+ return `sha256-xor-v1:${this.count}:${hex(this.bytes)}`;
668
522
  }
669
- assertManyWarmed(chunksBySource) {
670
- const missing = [];
671
- for (const chunks of chunksBySource.values()) {
672
- for (const chunk of this.uniqueChunks(chunks)) {
673
- if (!this.cache.has(this.key(chunk)))
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
- async emit(event) {
697
- if (!this.options.onEvent)
698
- return;
699
- await this.options.onEvent(event);
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 deferredChecksum() {
703
- let resolve;
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
- export function calculateOptimalChunkSize(totalSize) {
712
- const mb = 1024 * 1024;
713
- const gb = 1024 * mb;
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
- if (totalSize < gb) {
721
- return Math.max(minChunk, Math.min(maxChunk, Math.floor(totalSize / targetChunks)));
555
+ catch (cancelError) {
556
+ throw new Error(`route streaming failed (${errorMessage(cause)}) and transfer cancellation failed (${errorMessage(cancelError)})`);
722
557
  }
723
- return maxChunk;
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;