@beam-network/sdk 0.2.15 → 0.2.17

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,5 +1,6 @@
1
- import { abortMultipartUpload, computeChunksContentMd5, createMultipartUpload, nowIso, prepareProviderDestination, prepareProviderSource, prepareProviderSourceForPlan, isS3CompatibleProvider, listPartsPageMarker, listPartsVerifyMarker, signAbortMultipartUpload, signCompleteMultipartUpload, signDeleteObject, signDestinationRoute, signListMultipartUpload, signListObjects, signUploadPartCopy } from "./provider-signing.js";
2
- export const BEAM_DEFAULT_URL = "https://beamcore.b1m.ai";
1
+ import { abortMultipartUpload, createMultipartUpload, nowIso, prepareProviderDestination, prepareProviderSource, listPartsPageMarker, listPartsVerifyMarker, signAbortMultipartUpload, signCompleteMultipartUpload, signDeleteObject, signDestinationRoute, signListMultipartUpload, signListObjects, signUploadPartCopy } from "./provider-signing.js";
2
+ import { BeamTransferControl, BEAM_DEFAULT_NATS_URL } from "./nats-control.js";
3
+ export { BEAM_DEFAULT_NATS_URL };
3
4
  export class BeamApiError extends Error {
4
5
  status;
5
6
  body;
@@ -11,84 +12,110 @@ export class BeamApiError extends Error {
11
12
  }
12
13
  }
