@beam-network/sdk 0.2.9 → 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, 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,12 +15,23 @@ 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;
34
+ signedUrlFlow?: SignedUrlFlow;
24
35
  }): Promise<TransferPrepareResponse>;
25
36
  attachSignedUrls(transferId: string, input: {
26
37
  chunkRoutes: SignedChunkRoute[];
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { abortMultipartUpload, createMultipartUpload, nowIso, prepareProviderDestination, prepareProviderSource, 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,7 +36,9 @@ export class BeamClient {
36
36
  chunk_hashes: input.chunkHashes,
37
37
  callbacks: input.callbacks,
38
38
  test_mode: input.testMode || undefined,
39
- progressive_mode: input.progressiveMode || undefined
39
+ experimental: input.experimental || undefined,
40
+ progressive_mode: input.progressiveMode || undefined,
41
+ signed_url_flow: input.signedUrlFlow
40
42
  });
41
43
  return this.request("POST", "/transfers/create", body);
42
44
  }
@@ -57,13 +59,26 @@ export class BeamClient {
57
59
  validateId(transferId, "transferId");
58
60
  return this.request("DELETE", `/transfers/${encodeURIComponent(transferId)}`);
59
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
+ }
60
73
  prepareTransfer(input) {
61
74
  return this.request("POST", "/transfers/prepare", compact({
62
75
  sources: input.sources,
63
76
  destinations: input.destinations,
64
77
  name: input.name,
65
78
  test_mode: input.testMode || undefined,
66
- urls_expires_at: input.urlsExpiresAt
79
+ experimental: input.experimental || undefined,
80
+ urls_expires_at: input.urlsExpiresAt,
81
+ signed_url_flow: input.signedUrlFlow
67
82
  }));
68
83
  }
69
84
  attachSignedUrls(transferId, input) {
@@ -83,7 +98,9 @@ export class BeamClient {
83
98
  sources: preparedSources,
84
99
  destinations: preparedDestinations,
85
100
  name: input.name,
86
- testMode: input.testMode
101
+ testMode: input.testMode,
102
+ experimental: input.experimental,
103
+ signedUrlFlow: input.signedUrlFlow
87
104
  });
88
105
  if (!prepared.success) {
89
106
  return prepared;
@@ -92,12 +109,48 @@ export class BeamClient {
92
109
  await input.throwIfCancelled?.(prepared.transfer_id);
93
110
  const destinationsById = new Map();
94
111
  preparedDestinations.forEach((preparedDestination, index) => {
95
- 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);
96
117
  });
97
118
  const sourcesById = new Map();
98
119
  preparedSources.forEach((preparedSource, index) => {
99
- 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);
125
+ });
126
+ const signedUrlFlow = prepared.signed_url_flow ?? input.signedUrlFlow ?? "signed_url_v1";
127
+ const checksumCache = new ChunkChecksumCache({
128
+ concurrency: input.checksumReadConcurrency ?? 8,
129
+ expiresIn,
130
+ fetchImpl: this.fetchImpl,
131
+ provider: input.checksumProvider,
132
+ groupSizeBytes: input.checksumGroupSizeBytes ?? 256 * 1024 * 1024,
133
+ onEvent: input.onChecksumEvent
100
134
  });
135
+ if (signedUrlFlow === "signed_url_v1") {
136
+ const chunksBySource = new Map();
137
+ for (const chunk of prepared.chunk_plan) {
138
+ const needsChecksum = chunk.destinations.some((target) => {
139
+ const destination = destinationsById.get(target.destination_id);
140
+ return destination ? isS3CompatibleProvider(destination) : false;
141
+ });
142
+ if (!needsChecksum)
143
+ continue;
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);
150
+ if (source)
151
+ checksumCache.preloadMany(source, chunks);
152
+ }
153
+ }
101
154
  const routes = [];
102
155
  const multipartUploads = new Map();
103
156
  try {
@@ -161,7 +214,9 @@ export class BeamClient {
161
214
  finalObjectKey,
162
215
  partNumber: typeof metadata.part_number === "number"
163
216
  ? metadata.part_number
164
- : chunk.source_chunk_index + 1
217
+ : chunk.source_chunk_index + 1,
218
+ signedUrlFlow,
219
+ checksumCache
165
220
  }));
166
221
  }
167
222
  }
