@beam-network/sdk 0.2.10 → 0.2.11

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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AttachSignedUrlsResponse, BeamClientOptions, DistributeResponse, PreparedDestination, PreparedHttpSource, ProviderTransferCreateInput, RawTransferCreateInput, SignedChunkRoute, SignedUrlFlow, TransferCancelResponse, TransferCreateResponse, TransferPrepareResponse, TransferStatusInfo } from "./models.js";
1
+ import type { AttachSignedUrlsResponse, BeamClientOptions, DistributeResponse, PreparedDestination, PreparedHttpSource, ProviderTransferCreateInput, RawTransferCreateInput, SignedChunkRoute, SignedUrlFlow, TransferCancelResponse, TransferCreateResponse, TransferPlanResponse, TransferPrepareResponse, TransferStatusInfo } from "./models.js";
2
2
  export declare const BEAM_DEFAULT_URL = "https://beamcore.b1m.ai";
3
3
  export declare class BeamApiError extends Error {
4
4
  readonly status: number;
@@ -15,11 +15,21 @@ export declare class BeamClient {
15
15
  transferStatus(transferId: string): Promise<TransferStatusInfo>;
16
16
  distributeTransfer(transferId: string): Promise<DistributeResponse>;
17
17
  cancelTransfer(transferId: string): Promise<TransferCancelResponse>;
18
+ planTransfer(input: {
19
+ sources: PreparedHttpSource[];
20
+ destinations: PreparedDestination[];
21
+ name?: string;
22
+ testMode?: boolean;
23
+ experimental?: boolean;
24
+ urlsExpiresAt?: string;
25
+ signedUrlFlow?: SignedUrlFlow;
26
+ }): Promise<TransferPlanResponse>;
18
27
  prepareTransfer(input: {
19
28
  sources: PreparedHttpSource[];
20
29
  destinations: PreparedDestination[];
21
30
  name?: string;
22
31
  testMode?: boolean;
32
+ experimental?: boolean;
23
33
  urlsExpiresAt?: string;
24
34
  signedUrlFlow?: SignedUrlFlow;
25
35
  }): Promise<TransferPrepareResponse>;
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { abortMultipartUpload, computeChunkContentMd5, createMultipartUpload, nowIso, prepareProviderDestination, prepareProviderSource, isS3CompatibleProvider, listPartsPageMarker, listPartsVerifyMarker, signAbortMultipartUpload, signCompleteMultipartUpload, signDeleteObject, signDestinationRoute, signListMultipartUpload, signUploadPartCopy } from "./provider-signing.js";
1
+ import { abortMultipartUpload, computeChunksContentMd5, createMultipartUpload, nowIso, prepareProviderDestination, prepareProviderSource, isS3CompatibleProvider, listPartsPageMarker, listPartsVerifyMarker, signAbortMultipartUpload, signCompleteMultipartUpload, signDeleteObject, signDestinationRoute, signListMultipartUpload, signUploadPartCopy } from "./provider-signing.js";
2
2
  export const BEAM_DEFAULT_URL = "https://beamcore.b1m.ai";
3
3
  export class BeamApiError extends Error {
4
4
  status;
@@ -36,6 +36,7 @@ export class BeamClient {
36
36
  chunk_hashes: input.chunkHashes,
37
37
  callbacks: input.callbacks,
38
38
  test_mode: input.testMode || undefined,
39
+ experimental: input.experimental || undefined,
39
40
  progressive_mode: input.progressiveMode || undefined,
40
41
  signed_url_flow: input.signedUrlFlow
41
42
  });
@@ -58,12 +59,24 @@ export class BeamClient {
58
59
  validateId(transferId, "transferId");
59
60
  return this.request("DELETE", `/transfers/${encodeURIComponent(transferId)}`);
60
61
  }
62
+ planTransfer(input) {
63
+ return this.request("POST", "/transfers/plan", compact({
64
+ sources: input.sources,
65
+ destinations: input.destinations,
66
+ name: input.name,
67
+ test_mode: input.testMode || undefined,
68
+ experimental: input.experimental || undefined,
69
+ urls_expires_at: input.urlsExpiresAt,
70
+ signed_url_flow: input.signedUrlFlow
71
+ }));
72
+ }
61
73
  prepareTransfer(input) {
62
74
  return this.request("POST", "/transfers/prepare", compact({
63
75
  sources: input.sources,
64
76
  destinations: input.destinations,
65
77
  name: input.name,
66
78
  test_mode: input.testMode || undefined,
79
+ experimental: input.experimental || undefined,
67
80
  urls_expires_at: input.urlsExpiresAt,
68
81
  signed_url_flow: input.signedUrlFlow
69
82
  }));
@@ -86,6 +99,7 @@ export class BeamClient {
86
99
  destinations: preparedDestinations,
87
100
  name: input.name,
88
101
  testMode: input.testMode,
102
+ experimental: input.experimental,
89
103
  signedUrlFlow: input.signedUrlFlow
90
104
  });
91
105
  if (!prepared.success) {
@@ -95,19 +109,31 @@ export class BeamClient {
95
109
  await input.throwIfCancelled?.(prepared.transfer_id);
96
110
  const destinationsById = new Map();
97
111
  preparedDestinations.forEach((preparedDestination, index) => {
98
- destinationsById.set(preparedDestination.destination_id, input.destinations[index]);
112
+ const destination = input.destinations[index];
113
+ if (!destination) {
114
+ throw new Error(`missing provider destination config for prepared destination ${preparedDestination.destination_id}`);
115
+ }
116
+ destinationsById.set(preparedDestination.destination_id, destination);
99
117
  });
100
118
  const sourcesById = new Map();
101
119
  preparedSources.forEach((preparedSource, index) => {
102
- sourcesById.set(preparedSource.source_id, input.sources[index]);
120
+ const source = input.sources[index];
121
+ if (!source) {
122
+ throw new Error(`missing provider source config for prepared source ${preparedSource.source_id}`);
123
+ }
124
+ sourcesById.set(preparedSource.source_id, source);
103
125
  });
104
126
  const signedUrlFlow = prepared.signed_url_flow ?? input.signedUrlFlow ?? "signed_url_v1";
105
127
  const checksumCache = new ChunkChecksumCache({
106
- concurrency: 4,
128
+ concurrency: input.checksumReadConcurrency ?? 8,
107
129
  expiresIn,
108
- fetchImpl: this.fetchImpl
130
+ fetchImpl: this.fetchImpl,
131
+ provider: input.checksumProvider,
132
+ groupSizeBytes: input.checksumGroupSizeBytes ?? 256 * 1024 * 1024,
133
+ onEvent: input.onChecksumEvent
109
134
  });
110
135
  if (signedUrlFlow === "signed_url_v1") {
136
+ const chunksBySource = new Map();
111
137
  for (const chunk of prepared.chunk_plan) {
112
138
  const needsChecksum = chunk.destinations.some((target) => {
113
139
  const destination = destinationsById.get(target.destination_id);
@@ -115,9 +141,14 @@ export class BeamClient {
115
141
  });
116
142
  if (!needsChecksum)
117
143
  continue;
118
- const source = sourcesById.get(chunk.source_id);
144
+ const chunks = chunksBySource.get(chunk.source_id) ?? [];
145
+ chunks.push(chunk);
146
+ chunksBySource.set(chunk.source_id, chunks);
147
+ }
148
+ for (const [sourceId, chunks] of chunksBySource) {
149
+ const source = sourcesById.get(sourceId);
119
150
  if (source)
120
- checksumCache.preload(source, chunk);
151
+ checksumCache.preloadMany(source, chunks);
121
152
  }
122
153
  }
123
154
  const routes = [];
@@ -350,22 +381,120 @@ class ChunkChecksumCache {
350
381
  constructor(options) {
351
382
  this.options = options;
352
383
  }
353
- preload(source, chunk) {
354
- void this.get(source, chunk).catch(() => undefined);
384
+ preloadMany(source, chunks) {
385
+ const pending = this.uniqueChunks(chunks).filter((chunk) => !this.cache.has(this.key(chunk)));
386
+ for (const chunk of pending) {
387
+ this.cache.set(this.key(chunk), deferredChecksum());
388
+ }
389
+ for (const group of this.groupChunks(pending)) {
390
+ void this.run(() => this.fillGroup(source, group)).catch(() => undefined);
391
+ }
355
392
  }
356
393
  get(source, chunk) {
357
- const key = `${chunk.source_id}:${chunk.source_chunk_index}`;
394
+ const key = this.key(chunk);
358
395
  let existing = this.cache.get(key);
359
396
  if (!existing) {
360
- existing = this.run(() => computeChunkContentMd5({
397
+ existing = deferredChecksum();
398
+ this.cache.set(key, existing);
399
+ void this.run(() => this.fillGroup(source, [chunk])).catch(() => undefined);
400
+ }
401
+ return existing.promise;
402
+ }
403
+ async fillGroup(source, chunks) {
404
+ const unresolved = new Set(chunks.map((chunk) => this.key(chunk)));
405
+ try {
406
+ const misses = [];
407
+ for (const chunk of chunks) {
408
+ const key = this.key(chunk);
409
+ const deferred = this.cache.get(key);
410
+ if (!deferred)
411
+ continue;
412
+ const cached = await this.options.provider?.getContentMd5({ source, chunk, algorithm: "content-md5" }).catch(() => undefined);
413
+ if (cached) {
414
+ this.emit({ event: "checksum_cache_hit", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
415
+ deferred.resolve(cached);
416
+ unresolved.delete(key);
417
+ }
418
+ else {
419
+ this.emit({ event: "checksum_cache_miss", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
420
+ misses.push(chunk);
421
+ }
422
+ }
423
+ if (!misses.length)
424
+ return;
425
+ const startedAt = Date.now();
426
+ const checksums = await computeChunksContentMd5({
361
427
  source,
362
- chunk,
428
+ chunks: misses,
363
429
  expiresIn: this.options.expiresIn,
364
- fetchImpl: this.options.fetchImpl
365
- }));
366
- this.cache.set(key, existing);
430
+ fetchImpl: this.options.fetchImpl,
431
+ groupSizeBytes: this.options.groupSizeBytes
432
+ });
433
+ for (const chunk of misses) {
434
+ const checksum = checksums.get(chunk.source_chunk_index);
435
+ if (!checksum)
436
+ throw new Error(`checksum missing for source chunk ${chunk.source_chunk_index}`);
437
+ await this.options.provider?.setContentMd5?.({ source, chunk, algorithm: "content-md5", contentMd5: checksum });
438
+ const deferred = this.cache.get(this.key(chunk));
439
+ deferred?.resolve(checksum);
440
+ unresolved.delete(this.key(chunk));
441
+ this.emit({ event: "checksum_cold_chunk_cached", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
442
+ }
443
+ this.emit({
444
+ event: "checksum_cold_group_completed",
445
+ sourceId: misses[0]?.source_id ?? source.id ?? "source",
446
+ chunkCount: misses.length,
447
+ bytes: misses.reduce((sum, chunk) => sum + chunk.chunk_size, 0),
448
+ durationMs: Date.now() - startedAt
449
+ });
450
+ }
451
+ catch (error) {
452
+ for (const key of unresolved) {
453
+ this.cache.get(key)?.reject(error);
454
+ }
455
+ throw error;
367
456
  }
368
- return existing;
457
+ }
458
+ uniqueChunks(chunks) {
459
+ const seen = new Set();
460
+ const output = [];
461
+ for (const chunk of chunks) {
462
+ const key = this.key(chunk);
463
+ if (seen.has(key))
464
+ continue;
465
+ seen.add(key);
466
+ output.push(chunk);
467
+ }
468
+ return output.sort((a, b) => a.source_offset - b.source_offset || a.source_chunk_index - b.source_chunk_index);
469
+ }
470
+ groupChunks(chunks) {
471
+ const groups = [];
472
+ let current = [];
473
+ let groupStart = 0;
474
+ let groupEnd = 0;
475
+ const maxBytes = Math.max(1, Math.floor(this.options.groupSizeBytes));
476
+ for (const chunk of chunks) {
477
+ const chunkStart = chunk.source_offset;
478
+ const chunkEnd = chunk.source_offset + chunk.chunk_size;
479
+ const startsNewGroup = current.length === 0 || chunkStart !== groupEnd || chunkEnd - groupStart > maxBytes;
480
+ if (startsNewGroup) {
481
+ if (current.length)
482
+ groups.push(current);
483
+ current = [chunk];
484
+ groupStart = chunkStart;
485
+ groupEnd = chunkEnd;
486
+ }
487
+ else {
488
+ current.push(chunk);
489
+ groupEnd = chunkEnd;
490
+ }
491
+ }
492
+ if (current.length)
493
+ groups.push(current);
494
+ return groups;
495
+ }
496
+ key(chunk) {
497
+ return `${chunk.source_id}:${chunk.source_chunk_index}`;
369
498
  }
370
499
  run(task) {
371
500
  return new Promise((resolve, reject) => {
@@ -382,6 +511,20 @@ class ChunkChecksumCache {
382
511
  this.queue.push(start);
383
512
  });
384
513
  }
514
+ emit(event) {
515
+ if (!this.options.onEvent)
516
+ return;
517
+ void Promise.resolve(this.options.onEvent(event)).catch(() => undefined);
518
+ }
519
+ }
520
+ function deferredChecksum() {
521
+ let resolve;
522
+ let reject;
523
+ const promise = new Promise((res, rej) => {
524
+ resolve = res;
525
+ reject = rej;
526
+ });
527
+ return { promise, resolve, reject };
385
528
  }
386
529
  export function calculateOptimalChunkSize(totalSize) {
387
530
  const mb = 1024 * 1024;
package/dist/models.d.ts CHANGED
@@ -44,6 +44,7 @@ export interface TransferCreateRequest {
44
44
  chunk_hashes?: string[];
45
45
  callbacks?: CallbackConfig[];
46
46
  test_mode?: boolean;
47
+ experimental?: boolean;
47
48
  progressive_mode?: boolean;
48
49
  signed_url_flow?: SignedUrlFlow;
49
50
  }
@@ -60,12 +61,17 @@ export interface RawTransferCreateInput {
60
61
  * Maps to BeamCore test_mode.
61
62
  */
62
63
  testMode?: boolean;
64
+ /**
65
+ * Experimental transfers keep positive evidence but exclude failures and overseer interventions from PRISM.
66
+ */
67
+ experimental?: boolean;
63
68
  progressiveMode?: boolean;
64
69
  signedUrlFlow?: SignedUrlFlow;
65
70
  }
66
71
  export interface TransferCreateResponse {
67
72
  success: boolean;
68
73
  transfer_id: string;
74
+ experimental?: boolean;
69
75
  total_chunks: number;
70
76
  total_sources: number;
71
77
  total_destinations: number;
@@ -109,6 +115,7 @@ export interface TransferStatusInfo {
109
115
  transfer_id: string;
110
116
  status: string;
111
117
  error_message: string | null;
118
+ experimental?: boolean;
112
119
  started_at: string | null;
113
120
  completed_at: string | null;
114
121
  }
@@ -157,6 +164,26 @@ export interface HippiusProviderConfig {
157
164
  }
158
165
  export type ProviderConfig = S3ProviderConfig | R2ProviderConfig | S3CompatibleProviderConfig | HippiusProviderConfig;
159
166
  export type ProviderSourceConfig = ProviderConfig;
167
+ export interface ChunkChecksumProviderGetInput {
168
+ source: ProviderSourceConfig;
169
+ chunk: ChunkSigningPlanItem;
170
+ algorithm: "content-md5";
171
+ }
172
+ export interface ChunkChecksumProviderSetInput extends ChunkChecksumProviderGetInput {
173
+ contentMd5: string;
174
+ }
175
+ export interface ChunkChecksumProvider {
176
+ getContentMd5(input: ChunkChecksumProviderGetInput): Promise<string | null | undefined>;
177
+ setContentMd5?(input: ChunkChecksumProviderSetInput): Promise<void>;
178
+ }
179
+ export interface ChunkChecksumEvent {
180
+ event: "checksum_cache_hit" | "checksum_cache_miss" | "checksum_cold_chunk_cached" | "checksum_cold_group_completed";
181
+ sourceId: string;
182
+ sourceChunkIndex?: number;
183
+ chunkCount?: number;
184
+ bytes?: number;
185
+ durationMs?: number;
186
+ }
160
187
  export type ProviderDestinationConfig = ProviderConfig;
161
188
  export type S3ProviderConfigInput = Omit<S3ProviderConfig, "provider">;
162
189
  export type R2ProviderConfigInput = Omit<R2ProviderConfig, "provider">;
@@ -182,6 +209,10 @@ export interface ProviderTransferCreateInput {
182
209
  * Maps to BeamCore test_mode.
183
210
  */
184
211
  testMode?: boolean;
212
+ /**
213
+ * Experimental transfers keep positive evidence but exclude failures and overseer interventions from PRISM.
214
+ */
215
+ experimental?: boolean;
185
216
  expiresIn?: number;
186
217
  /**
187
218
  * Defaults to true. Set to false to only prepare and attach signed URLs.
@@ -190,6 +221,10 @@ export interface ProviderTransferCreateInput {
190
221
  onPrepared?: (prepared: TransferPrepareResponse) => void | Promise<void>;
191
222
  throwIfCancelled?: (transferId: string) => void | Promise<void>;
192
223
  signedUrlFlow?: SignedUrlFlow;
224
+ checksumProvider?: ChunkChecksumProvider;
225
+ checksumReadConcurrency?: number;
226
+ checksumGroupSizeBytes?: number;
227
+ onChecksumEvent?: (event: ChunkChecksumEvent) => void | Promise<void>;
193
228
  }
194
229
  export interface PreparedHttpSource {
195
230
  source_id: string;
@@ -237,11 +272,25 @@ export interface SignedChunkRoute {
237
272
  dest_headers?: Record<string, string>;
238
273
  metadata?: Record<string, unknown>;
239
274
  }
275
+ export interface TransferPlanResponse {
276
+ success: boolean;
277
+ chunk_size?: number;
278
+ total_size?: number;
279
+ total_sources?: number;
280
+ total_destinations?: number;
281
+ logical_chunks?: number;
282
+ total_chunks?: number;
283
+ chunk_plan: ChunkSigningPlanItem[];
284
+ signed_url_flow?: SignedUrlFlow;
285
+ error?: string;
286
+ message?: string;
287
+ }
240
288
  export interface TransferPrepareResponse {
241
289
  success: boolean;
242
290
  transfer_id: string;
243
291
  transfer_key?: string;
244
292
  test_mode?: boolean;
293
+ experimental?: boolean;
245
294
  chunk_size?: number;
246
295
  total_size?: number;
247
296
  total_sources?: number;
@@ -51,6 +51,13 @@ export declare function signDestinationRoute(input: {
51
51
  deleteUrl?: string;
52
52
  fetchImpl?: typeof fetch;
53
53
  }): Promise<SignedChunkRoute>;
54
+ export declare function computeChunksContentMd5(input: {
55
+ source: ProviderSourceConfig;
56
+ chunks: ChunkSigningPlanItem[];
57
+ expiresIn: number;
58
+ fetchImpl?: typeof fetch;
59
+ groupSizeBytes?: number;
60
+ }): Promise<Map<number, string>>;
54
61
  export declare function computeChunkContentMd5(input: {
55
62
  source: ProviderSourceConfig;
56
63
  chunk: ChunkSigningPlanItem;
@@ -23,7 +23,13 @@ export async function prepareProviderSource(source, options = {}) {
23
23
  size: Number(head.ContentLength ?? 0),
24
24
  filename: filename(source.key),
25
25
  expires_at: expiresAtIso(expiresIn),
26
- metadata: s3CompatibleMetadata(source, endpoint)
26
+ metadata: {
27
+ ...s3CompatibleMetadata(source, endpoint),
28
+ content_length: Number(head.ContentLength ?? 0),
29
+ ...(head.ETag ? { etag: head.ETag } : {}),
30
+ ...(head.LastModified ? { last_modified: head.LastModified.toISOString() } : {}),
31
+ ...(head.VersionId ? { version_id: head.VersionId } : {})
32
+ }
27
33
  };
28
34
  }
29
35
  if (isHippiusProvider(source)) {
@@ -221,8 +227,11 @@ export async function signDestinationRoute(input) {
221
227
  };
222
228
  }
223
229
  function rangeHeaderForChunk(chunk) {
224
- const start = Math.max(0, Math.floor(chunk.source_offset));
225
- const size = Math.max(1, Math.floor(chunk.chunk_size));
230
+ return rangeHeaderForRange(chunk.source_offset, chunk.chunk_size);
231
+ }
232
+ function rangeHeaderForRange(sourceOffset, chunkSize) {
233
+ const start = Math.max(0, Math.floor(sourceOffset));
234
+ const size = Math.max(1, Math.floor(chunkSize));
226
235
  return `bytes=${start}-${start + size - 1}`;
227
236
  }
228
237
  async function signSourceRoute(input) {
@@ -249,6 +258,27 @@ async function signSourceRoute(input) {
249
258
  }
250
259
  return unsupportedProviderConfig(input.source);
251
260
  }
261
+ async function signSourceRangeRoute(input) {
262
+ const range = rangeHeaderForRange(input.sourceOffset, input.chunkSize);
263
+ if (isS3CompatibleProvider(input.source)) {
264
+ const client = createS3CompatibleClient(input.source);
265
+ return {
266
+ url: await getSignedUrl(client, new GetObjectCommand({
267
+ Bucket: input.source.bucket,
268
+ Key: input.source.key,
269
+ Range: range
270
+ }), { expiresIn: input.expiresIn }),
271
+ headers: { Range: range }
272
+ };
273
+ }
274
+ if (isHippiusProvider(input.source)) {
275
+ return {
276
+ url: await hippiusPresign(input.fetchImpl ?? globalThis.fetch, input.source.base_url ?? "https://api.hippius.com", input.source.api_token, input.source.bucket, input.source.key, "get", input.expiresIn),
277
+ headers: { Range: range }
278
+ };
279
+ }
280
+ return { url: input.fallbackUrl, headers: { Range: range } };
281
+ }
252
282
  async function signDestinationUrl(input) {
253
283
  if (isS3CompatibleProvider(input.destination)) {
254
284
  const client = createS3CompatibleClient(input.destination);
@@ -268,27 +298,168 @@ async function signDestinationUrl(input) {
268
298
  }
269
299
  return unsupportedProviderConfig(input.destination);
270
300
  }
301
+ export async function computeChunksContentMd5(input) {
302
+ const output = new Map();
303
+ const chunks = [...input.chunks].sort((a, b) => a.source_offset - b.source_offset || a.source_chunk_index - b.source_chunk_index);
304
+ const groups = groupContiguousChunks(chunks, input.groupSizeBytes ?? 256 * 1024 * 1024);
305
+ for (const group of groups) {
306
+ const groupOutput = await retryChecksumRead(() => computeChunkGroupContentMd5({
307
+ source: input.source,
308
+ chunks: group,
309
+ expiresIn: input.expiresIn,
310
+ fetchImpl: input.fetchImpl
311
+ }));
312
+ for (const [sourceChunkIndex, checksum] of groupOutput) {
313
+ output.set(sourceChunkIndex, checksum);
314
+ }
315
+ }
316
+ return output;
317
+ }
271
318
  export async function computeChunkContentMd5(input) {
272
- const sourceRoute = await signSourceRoute({
319
+ const checksums = await computeChunksContentMd5({
273
320
  source: input.source,
274
- fallbackUrl: input.chunk.source_url,
275
- chunk: input.chunk,
321
+ chunks: [input.chunk],
322
+ expiresIn: input.expiresIn,
323
+ fetchImpl: input.fetchImpl,
324
+ groupSizeBytes: input.chunk.chunk_size
325
+ });
326
+ const checksum = checksums.get(input.chunk.source_chunk_index);
327
+ if (!checksum) {
328
+ throw new Error(`chunk checksum missing for source chunk ${input.chunk.source_chunk_index}`);
329
+ }
330
+ return checksum;
331
+ }
332
+ function groupContiguousChunks(chunks, maxGroupSizeBytes) {
333
+ const groups = [];
334
+ let current = [];
335
+ let groupStart = 0;
336
+ let groupEnd = 0;
337
+ const maxBytes = Math.max(1, Math.floor(maxGroupSizeBytes));
338
+ for (const chunk of chunks) {
339
+ const chunkStart = Math.max(0, Math.floor(chunk.source_offset));
340
+ const chunkEnd = chunkStart + Math.max(1, Math.floor(chunk.chunk_size));
341
+ const startsNewGroup = current.length === 0 ||
342
+ chunkStart !== groupEnd ||
343
+ chunkEnd - groupStart > maxBytes;
344
+ if (startsNewGroup) {
345
+ if (current.length)
346
+ groups.push(current);
347
+ current = [chunk];
348
+ groupStart = chunkStart;
349
+ groupEnd = chunkEnd;
350
+ continue;
351
+ }
352
+ current.push(chunk);
353
+ groupEnd = chunkEnd;
354
+ }
355
+ if (current.length)
356
+ groups.push(current);
357
+ return groups;
358
+ }
359
+ async function computeChunkGroupContentMd5(input) {
360
+ if (!input.chunks.length)
361
+ return new Map();
362
+ const chunks = [...input.chunks].sort((a, b) => a.source_offset - b.source_offset || a.source_chunk_index - b.source_chunk_index);
363
+ const first = chunks[0];
364
+ const last = chunks[chunks.length - 1];
365
+ const sourceOffset = first.source_offset;
366
+ const totalSize = last.source_offset + last.chunk_size - sourceOffset;
367
+ const sourceRoute = await signSourceRangeRoute({
368
+ source: input.source,
369
+ fallbackUrl: first.source_url,
370
+ sourceOffset,
371
+ chunkSize: totalSize,
276
372
  expiresIn: input.expiresIn,
277
373
  fetchImpl: input.fetchImpl
278
374
  });
279
375
  const fetchImpl = input.fetchImpl ?? globalThis.fetch;
280
376
  const response = await fetchImpl(sourceRoute.url, { method: "GET", headers: sourceRoute.headers });
281
377
  if (!response.ok) {
282
- throw new Error(`chunk checksum fetch failed with ${response.status}: ${await response.text()}`);
378
+ const message = `chunk checksum fetch failed with ${response.status}: ${await response.text()}`;
379
+ throw Object.assign(new Error(message), { status: response.status });
283
380
  }
284
- const body = Buffer.from(await response.arrayBuffer());
285
- if (body.length !== input.chunk.chunk_size) {
286
- throw new Error(`chunk checksum fetch size mismatch: expected ${input.chunk.chunk_size}, got ${body.length}`);
381
+ const output = new Map();
382
+ let chunkIndex = 0;
383
+ let bytesInChunk = 0;
384
+ let totalRead = 0;
385
+ let hash = createHash("md5");
386
+ const consume = (value) => {
387
+ let offset = 0;
388
+ while (offset < value.byteLength) {
389
+ const chunk = chunks[chunkIndex];
390
+ if (!chunk) {
391
+ throw new Error(`chunk checksum fetch size mismatch: read more than ${totalSize} bytes`);
392
+ }
393
+ const remainingInChunk = chunk.chunk_size - bytesInChunk;
394
+ const take = Math.min(remainingInChunk, value.byteLength - offset);
395
+ hash.update(value.subarray(offset, offset + take));
396
+ offset += take;
397
+ totalRead += take;
398
+ bytesInChunk += take;
399
+ if (bytesInChunk === chunk.chunk_size) {
400
+ output.set(chunk.source_chunk_index, hash.digest("base64"));
401
+ chunkIndex += 1;
402
+ bytesInChunk = 0;
403
+ hash = createHash("md5");
404
+ }
405
+ }
406
+ };
407
+ if (response.body) {
408
+ const reader = response.body.getReader();
409
+ while (true) {
410
+ const { done, value } = await reader.read();
411
+ if (done)
412
+ break;
413
+ if (value)
414
+ consume(value);
415
+ }
287
416
  }
288
- return createHash("md5").update(body).digest("base64");
417
+ else {
418
+ consume(new Uint8Array(await response.arrayBuffer()));
419
+ }
420
+ if (totalRead !== totalSize || output.size !== chunks.length) {
421
+ throw new Error(`chunk checksum fetch size mismatch: expected ${totalSize}, got ${totalRead}`);
422
+ }
423
+ return output;
424
+ }
425
+ async function retryChecksumRead(task) {
426
+ let lastError;
427
+ for (let attempt = 0; attempt < 3; attempt += 1) {
428
+ try {
429
+ return await task();
430
+ }
431
+ catch (error) {
432
+ lastError = error;
433
+ const status = typeof error === "object" && error && "status" in error ? Number(error.status) : undefined;
434
+ if (status && status >= 400 && status < 500 && status !== 408 && status !== 429)
435
+ break;
436
+ if (attempt < 2)
437
+ await sleep(250 * (attempt + 1));
438
+ }
439
+ }
440
+ throw lastError;
441
+ }
442
+ function sleep(ms) {
443
+ return new Promise((resolve) => setTimeout(resolve, ms));
444
+ }
445
+ const s3ClientCache = new Map();
446
+ function s3ClientCacheKey(source, endpoint) {
447
+ return JSON.stringify({
448
+ provider: source.provider,
449
+ endpoint,
450
+ region: s3CompatibleRegion(source),
451
+ forcePathStyle: s3CompatibleForcePathStyle(source, endpoint),
452
+ accessKeyId: source.access_key_id,
453
+ secretAccessKey: source.secret_access_key,
454
+ sessionToken: "session_token" in source ? source.session_token : undefined
455
+ });
289
456
  }
290
457
  function createS3CompatibleClient(source, endpoint = s3CompatibleEndpoint(source)) {
291
- return new S3Client({
458
+ const cacheKey = s3ClientCacheKey(source, endpoint);
459
+ const cached = s3ClientCache.get(cacheKey);
460
+ if (cached)
461
+ return cached;
462
+ const client = new S3Client({
292
463
  region: s3CompatibleRegion(source),
293
464
  endpoint,
294
465
  forcePathStyle: s3CompatibleForcePathStyle(source, endpoint),
@@ -298,6 +469,8 @@ function createS3CompatibleClient(source, endpoint = s3CompatibleEndpoint(source
298
469
  sessionToken: "session_token" in source ? source.session_token : undefined
299
470
  }
300
471
  });
472
+ s3ClientCache.set(cacheKey, client);
473
+ return client;
301
474
  }
302
475
  export function isS3CompatibleProvider(config) {
303
476
  if (config.provider === "s3" || config.provider === "r2") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beam-network/sdk",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "TypeScript SDK for BEAM transfer creation and management.",
5
5
  "type": "module",
6
6
  "license": "MIT",