13
14
  export class BeamClient {
14
- baseUrl;
15
15
  apiKey;
16
+ natsUrl;
16
17
  fetchImpl;
18
+ control;
19
+ recoverySigners = new Map();
17
20
  constructor(options = {}) {
18
21
  if (!options.apiKey?.trim()) {
19
22
  throw new Error("apiKey is required.");
20
23
  }
21
- this.baseUrl = normalizeBaseUrl(resolveBaseUrl(options));
22
24
  this.apiKey = options.apiKey;
25
+ this.natsUrl = options.natsUrl ?? options.natsWsUrl ?? BEAM_DEFAULT_NATS_URL;
26
+ this.control = new BeamTransferControl({
27
+ apiKey: this.apiKey,
28
+ natsUrl: options.natsUrl,
29
+ natsWsUrl: options.natsWsUrl,
30
+ environment: options.environment,
31
+ subjectPrefix: options.transferClientSubjectPrefix,
32
+ transferRuntimeShardCount: options.transferRuntimeShardCount,
33
+ requestTimeoutMs: options.requestTimeoutMs,
34
+ maxPayloadBytes: options.maxPayloadBytes
35
+ });
23
36
  this.fetchImpl = options.fetch ?? globalThis.fetch;
24
37
  if (!this.fetchImpl) {
25
38
  throw new Error("A fetch implementation is required.");
26
39
  }
27
40
  }
41
+ async close() {
42
+ this.stopAllRecoverySigners();
43
+ return this.control.close();
44
+ }
28
45
  createRawTransfer(input) {
29
46
  const body = compact({
47
+ transfer_id: newTransferId(),
30
48
  sources: input.sources,
31
49
  destinations: input.destinations,
32
50
  total_size: input.totalSize,
33
- chunk_size: input.chunkSize ?? calculateOptimalChunkSize(input.totalSize),
51
+ chunk_size: input.chunkSize,
34
52
  name: input.name,
35
53
  merkle_root: input.merkleRoot,
36
54
  chunk_hashes: input.chunkHashes,
37
55
  callbacks: input.callbacks,
38
56
  test_mode: input.testMode || undefined,
39
- experimental: input.experimental || undefined,
40
57
  progressive_mode: input.progressiveMode || undefined,
41
58
  signed_url_flow: input.signedUrlFlow
42
59
  });
43
- return this.request("POST", "/transfers/create", body);
60
+ return this.control.request("transfer.create", body);
44
61
  }
45
62
  createTransfer(input) {
46
63
  return this.prepareProviderTransfer(input);
47
64
  }
48
65
  transferStatus(transferId) {
49
66
  validateId(transferId, "transferId");
50
- return this.request("GET", `/transfers/${encodeURIComponent(transferId)}/status`);
67
+ return this.control.request("transfer.status", { transfer_id: transferId }, transferId);
51
68
  }
52
69
  distributeTransfer(transferId) {
53
70
  validateId(transferId, "transferId");
54
- return this.request("POST", "/transfers/distribute", {
55
- transfer_id: transferId
56
- });
71
+ return this.control.request("transfer.distribute", { transfer_id: transferId }, transferId);
57
72
  }
58
- cancelTransfer(transferId) {
73
+ async cancelTransfer(transferId) {
59
74
  validateId(transferId, "transferId");
60
- return this.request("DELETE", `/transfers/${encodeURIComponent(transferId)}`);
75
+ try {
76
+ return await this.control.request("transfer.cancel", { transfer_id: transferId }, transferId);
77
+ }
78
+ finally {
79
+ this.stopRecoverySigner(transferId);
80
+ }
61
81
  }
62
82
  planTransfer(input) {
63
- return this.request("POST", "/transfers/plan", compact({
83
+ return this.control.request("transfer.plan", compact({
64
84
  sources: input.sources,
65
85
  destinations: input.destinations,
66
86
  name: input.name,
67
87
  test_mode: input.testMode || undefined,
68
- experimental: input.experimental || undefined,
69
88
  urls_expires_at: input.urlsExpiresAt,
70
89
  signed_url_flow: input.signedUrlFlow
71
90
  }));
72
91
  }
73
92
  prepareTransfer(input) {
74
- return this.request("POST", "/transfers/prepare", compact({
93
+ const transferId = newTransferId();
94
+ return this.control.request("transfer.prepare", compact({
95
+ transfer_id: transferId,
75
96
  sources: input.sources,
76
97
  destinations: input.destinations,
77
98
  name: input.name,
78
99
  test_mode: input.testMode || undefined,
79
- experimental: input.experimental || undefined,
80
100
  urls_expires_at: input.urlsExpiresAt,
81
101
  signed_url_flow: input.signedUrlFlow
82
- }));
102
+ }), transferId);
83
103
  }
84
- attachSignedUrls(transferId, input) {
104
+ async attachSignedUrls(transferId, input) {
85
105
  validateId(transferId, "transferId");
86
- return this.request("POST", "/transfers/attach-signed-urls", compact({
87
- transfer_id: transferId,
88
- chunk_routes: input.chunkRoutes,
89
- transfer_key: input.transferKey,
90
- urls_expires_at: input.urlsExpiresAt
91
- }));
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);
117
+ }
118
+ return stream.complete();
92
119
  }
93
120
  async prepareProviderTransfer(input) {
94
121
  const expiresIn = input.expiresIn ?? 3600;
@@ -102,93 +129,19 @@ export class BeamClient {
102
129
  }
103
130
  destinationsById.set(preparedDestination.destination_id, destination);
104
131
  });
105
- const checksumCache = new ChunkChecksumCache({
106
- concurrency: input.checksumReadConcurrency ?? 8,
107
- expiresIn,
108
- fetchImpl: this.fetchImpl,
109
- provider: input.checksumProvider,
110
- groupSizeBytes: input.checksumGroupSizeBytes ?? 256 * 1024 * 1024,
111
- readTimeoutMs: input.checksumReadTimeoutMs,
112
- onEvent: input.onChecksumEvent,
113
- 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
114
142
  });
115
- let preparedSources;
116
- let prepared;
117
- if (requestedSignedUrlFlow === "signed_url_v1") {
118
- await input.throwIfCancelled?.();
119
- const plannedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSourceForPlan(source, { index, fetchImpl: this.fetchImpl })));
120
- const planned = await this.planTransfer({
121
- sources: plannedSources,
122
- destinations: preparedDestinations,
123
- name: input.name,
124
- testMode: input.testMode,
125
- experimental: input.experimental,
126
- signedUrlFlow: requestedSignedUrlFlow
127
- });
128
- if (!planned.success) {
129
- return failedPlanAsPrepareResponse(planned);
130
- }
131
- await input.onPlanned?.(planned);
132
- await input.throwIfCancelled?.();
133
- const plannedSourcesById = mapSourcesByPreparedId(plannedSources, input.sources);
134
- const chunksBySource = checksumChunksBySource(planned.chunk_plan, destinationsById);
135
- const totalWarmupChunks = [...chunksBySource.values()].reduce((sum, chunks) => sum + chunks.length, 0);
136
- const totalWarmupBytes = [...chunksBySource.values()].reduce((sum, chunks) => sum + chunks.reduce((chunkSum, chunk) => chunkSum + chunk.chunk_size, 0), 0);
137
- if (totalWarmupChunks > 0) {
138
- const startedAt = Date.now();
139
- await emitChecksumEvent(input, {
140
- event: "checksum_warmup_started",
141
- sourceId: "all",
142
- chunkCount: totalWarmupChunks,
143
- bytes: totalWarmupBytes
144
- });
145
- for (const [sourceId, chunks] of chunksBySource) {
146
- await input.throwIfCancelled?.();
147
- const source = plannedSourcesById.get(sourceId);
148
- if (!source) {
149
- throw new Error(`BeamCore planned unknown source_id: ${sourceId}`);
150
- }
151
- await checksumCache.ensureMany(source, chunks);
152
- }
153
- await emitChecksumEvent(input, {
154
- event: "checksum_warmup_completed",
155
- sourceId: "all",
156
- chunkCount: totalWarmupChunks,
157
- bytes: totalWarmupBytes,
158
- durationMs: Date.now() - startedAt
159
- });
160
- }
161
- await input.throwIfCancelled?.();
162
- checksumCache.seal();
163
- preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
164
- await input.onBeforeTransferPrepare?.();
165
- prepared = await this.prepareTransfer({
166
- sources: preparedSources,
167
- destinations: preparedDestinations,
168
- name: input.name,
169
- testMode: input.testMode,
170
- experimental: input.experimental,
171
- signedUrlFlow: requestedSignedUrlFlow
172
- });
173
- if (!prepared.success) {
174
- return prepared;
175
- }
176
- assertPreparedTransferMatchesPlan(planned, prepared);
177
- }
178
- else {
179
- preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
180
- await input.onBeforeTransferPrepare?.();
181
- prepared = await this.prepareTransfer({
182
- sources: preparedSources,
183
- destinations: preparedDestinations,
184
- name: input.name,
185
- testMode: input.testMode,
186
- experimental: input.experimental,
187
- signedUrlFlow: requestedSignedUrlFlow
188
- });
189
- if (!prepared.success) {
190
- return prepared;
191
- }
143
+ if (!prepared.success) {
144
+ return prepared;
192
145
  }
193
146
  await input.onPrepared?.(prepared);
194
147
  await input.throwIfCancelled?.(prepared.transfer_id);
@@ -201,13 +154,26 @@ export class BeamClient {
201
154
  sourcesById.set(preparedSource.source_id, source);
202
155
  });
203
156
  const signedUrlFlow = prepared.signed_url_flow ?? requestedSignedUrlFlow;
204
- if (signedUrlFlow === "signed_url_v1") {
205
- const chunksBySource = checksumChunksBySource(prepared.chunk_plan, destinationsById);
206
- checksumCache.assertManyWarmed(chunksBySource);
207
- }
208
- 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
+ });
209
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;
210
174
  try {
175
+ await routeStream.begin();
176
+ routeStreamStarted = true;
211
177
  for (const chunk of prepared.chunk_plan) {
212
178
  await input.throwIfCancelled?.(prepared.transfer_id);
213
179
  for (const target of chunk.destinations) {
@@ -257,7 +223,7 @@ export class BeamClient {
257
223
  }
258
224
  multipartUploads.set(multipartKey, upload);
259
225
  }
260
- routes.push(await this.signProviderRoute({
226
+ await routeStream.addRoute(await this.signProviderRoute({
261
227
  chunk,
262
228
  target,
263
229
  source,
@@ -268,35 +234,24 @@ export class BeamClient {
268
234
  finalObjectKey,
269
235
  partNumber: typeof metadata.part_number === "number"
270
236
  ? metadata.part_number
271
- : chunk.source_chunk_index + 1,
272
- signedUrlFlow,
273
- checksumCache
237
+ : chunk.chunk_index * 3 + 1,
238
+ signedUrlFlow
274
239
  }));
275
240
  }
276
241
  }
277
- }
278
- catch (error) {
279
- await abortCreatedUploads(multipartUploads);
280
- throw error;
281
- }
282
- try {
283
- await input.throwIfCancelled?.(prepared.transfer_id);
284
- const attached = await this.attachSignedUrls(prepared.transfer_id, {
285
- chunkRoutes: routes,
286
- transferKey: prepared.transfer_key
287
- });
242
+ const attached = await routeStream.complete();
288
243
  if (!attached.success) {
289
- throw new Error(attached.error ?? attached.message ?? "attach signed URLs failed");
244
+ throw new Error(attached.error ?? attached.message ?? "route stream failed");
290
245
  }
291
246
  }
292
247
  catch (error) {
248
+ this.stopRecoverySigner(prepared.transfer_id);
293
249
  await abortCreatedUploads(multipartUploads);
250
+ if (routeStreamStarted) {
251
+ await cancelTransferAfterRouteStreamFailure(this, prepared.transfer_id, error);
252
+ }
294
253
  throw error;
295
254
  }
296
- if (input.distribute !== false) {
297
- await input.throwIfCancelled?.(prepared.transfer_id);
298
- await this.distributeTransfer(prepared.transfer_id);
299
- }
300
255
  return prepared;
301
256
  }
302
257
  async signProviderRoute(input) {
@@ -317,7 +272,6 @@ export class BeamClient {
317
272
  throw new Error("destination signing target is missing object_key");
318
273
  }
319
274
  if (input.signedUrlFlow === "signed_url_v1") {
320
- const contentMd5 = await input.checksumCache.get(input.source, input.chunk);
321
275
  const partVerifyUrl = await signListMultipartUpload(input.destination, input.finalObjectKey, input.upload.uploadId, input.expiresIn, { maxParts: 1, partNumberMarker: listPartsVerifyMarker(input.partNumber) });
322
276
  const listPageUrl = await signListMultipartUpload(input.destination, input.finalObjectKey, input.upload.uploadId, input.expiresIn, { maxParts: 1000, partNumberMarker: listPartsPageMarker(input.partNumber) });
323
277
  return signDestinationRoute({
@@ -334,7 +288,6 @@ export class BeamClient {
334
288
  listUrl: input.upload.listUrl,
335
289
  partVerifyUrl,
336
290
  listPageUrl,
337
- contentMd5,
338
291
  transferId: input.transferId,
339
292
  multipartGroupId: input.upload.multipartGroupId,
340
293
  multipartCreatedAt: input.upload.multipartCreatedAt,
@@ -381,6 +334,80 @@ export class BeamClient {
381
334
  fetchImpl: this.fetchImpl
382
335
  });
383
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
+ }
384
411
  async createAndDistribute(input) {
385
412
  const transfer = await this.createRawTransfer(input);
386
413
  if (transfer.success) {
@@ -395,12 +422,15 @@ export class BeamClient {
395
422
  while (true) {
396
423
  const status = await this.transferStatus(transferId);
397
424
  if (status.status === "completed") {
425
+ this.stopRecoverySigner(transferId);
398
426
  return status;
399
427
  }
400
428
  if (status.status === "failed") {
429
+ this.stopRecoverySigner(transferId);
401
430
  throw new Error(`Transfer failed: ${status.error_message ?? "unknown error"}`);
402
431
  }
403
432
  if (status.status === "cancelled") {
433
+ this.stopRecoverySigner(transferId);
404
434
  throw new Error("Transfer cancelled");
405
435
  }
406
436
  if (Date.now() - startedAt >= timeoutMs) {
@@ -409,315 +439,131 @@ export class BeamClient {
409
439
  await sleep(pollIntervalMs);
410
440
  }
411
441
  }
412
- async request(method, path, body) {
413
- const headers = {
414
- Accept: "application/json",
415
- "X-Api-Key": this.apiKey
416
- };
417
- let payload;
418
- if (body !== undefined) {
419
- headers["Content-Type"] = "application/json";
420
- payload = JSON.stringify(body);
421
- }
422
- const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
423
- method,
424
- headers,
425
- body: payload
426
- });
427
- const text = await response.text();
428
- if (!response.ok) {
429
- throw new BeamApiError(`BEAM API request failed with ${response.status}`, response.status, text);
430
- }
431
- return (text ? JSON.parse(text) : {});
432
- }
433
- }
434
- function checksumChunksBySource(chunkPlan, destinationsById) {
435
- const chunksBySource = new Map();
436
- for (const chunk of chunkPlan) {
437
- const needsChecksum = chunk.destinations.some((target) => {
438
- const destination = destinationsById.get(target.destination_id);
439
- return destination ? isS3CompatibleProvider(destination) : false;
440
- });
441
- if (!needsChecksum)
442
- continue;
443
- const chunks = chunksBySource.get(chunk.source_id) ?? [];
444
- chunks.push(chunk);
445
- chunksBySource.set(chunk.source_id, chunks);
446
- }
447
- return chunksBySource;
448
- }
449
- function mapSourcesByPreparedId(preparedSources, providerSources) {
450
- const sourcesById = new Map();
451
- preparedSources.forEach((preparedSource, index) => {
452
- const source = providerSources[index];
453
- if (!source) {
454
- throw new Error(`missing provider source config for planned source ${preparedSource.source_id}`);
455
- }
456
- sourcesById.set(preparedSource.source_id, source);
457
- });
458
- return sourcesById;
459
- }
460
- function failedPlanAsPrepareResponse(plan) {
461
- return {
462
- success: false,
463
- transfer_id: "",
464
- chunk_size: plan.chunk_size,
465
- total_size: plan.total_size,
466
- total_sources: plan.total_sources,
467
- total_destinations: plan.total_destinations,
468
- logical_chunks: plan.logical_chunks,
469
- total_chunks: plan.total_chunks,
470
- chunk_plan: plan.chunk_plan ?? [],
471
- signed_url_flow: plan.signed_url_flow,
472
- error: plan.error,
473
- message: plan.message
474
- };
475
442
  }
476
- function assertPreparedTransferMatchesPlan(plan, prepared) {
477
- const summaryMatches = plan.chunk_size === prepared.chunk_size &&
478
- plan.total_size === prepared.total_size &&
479
- plan.total_sources === prepared.total_sources &&
480
- plan.total_destinations === prepared.total_destinations &&
481
- plan.logical_chunks === prepared.logical_chunks &&
482
- plan.total_chunks === prepared.total_chunks;
483
- if (!summaryMatches || planSignature(plan.chunk_plan) !== planSignature(prepared.chunk_plan)) {
484
- throw new Error("prepared transfer chunk plan does not match preflight checksum plan");
485
- }
486
- }
487
- function planSignature(chunks) {
488
- return JSON.stringify(chunks.map((chunk) => ({
489
- chunk_index: chunk.chunk_index,
490
- source_id: chunk.source_id,
491
- source_chunk_index: chunk.source_chunk_index,
492
- source_offset: chunk.source_offset,
493
- chunk_size: chunk.chunk_size,
494
- destinations: chunk.destinations.map((target) => {
495
- const metadata = target.metadata ?? {};
496
- return {
497
- destination_id: target.destination_id,
498
- provider: target.provider,
499
- final_object_key: typeof metadata.final_object_key === "string" ? metadata.final_object_key : target.object_key,
500
- part_number: typeof metadata.part_number === "number" ? metadata.part_number : undefined,
501
- delivery_index: typeof metadata.delivery_index === "number" ? metadata.delivery_index : undefined
502
- };
503
- })
504
- })));
505
- }
506
- async function emitChecksumEvent(input, event) {
507
- if (!input.onChecksumEvent)
508
- return;
509
- await input.onChecksumEvent(event);
510
- }
511
- 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;
512
448
  options;
513
- queue = [];
514
- cache = new Map();
515
- active = 0;
516
- sealed = false;
517
- constructor(options) {
449
+ streamId = newTransferId();
450
+ checksum = new RouteKeysChecksum();
451
+ batch = [];
452
+ batchIndex = 0;
453
+ constructor(control, options) {
454
+ this.control = control;
518
455
  this.options = options;
519
456
  }
520
- preloadMany(source, chunks) {
521
- if (this.sealed) {
522
- const missing = this.uniqueChunks(chunks).filter((chunk) => !this.cache.has(this.key(chunk)));
523
- if (missing.length) {
524
- throw new Error(`checksum cache missing ${missing.length} chunk(s) after preflight warmup; refusing post-prepare source reads`);
525
- }
526
- return;
527
- }
528
- const pending = this.uniqueChunks(chunks).filter((chunk) => !this.cache.has(this.key(chunk)));
529
- for (const chunk of pending) {
530
- this.cache.set(this.key(chunk), deferredChecksum());
531
- }
532
- for (const group of this.groupChunks(pending)) {
533
- void this.run(() => this.fillGroup(source, group)).catch(() => undefined);
534
- }
535
- }
536
- async ensureMany(source, chunks) {
537
- const unique = this.uniqueChunks(chunks);
538
- this.preloadMany(source, unique);
539
- await Promise.all(unique.map((chunk) => this.get(source, chunk)));
540
- }
541
- get(source, chunk) {
542
- const key = this.key(chunk);
543
- let existing = this.cache.get(key);
544
- if (!existing) {
545
- if (this.sealed) {
546
- return Promise.reject(new Error(`checksum cache missing ${key} after preflight warmup; refusing post-prepare source read`));
547
- }
548
- existing = deferredChecksum();
549
- this.cache.set(key, existing);
550
- void this.run(() => this.fillGroup(source, [chunk])).catch(() => undefined);
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();
551
473
  }
552
- return existing.promise;
553
474
  }
554
- async fillGroup(source, chunks) {
555
- const unresolved = new Set(chunks.map((chunk) => this.key(chunk)));
556
- try {
557
- await this.options.throwIfCancelled?.();
558
- const misses = [];
559
- for (const chunk of chunks) {
560
- const key = this.key(chunk);
561
- const deferred = this.cache.get(key);
562
- if (!deferred)
563
- continue;
564
- const cached = await this.options.provider?.getContentMd5({ source, chunk, algorithm: "content-md5" }).catch(() => undefined);
565
- if (cached) {
566
- await this.emit({ event: "checksum_cache_hit", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
567
- deferred.resolve(cached);
568
- unresolved.delete(key);
569
- }
570
- else {
571
- await this.emit({ event: "checksum_cache_miss", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
572
- misses.push(chunk);
573
- }
574
- }
575
- if (!misses.length)
576
- return;
577
- await this.options.throwIfCancelled?.();
578
- const startedAt = Date.now();
579
- const checksums = await computeChunksContentMd5({
580
- source,
581
- chunks: misses,
582
- expiresIn: this.options.expiresIn,
583
- fetchImpl: this.options.fetchImpl,
584
- groupSizeBytes: this.options.groupSizeBytes,
585
- readTimeoutMs: this.options.readTimeoutMs
586
- });
587
- for (const chunk of misses) {
588
- await this.options.throwIfCancelled?.();
589
- const checksum = checksums.get(chunk.source_chunk_index);
590
- if (!checksum)
591
- throw new Error(`checksum missing for source chunk ${chunk.source_chunk_index}`);
592
- await this.options.provider?.setContentMd5?.({ source, chunk, algorithm: "content-md5", contentMd5: checksum });
593
- const deferred = this.cache.get(this.key(chunk));
594
- deferred?.resolve(checksum);
595
- unresolved.delete(this.key(chunk));
596
- await this.emit({ event: "checksum_cold_chunk_cached", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
597
- }
598
- await this.emit({
599
- event: "checksum_cold_group_completed",
600
- sourceId: misses[0]?.source_id ?? source.id ?? "source",
601
- chunkCount: misses.length,
602
- bytes: misses.reduce((sum, chunk) => sum + chunk.chunk_size, 0),
603
- durationMs: Date.now() - startedAt
604
- });
605
- }
606
- catch (error) {
607
- for (const key of unresolved) {
608
- this.cache.get(key)?.reject(error);
609
- }
610
- throw error;
611
- }
612
- }
613
- uniqueChunks(chunks) {
614
- const seen = new Set();
615
- 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);
616
495
  for (const chunk of chunks) {
617
- const key = this.key(chunk);
618
- if (seen.has(key))
619
- continue;
620
- seen.add(key);
621
- 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);
622
507
  }
623
- return output.sort((a, b) => a.source_offset - b.source_offset || a.source_chunk_index - b.source_chunk_index);
624
508
  }
625
- groupChunks(chunks) {
626
- const groups = [];
627
- let current = [];
628
- let groupStart = 0;
629
- let groupEnd = 0;
630
- const maxBytes = Math.max(1, Math.floor(this.options.groupSizeBytes));
631
- for (const chunk of chunks) {
632
- const chunkStart = chunk.source_offset;
633
- const chunkEnd = chunk.source_offset + chunk.chunk_size;
634
- const startsNewGroup = current.length === 0 || chunkStart !== groupEnd || chunkEnd - groupStart > maxBytes;
635
- if (startsNewGroup) {
636
- if (current.length)
637
- groups.push(current);
638
- current = [chunk];
639
- groupStart = chunkStart;
640
- groupEnd = chunkEnd;
641
- }
642
- else {
643
- current.push(chunk);
644
- groupEnd = chunkEnd;
645
- }
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;
646
517
  }
647
- if (current.length)
648
- groups.push(current);
649
- return groups;
650
- }
651
- key(chunk) {
652
- return `${chunk.source_id}:${chunk.source_chunk_index}`;
518
+ this.count += 1;
653
519
  }
654
- seal() {
655
- this.sealed = true;
520
+ value() {
521
+ return `sha256-xor-v1:${this.count}:${hex(this.bytes)}`;
656
522
  }
657
- assertManyWarmed(chunksBySource) {
658
- const missing = [];
659
- for (const chunks of chunksBySource.values()) {
660
- for (const chunk of this.uniqueChunks(chunks)) {
661
- if (!this.cache.has(this.key(chunk)))
662
- missing.push(this.key(chunk));
663
- }
664
- }
665
- if (missing.length) {
666
- throw new Error(`checksum cache missing ${missing.length} chunk(s) after preflight warmup; refusing post-prepare source reads`);
667
- }
668
- }
669
- run(task) {
670
- return new Promise((resolve, reject) => {
671
- const start = () => {
672
- this.active += 1;
673
- task().then(resolve, reject).finally(() => {
674
- this.active -= 1;
675
- this.queue.shift()?.();
676
- });
677
- };
678
- if (this.active < Math.max(1, this.options.concurrency))
679
- start();
680
- else
681
- this.queue.push(start);
682
- });
523
+ }
524
+ async function routeKeysChecksum(routeKeys) {
525
+ const checksum = new RouteKeysChecksum();
526
+ for (const routeKeyValue of routeKeys) {
527
+ await checksum.add(routeKeyValue);
683
528
  }
684
- async emit(event) {
685
- if (!this.options.onEvent)
686
- return;
687
- 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));
688
544
  }
545
+ const { createHash } = await import("node:crypto");
546
+ return new Uint8Array(createHash("sha256").update(value).digest());
689
547
  }
690
- function deferredChecksum() {
691
- let resolve;
692
- let reject;
693
- const promise = new Promise((res, rej) => {
694
- resolve = res;
695
- reject = rej;
696
- });
697
- return { promise, resolve, reject };
548
+ function hex(bytes) {
549
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
698
550
  }
699
- export function calculateOptimalChunkSize(totalSize) {
700
- const mb = 1024 * 1024;
701
- const gb = 1024 * mb;
702
- const minChunk = 5 * mb;
703
- const maxChunk = 5 * mb;
704
- const targetChunks = 10;
705
- if (totalSize <= minChunk) {
706
- return totalSize;
551
+ async function cancelTransferAfterRouteStreamFailure(client, transferId, cause) {
552
+ try {
553
+ await client.cancelTransfer(transferId);
707
554
  }
708
- if (totalSize < gb) {
709
- 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)})`);
710
557
  }
711
- return maxChunk;
712
558
  }
713
- function resolveBaseUrl(options) {
714
- if (options.baseUrl) {
715
- return options.baseUrl;
716
- }
717
- return BEAM_DEFAULT_URL;
559
+ function errorMessage(error) {
560
+ return error instanceof Error ? error.message : String(error);
718
561
  }
719
- function normalizeBaseUrl(value) {
720
- return value.replace(/\/+$/, "");
562
+ function newTransferId() {
563
+ const cryptoApi = globalThis.crypto;
564
+ if (cryptoApi?.randomUUID)
565
+ return cryptoApi.randomUUID();
566
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
721
567
  }
722
568
  function compact(input) {
723
569
  return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));