@@ -207,6 +262,32 @@ export class BeamClient {
207
262
  if (!input.target.object_key) {
208
263
  throw new Error("destination signing target is missing object_key");
209
264
  }
265
+ if (input.signedUrlFlow === "signed_url_v1") {
266
+ const contentMd5 = await input.checksumCache.get(input.source, input.chunk);
267
+ const partVerifyUrl = await signListMultipartUpload(input.destination, input.finalObjectKey, input.upload.uploadId, input.expiresIn, { maxParts: 1, partNumberMarker: listPartsVerifyMarker(input.partNumber) });
268
+ const listPageUrl = await signListMultipartUpload(input.destination, input.finalObjectKey, input.upload.uploadId, input.expiresIn, { maxParts: 1000, partNumberMarker: listPartsPageMarker(input.partNumber) });
269
+ return signDestinationRoute({
270
+ chunk: input.chunk,
271
+ target: input.target,
272
+ source: input.source,
273
+ destination: input.destination,
274
+ expiresIn: input.expiresIn,
275
+ uploadId: input.upload.uploadId,
276
+ partNumber: input.partNumber,
277
+ multipartObjectKey: input.finalObjectKey,
278
+ completeUrl: input.upload.completeUrl,
279
+ abortUrl: input.upload.abortUrl,
280
+ listUrl: input.upload.listUrl,
281
+ partVerifyUrl,
282
+ listPageUrl,
283
+ contentMd5,
284
+ transferId: input.transferId,
285
+ multipartGroupId: input.upload.multipartGroupId,
286
+ multipartCreatedAt: input.upload.multipartCreatedAt,
287
+ commitMethod: "upload_part",
288
+ fetchImpl: this.fetchImpl
289
+ });
290
+ }
210
291
  const stagingObjectKey = buildStagingObjectKey({
211
292
  finalObjectKey: input.finalObjectKey,
212
293
  transferId: input.transferId,
@@ -292,6 +373,159 @@ export class BeamClient {
292
373
  return (text ? JSON.parse(text) : {});
293
374
  }
294
375
  }
376
+ class ChunkChecksumCache {
377
+ options;
378
+ queue = [];
379
+ cache = new Map();
380
+ active = 0;
381
+ constructor(options) {
382
+ this.options = options;
383
+ }
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
+ }
392
+ }
393
+ get(source, chunk) {
394
+ const key = this.key(chunk);
395
+ let existing = this.cache.get(key);
396
+ if (!existing) {
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({
427
+ source,
428
+ chunks: misses,
429
+ expiresIn: this.options.expiresIn,
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;
456
+ }
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}`;
498
+ }
499
+ run(task) {
500
+ return new Promise((resolve, reject) => {
501
+ const start = () => {
502
+ this.active += 1;
503
+ task().then(resolve, reject).finally(() => {
504
+ this.active -= 1;
505
+ this.queue.shift()?.();
506
+ });
507
+ };
508
+ if (this.active < Math.max(1, this.options.concurrency))
509
+ start();
510
+ else
511
+ this.queue.push(start);
512
+ });
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 };
528
+ }
295
529
  export function calculateOptimalChunkSize(totalSize) {
296
530
  const mb = 1024 * 1024;
297
531
  const gb = 1024 * mb;
package/dist/models.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export type SignedUrlFlow = "signed_url_v1" | "signed_url_v2";
1
2
  export interface BeamClientOptions {
2
3
  apiKey?: string;
3
4
  baseUrl?: string;
@@ -43,7 +44,9 @@ export interface TransferCreateRequest {
43
44
  chunk_hashes?: string[];
44
45
  callbacks?: CallbackConfig[];
45
46
  test_mode?: boolean;
47
+ experimental?: boolean;
46
48
  progressive_mode?: boolean;
49
+ signed_url_flow?: SignedUrlFlow;
47
50
  }
48
51
  export interface RawTransferCreateInput {
49
52
  sources: SourceConfig[];
@@ -58,11 +61,17 @@ export interface RawTransferCreateInput {
58
61
  * Maps to BeamCore test_mode.
59
62
  */
60
63
  testMode?: boolean;
64
+ /**
65
+ * Experimental transfers keep positive evidence but exclude failures and overseer interventions from PRISM.
66
+ */
67
+ experimental?: boolean;
61
68
  progressiveMode?: boolean;
69
+ signedUrlFlow?: SignedUrlFlow;
62
70
  }
63
71
  export interface TransferCreateResponse {
64
72
  success: boolean;
65
73
  transfer_id: string;
74
+ experimental?: boolean;
66
75
  total_chunks: number;
67
76
  total_sources: number;
68
77
  total_destinations: number;
@@ -106,6 +115,7 @@ export interface TransferStatusInfo {
106
115
  transfer_id: string;
107
116
  status: string;
108
117
  error_message: string | null;
118
+ experimental?: boolean;
109
119
  started_at: string | null;
110
120
  completed_at: string | null;
111
121
  }
@@ -154,6 +164,26 @@ export interface HippiusProviderConfig {
154
164
  }
155
165
  export type ProviderConfig = S3ProviderConfig | R2ProviderConfig | S3CompatibleProviderConfig | HippiusProviderConfig;
156
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
+ }
157
187
  export type ProviderDestinationConfig = ProviderConfig;
158
188
  export type S3ProviderConfigInput = Omit<S3ProviderConfig, "provider">;
159
189
  export type R2ProviderConfigInput = Omit<R2ProviderConfig, "provider">;
@@ -179,6 +209,10 @@ export interface ProviderTransferCreateInput {
179
209
  * Maps to BeamCore test_mode.
180
210
  */
181
211
  testMode?: boolean;
212
+ /**
213
+ * Experimental transfers keep positive evidence but exclude failures and overseer interventions from PRISM.
214
+ */
215
+ experimental?: boolean;
182
216
  expiresIn?: number;
183
217
  /**
184
218
  * Defaults to true. Set to false to only prepare and attach signed URLs.
@@ -186,6 +220,11 @@ export interface ProviderTransferCreateInput {
186
220
  distribute?: boolean;
187
221
  onPrepared?: (prepared: TransferPrepareResponse) => void | Promise<void>;
188
222
  throwIfCancelled?: (transferId: string) => void | Promise<void>;
223
+ signedUrlFlow?: SignedUrlFlow;
224
+ checksumProvider?: ChunkChecksumProvider;
225
+ checksumReadConcurrency?: number;
226
+ checksumGroupSizeBytes?: number;
227
+ onChecksumEvent?: (event: ChunkChecksumEvent) => void | Promise<void>;
189
228
  }
190
229
  export interface PreparedHttpSource {
191
230
  source_id: string;
@@ -230,13 +269,28 @@ export interface SignedChunkRoute {
230
269
  chunk_size: number;
231
270
  expires_at?: string;
232
271
  headers?: Record<string, string>;
272
+ dest_headers?: Record<string, string>;
233
273
  metadata?: Record<string, unknown>;
234
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
+ }
235
288
  export interface TransferPrepareResponse {
236
289
  success: boolean;
237
290
  transfer_id: string;
238
291
  transfer_key?: string;
239
292
  test_mode?: boolean;
293
+ experimental?: boolean;
240
294
  chunk_size?: number;
241
295
  total_size?: number;
242
296
  total_sources?: number;
@@ -244,6 +298,7 @@ export interface TransferPrepareResponse {
244
298
  logical_chunks?: number;
245
299
  total_chunks?: number;
246
300
  chunk_plan: ChunkSigningPlanItem[];
301
+ signed_url_flow?: SignedUrlFlow;
247
302
  error?: string;
248
303
  message?: string;
249
304
  }
@@ -13,7 +13,12 @@ export declare function prepareProviderDestination(destination: ProviderDestinat
13
13
  export declare function createMultipartUpload(destination: ProviderDestinationConfig, objectKey: string): Promise<string>;
14
14
  export declare function signCompleteMultipartUpload(destination: ProviderDestinationConfig, objectKey: string, uploadId: string, expiresIn: number): Promise<string>;
15
15
  export declare function signAbortMultipartUpload(destination: ProviderDestinationConfig, objectKey: string, uploadId: string, expiresIn: number): Promise<string>;
16
- export declare function signListMultipartUpload(destination: ProviderDestinationConfig, objectKey: string, uploadId: string, expiresIn: number): Promise<string>;
16
+ export declare function signListMultipartUpload(destination: ProviderDestinationConfig, objectKey: string, uploadId: string, expiresIn: number, options?: {
17
+ maxParts?: number;
18
+ partNumberMarker?: number;
19
+ }): Promise<string>;
20
+ export declare function listPartsPageMarker(partNumber: number, pageSize?: number): number | undefined;
21
+ export declare function listPartsVerifyMarker(partNumber: number): number | undefined;
17
22
  export declare function signUploadPartCopy(destination: ProviderDestinationConfig, finalObjectKey: string, stagingObjectKey: string, uploadId: string, partNumber: number, expiresIn: number, copySourceRange?: string): Promise<{
18
23
  copyUrl: string;
19
24
  copySource: string;
@@ -35,7 +40,10 @@ export declare function signDestinationRoute(input: {
35
40
  transferId?: string;
36
41
  multipartGroupId?: string;
37
42
  multipartCreatedAt?: string;
38
- commitMethod?: "upload_part_copy";
43
+ commitMethod?: "upload_part" | "upload_part_copy";
44
+ contentMd5?: string;
45
+ partVerifyUrl?: string;
46
+ listPageUrl?: string;
39
47
  stagingObjectKey?: string;
40
48
  copyUrl?: string;
41
49
  copySource?: string;
@@ -43,6 +51,19 @@ export declare function signDestinationRoute(input: {
43
51
  deleteUrl?: string;
44
52
  fetchImpl?: typeof fetch;
45
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>>;
61
+ export declare function computeChunkContentMd5(input: {
62
+ source: ProviderSourceConfig;
63
+ chunk: ChunkSigningPlanItem;
64
+ expiresIn: number;
65
+ fetchImpl?: typeof fetch;
66
+ }): Promise<string>;
46
67
  export declare function isS3CompatibleProvider(config: ProviderSourceConfig | ProviderDestinationConfig): config is AnyS3CompatibleProviderConfig;
47
68
  export declare function s3CompatibleEndpoint(source: AnyS3CompatibleProviderConfig): string | undefined;
48
69
  export declare function s3CompatibleRegion(source: AnyS3CompatibleProviderConfig): string;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListPartsCommand, PutObjectCommand, S3Client, UploadPartCopyCommand, UploadPartCommand } from "@aws-sdk/client-s3";
2
3
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
3
4
  export function nowIso() {
@@ -22,7 +23,13 @@ export async function prepareProviderSource(source, options = {}) {
22
23
  size: Number(head.ContentLength ?? 0),
23
24
  filename: filename(source.key),
24
25
  expires_at: expiresAtIso(expiresIn),
25
- 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
+ }
26
33
  };
27
34
  }
28
35
  if (isHippiusProvider(source)) {
@@ -96,13 +103,26 @@ export async function signAbortMultipartUpload(destination, objectKey, uploadId,
96
103
  }
97
104
  throw new Error(`multipart abort is not supported for ${destination.provider}`);
98
105
  }
99
- export async function signListMultipartUpload(destination, objectKey, uploadId, expiresIn) {
106
+ export async function signListMultipartUpload(destination, objectKey, uploadId, expiresIn, options = {}) {
100
107
  if (isS3CompatibleProvider(destination)) {
101
108
  const client = createS3CompatibleClient(destination);
102
- return getSignedUrl(client, new ListPartsCommand({ Bucket: destination.bucket, Key: objectKey, UploadId: uploadId }), { expiresIn });
109
+ return getSignedUrl(client, new ListPartsCommand({
110
+ Bucket: destination.bucket,
111
+ Key: objectKey,
112
+ UploadId: uploadId,
113
+ MaxParts: options.maxParts,
114
+ PartNumberMarker: options.partNumberMarker === undefined ? undefined : String(options.partNumberMarker)
115
+ }), { expiresIn });
103
116
  }
104
117
  throw new Error(`multipart list-parts is not supported for ${destination.provider}`);
105
118
  }
119
+ export function listPartsPageMarker(partNumber, pageSize = 1000) {
120
+ const marker = Math.floor((Math.max(1, partNumber) - 1) / pageSize) * pageSize;
121
+ return marker > 0 ? marker : undefined;
122
+ }
123
+ export function listPartsVerifyMarker(partNumber) {
124
+ return partNumber > 1 ? partNumber - 1 : undefined;
125
+ }
106
126
  export async function signUploadPartCopy(destination, finalObjectKey, stagingObjectKey, uploadId, partNumber, expiresIn, copySourceRange) {
107
127
  if (isS3CompatibleProvider(destination)) {
108
128
  const client = createS3CompatibleClient(destination);
@@ -163,7 +183,8 @@ export async function signDestinationRoute(input) {
163
183
  uploadId: input.uploadId,
164
184
  partNumber: input.partNumber,
165
185
  useStagingPut: input.commitMethod === "upload_part_copy",
166
- fetchImpl: input.fetchImpl
186
+ fetchImpl: input.fetchImpl,
187
+ contentMd5: input.contentMd5
167
188
  });
168
189
  const sourceRoute = await signSourceRoute({
169
190
  source: input.source,
@@ -182,6 +203,7 @@ export async function signDestinationRoute(input) {
182
203
  chunk_size: input.chunk.chunk_size,
183
204
  expires_at: expiresAtIso(input.expiresIn),
184
205
  headers: sourceRoute.headers,
206
+ ...(input.contentMd5 ? { dest_headers: { "Content-MD5": input.contentMd5 } } : {}),
185
207
  metadata: {
186
208
  ...(input.target.metadata ?? {}),
187
209
  ...(input.transferId ? { transfer_id: input.transferId } : {}),
@@ -195,6 +217,9 @@ export async function signDestinationRoute(input) {
195
217
  ...(input.deleteUrl ? { delete_url: input.deleteUrl } : {}),
196
218
  ...(input.uploadId ? { upload_id: input.uploadId } : {}),
197
219
  ...(input.partNumber ? { part_number: input.partNumber } : {}),
220
+ ...(input.contentMd5 ? { checksum_algorithm: "content-md5", checksum_value: input.contentMd5 } : {}),
221
+ ...(input.partVerifyUrl ? { part_verify_url: input.partVerifyUrl } : {}),
222
+ ...(input.listPageUrl ? { list_page_url: input.listPageUrl } : {}),
198
223
  ...(input.completeUrl ? { complete_url: input.completeUrl } : {}),
199
224
  ...(input.abortUrl ? { abort_url: input.abortUrl } : {}),
200
225
  ...(input.listUrl ? { list_url: input.listUrl } : {})
@@ -202,8 +227,11 @@ export async function signDestinationRoute(input) {
202
227
  };
203
228
  }
204
229
  function rangeHeaderForChunk(chunk) {
205
- const start = Math.max(0, Math.floor(chunk.source_offset));
206
- 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));
207
235
  return `bytes=${start}-${start + size - 1}`;
208
236
  }
209
237
  async function signSourceRoute(input) {
@@ -230,6 +258,27 @@ async function signSourceRoute(input) {
230
258
  }
231
259
  return unsupportedProviderConfig(input.source);
232
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
+ }
233
282
  async function signDestinationUrl(input) {
234
283
  if (isS3CompatibleProvider(input.destination)) {
235
284
  const client = createS3CompatibleClient(input.destination);
@@ -238,7 +287,8 @@ async function signDestinationUrl(input) {
238
287
  Bucket: input.destination.bucket,
239
288
  Key: input.uploadPartObjectKey,
240
289
  UploadId: input.uploadId,
241
- PartNumber: input.partNumber
290
+ PartNumber: input.partNumber,
291
+ ContentMD5: input.contentMd5
242
292
  }), { expiresIn: input.expiresIn });
243
293
  }
244
294
  return getSignedUrl(client, new PutObjectCommand({ Bucket: input.destination.bucket, Key: input.objectKey }), { expiresIn: input.expiresIn });
@@ -248,8 +298,168 @@ async function signDestinationUrl(input) {
248
298
  }
249
299
  return unsupportedProviderConfig(input.destination);
250
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
+ }
318
+ export async function computeChunkContentMd5(input) {
319
+ const checksums = await computeChunksContentMd5({
320
+ source: input.source,
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,
372
+ expiresIn: input.expiresIn,
373
+ fetchImpl: input.fetchImpl
374
+ });
375
+ const fetchImpl = input.fetchImpl ?? globalThis.fetch;
376
+ const response = await fetchImpl(sourceRoute.url, { method: "GET", headers: sourceRoute.headers });
377
+ if (!response.ok) {
378
+ const message = `chunk checksum fetch failed with ${response.status}: ${await response.text()}`;
379
+ throw Object.assign(new Error(message), { status: response.status });
380
+ }
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
+ }
416
+ }
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
+ });
456
+ }
251
457
  function createS3CompatibleClient(source, endpoint = s3CompatibleEndpoint(source)) {
252
- 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({
253
463
  region: s3CompatibleRegion(source),
254
464
  endpoint,
255
465
  forcePathStyle: s3CompatibleForcePathStyle(source, endpoint),
@@ -259,6 +469,8 @@ function createS3CompatibleClient(source, endpoint = s3CompatibleEndpoint(source
259
469
  sessionToken: "session_token" in source ? source.session_token : undefined
260
470
  }
261
471
  });
472
+ s3ClientCache.set(cacheKey, client);
473
+ return client;
262
474
  }
263
475
  export function isS3CompatibleProvider(config) {
264
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.9",
3
+ "version": "0.2.11",
4
4
  "description": "TypeScript SDK for BEAM transfer creation and management.",
5
5
  "type": "module",
6
6
  "license": "MIT",