@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 +12 -1
- package/dist/client.js +241 -7
- package/dist/models.d.ts +55 -0
- package/dist/provider-signing.d.ts +23 -2
- package/dist/provider-signing.js +220 -8
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/provider-signing.js
CHANGED
|
@@ -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:
|
|
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({
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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") {
|