@beam-network/sdk 0.2.10 → 0.2.12
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 +11 -1
- package/dist/client.js +323 -39
- package/dist/models.d.ts +57 -3
- package/dist/provider-signing.d.ts +12 -1
- package/dist/provider-signing.js +285 -12
- 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, SignedUrlFlow, TransferCancelResponse, TransferCreateResponse, TransferPrepareResponse, TransferStatusInfo } from "./models.js";
|
|
1
|
+
import type { AttachSignedUrlsResponse, BeamClientOptions, DistributeResponse, PlanningHttpSource, 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: PlanningHttpSource[];
|
|
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,
|
|
1
|
+
import { abortMultipartUpload, computeChunksContentMd5, createMultipartUpload, nowIso, prepareProviderDestination, prepareProviderSource, prepareProviderSourceForPlan, 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
|
}));
|
|
@@ -79,45 +92,119 @@ export class BeamClient {
|
|
|
79
92
|
}
|
|
80
93
|
async prepareProviderTransfer(input) {
|
|
81
94
|
const expiresIn = input.expiresIn ?? 3600;
|
|
82
|
-
const
|
|
95
|
+
const requestedSignedUrlFlow = input.signedUrlFlow ?? "signed_url_v1";
|
|
83
96
|
const preparedDestinations = input.destinations.map((destination, index) => prepareProviderDestination(destination, { index }));
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
destinations
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
const destinationsById = new Map();
|
|
98
|
+
preparedDestinations.forEach((preparedDestination, index) => {
|
|
99
|
+
const destination = input.destinations[index];
|
|
100
|
+
if (!destination) {
|
|
101
|
+
throw new Error(`missing provider destination config for prepared destination ${preparedDestination.destination_id}`);
|
|
102
|
+
}
|
|
103
|
+
destinationsById.set(preparedDestination.destination_id, destination);
|
|
104
|
+
});
|
|
105
|
+
const checksumCache = new ChunkChecksumCache({
|
|
106
|
+
concurrency: input.checksumReadConcurrency ?? 8,
|
|
107
|
+
expiresIn,
|
|
108
|
+
fetchImpl: this.fetchImpl,
|
|
109
|
+
provider: input.checksumProvider,
|
|
110
|
+
groupSizeBytes: input.checksumGroupSizeBytes ?? 256 * 1024 * 1024,
|
|
111
|
+
onEvent: input.onChecksumEvent,
|
|
112
|
+
throwIfCancelled: () => input.throwIfCancelled?.()
|
|
90
113
|
});
|
|
91
|
-
|
|
92
|
-
|
|
114
|
+
let preparedSources;
|
|
115
|
+
let prepared;
|
|
116
|
+
if (requestedSignedUrlFlow === "signed_url_v1") {
|
|
117
|
+
await input.throwIfCancelled?.();
|
|
118
|
+
const plannedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSourceForPlan(source, { index, fetchImpl: this.fetchImpl })));
|
|
119
|
+
const planned = await this.planTransfer({
|
|
120
|
+
sources: plannedSources,
|
|
121
|
+
destinations: preparedDestinations,
|
|
122
|
+
name: input.name,
|
|
123
|
+
testMode: input.testMode,
|
|
124
|
+
experimental: input.experimental,
|
|
125
|
+
signedUrlFlow: requestedSignedUrlFlow
|
|
126
|
+
});
|
|
127
|
+
if (!planned.success) {
|
|
128
|
+
return failedPlanAsPrepareResponse(planned);
|
|
129
|
+
}
|
|
130
|
+
await input.onPlanned?.(planned);
|
|
131
|
+
await input.throwIfCancelled?.();
|
|
132
|
+
const plannedSourcesById = mapSourcesByPreparedId(plannedSources, input.sources);
|
|
133
|
+
const chunksBySource = checksumChunksBySource(planned.chunk_plan, destinationsById);
|
|
134
|
+
const totalWarmupChunks = [...chunksBySource.values()].reduce((sum, chunks) => sum + chunks.length, 0);
|
|
135
|
+
const totalWarmupBytes = [...chunksBySource.values()].reduce((sum, chunks) => sum + chunks.reduce((chunkSum, chunk) => chunkSum + chunk.chunk_size, 0), 0);
|
|
136
|
+
if (totalWarmupChunks > 0) {
|
|
137
|
+
const startedAt = Date.now();
|
|
138
|
+
emitChecksumEvent(input, {
|
|
139
|
+
event: "checksum_warmup_started",
|
|
140
|
+
sourceId: "all",
|
|
141
|
+
chunkCount: totalWarmupChunks,
|
|
142
|
+
bytes: totalWarmupBytes
|
|
143
|
+
});
|
|
144
|
+
for (const [sourceId, chunks] of chunksBySource) {
|
|
145
|
+
await input.throwIfCancelled?.();
|
|
146
|
+
const source = plannedSourcesById.get(sourceId);
|
|
147
|
+
if (!source) {
|
|
148
|
+
throw new Error(`BeamCore planned unknown source_id: ${sourceId}`);
|
|
149
|
+
}
|
|
150
|
+
await checksumCache.ensureMany(source, chunks);
|
|
151
|
+
}
|
|
152
|
+
emitChecksumEvent(input, {
|
|
153
|
+
event: "checksum_warmup_completed",
|
|
154
|
+
sourceId: "all",
|
|
155
|
+
chunkCount: totalWarmupChunks,
|
|
156
|
+
bytes: totalWarmupBytes,
|
|
157
|
+
durationMs: Date.now() - startedAt
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
await input.throwIfCancelled?.();
|
|
161
|
+
preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
|
|
162
|
+
await input.onBeforeTransferPrepare?.();
|
|
163
|
+
prepared = await this.prepareTransfer({
|
|
164
|
+
sources: preparedSources,
|
|
165
|
+
destinations: preparedDestinations,
|
|
166
|
+
name: input.name,
|
|
167
|
+
testMode: input.testMode,
|
|
168
|
+
experimental: input.experimental,
|
|
169
|
+
signedUrlFlow: requestedSignedUrlFlow
|
|
170
|
+
});
|
|
171
|
+
if (!prepared.success) {
|
|
172
|
+
return prepared;
|
|
173
|
+
}
|
|
174
|
+
assertPreparedTransferMatchesPlan(planned, prepared);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
|
|
178
|
+
await input.onBeforeTransferPrepare?.();
|
|
179
|
+
prepared = await this.prepareTransfer({
|
|
180
|
+
sources: preparedSources,
|
|
181
|
+
destinations: preparedDestinations,
|
|
182
|
+
name: input.name,
|
|
183
|
+
testMode: input.testMode,
|
|
184
|
+
experimental: input.experimental,
|
|
185
|
+
signedUrlFlow: requestedSignedUrlFlow
|
|
186
|
+
});
|
|
187
|
+
if (!prepared.success) {
|
|
188
|
+
return prepared;
|
|
189
|
+
}
|
|
93
190
|
}
|
|
94
191
|
await input.onPrepared?.(prepared);
|
|
95
192
|
await input.throwIfCancelled?.(prepared.transfer_id);
|
|
96
|
-
const destinationsById = new Map();
|
|
97
|
-
preparedDestinations.forEach((preparedDestination, index) => {
|
|
98
|
-
destinationsById.set(preparedDestination.destination_id, input.destinations[index]);
|
|
99
|
-
});
|
|
100
193
|
const sourcesById = new Map();
|
|
101
194
|
preparedSources.forEach((preparedSource, index) => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
expiresIn,
|
|
108
|
-
fetchImpl: this.fetchImpl
|
|
195
|
+
const source = input.sources[index];
|
|
196
|
+
if (!source) {
|
|
197
|
+
throw new Error(`missing provider source config for prepared source ${preparedSource.source_id}`);
|
|
198
|
+
}
|
|
199
|
+
sourcesById.set(preparedSource.source_id, source);
|
|
109
200
|
});
|
|
201
|
+
const signedUrlFlow = prepared.signed_url_flow ?? requestedSignedUrlFlow;
|
|
110
202
|
if (signedUrlFlow === "signed_url_v1") {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return destination ? isS3CompatibleProvider(destination) : false;
|
|
115
|
-
});
|
|
116
|
-
if (!needsChecksum)
|
|
117
|
-
continue;
|
|
118
|
-
const source = sourcesById.get(chunk.source_id);
|
|
203
|
+
const chunksBySource = checksumChunksBySource(prepared.chunk_plan, destinationsById);
|
|
204
|
+
for (const [sourceId, chunks] of chunksBySource) {
|
|
205
|
+
const source = sourcesById.get(sourceId);
|
|
119
206
|
if (source)
|
|
120
|
-
checksumCache.
|
|
207
|
+
checksumCache.preloadMany(source, chunks);
|
|
121
208
|
}
|
|
122
209
|
}
|
|
123
210
|
const routes = [];
|
|
@@ -342,6 +429,83 @@ export class BeamClient {
|
|
|
342
429
|
return (text ? JSON.parse(text) : {});
|
|
343
430
|
}
|
|
344
431
|
}
|
|
432
|
+
function checksumChunksBySource(chunkPlan, destinationsById) {
|
|
433
|
+
const chunksBySource = new Map();
|
|
434
|
+
for (const chunk of chunkPlan) {
|
|
435
|
+
const needsChecksum = chunk.destinations.some((target) => {
|
|
436
|
+
const destination = destinationsById.get(target.destination_id);
|
|
437
|
+
return destination ? isS3CompatibleProvider(destination) : false;
|
|
438
|
+
});
|
|
439
|
+
if (!needsChecksum)
|
|
440
|
+
continue;
|
|
441
|
+
const chunks = chunksBySource.get(chunk.source_id) ?? [];
|
|
442
|
+
chunks.push(chunk);
|
|
443
|
+
chunksBySource.set(chunk.source_id, chunks);
|
|
444
|
+
}
|
|
445
|
+
return chunksBySource;
|
|
446
|
+
}
|
|
447
|
+
function mapSourcesByPreparedId(preparedSources, providerSources) {
|
|
448
|
+
const sourcesById = new Map();
|
|
449
|
+
preparedSources.forEach((preparedSource, index) => {
|
|
450
|
+
const source = providerSources[index];
|
|
451
|
+
if (!source) {
|
|
452
|
+
throw new Error(`missing provider source config for planned source ${preparedSource.source_id}`);
|
|
453
|
+
}
|
|
454
|
+
sourcesById.set(preparedSource.source_id, source);
|
|
455
|
+
});
|
|
456
|
+
return sourcesById;
|
|
457
|
+
}
|
|
458
|
+
function failedPlanAsPrepareResponse(plan) {
|
|
459
|
+
return {
|
|
460
|
+
success: false,
|
|
461
|
+
transfer_id: "",
|
|
462
|
+
chunk_size: plan.chunk_size,
|
|
463
|
+
total_size: plan.total_size,
|
|
464
|
+
total_sources: plan.total_sources,
|
|
465
|
+
total_destinations: plan.total_destinations,
|
|
466
|
+
logical_chunks: plan.logical_chunks,
|
|
467
|
+
total_chunks: plan.total_chunks,
|
|
468
|
+
chunk_plan: plan.chunk_plan ?? [],
|
|
469
|
+
signed_url_flow: plan.signed_url_flow,
|
|
470
|
+
error: plan.error,
|
|
471
|
+
message: plan.message
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function assertPreparedTransferMatchesPlan(plan, prepared) {
|
|
475
|
+
const summaryMatches = plan.chunk_size === prepared.chunk_size &&
|
|
476
|
+
plan.total_size === prepared.total_size &&
|
|
477
|
+
plan.total_sources === prepared.total_sources &&
|
|
478
|
+
plan.total_destinations === prepared.total_destinations &&
|
|
479
|
+
plan.logical_chunks === prepared.logical_chunks &&
|
|
480
|
+
plan.total_chunks === prepared.total_chunks;
|
|
481
|
+
if (!summaryMatches || planSignature(plan.chunk_plan) !== planSignature(prepared.chunk_plan)) {
|
|
482
|
+
throw new Error("prepared transfer chunk plan does not match preflight checksum plan");
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function planSignature(chunks) {
|
|
486
|
+
return JSON.stringify(chunks.map((chunk) => ({
|
|
487
|
+
chunk_index: chunk.chunk_index,
|
|
488
|
+
source_id: chunk.source_id,
|
|
489
|
+
source_chunk_index: chunk.source_chunk_index,
|
|
490
|
+
source_offset: chunk.source_offset,
|
|
491
|
+
chunk_size: chunk.chunk_size,
|
|
492
|
+
destinations: chunk.destinations.map((target) => {
|
|
493
|
+
const metadata = target.metadata ?? {};
|
|
494
|
+
return {
|
|
495
|
+
destination_id: target.destination_id,
|
|
496
|
+
provider: target.provider,
|
|
497
|
+
final_object_key: typeof metadata.final_object_key === "string" ? metadata.final_object_key : target.object_key,
|
|
498
|
+
part_number: typeof metadata.part_number === "number" ? metadata.part_number : undefined,
|
|
499
|
+
delivery_index: typeof metadata.delivery_index === "number" ? metadata.delivery_index : undefined
|
|
500
|
+
};
|
|
501
|
+
})
|
|
502
|
+
})));
|
|
503
|
+
}
|
|
504
|
+
function emitChecksumEvent(input, event) {
|
|
505
|
+
if (!input.onChecksumEvent)
|
|
506
|
+
return;
|
|
507
|
+
void Promise.resolve(input.onChecksumEvent(event)).catch(() => undefined);
|
|
508
|
+
}
|
|
345
509
|
class ChunkChecksumCache {
|
|
346
510
|
options;
|
|
347
511
|
queue = [];
|
|
@@ -350,22 +514,128 @@ class ChunkChecksumCache {
|
|
|
350
514
|
constructor(options) {
|
|
351
515
|
this.options = options;
|
|
352
516
|
}
|
|
353
|
-
|
|
354
|
-
|
|
517
|
+
preloadMany(source, chunks) {
|
|
518
|
+
const pending = this.uniqueChunks(chunks).filter((chunk) => !this.cache.has(this.key(chunk)));
|
|
519
|
+
for (const chunk of pending) {
|
|
520
|
+
this.cache.set(this.key(chunk), deferredChecksum());
|
|
521
|
+
}
|
|
522
|
+
for (const group of this.groupChunks(pending)) {
|
|
523
|
+
void this.run(() => this.fillGroup(source, group)).catch(() => undefined);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
async ensureMany(source, chunks) {
|
|
527
|
+
const unique = this.uniqueChunks(chunks);
|
|
528
|
+
this.preloadMany(source, unique);
|
|
529
|
+
await Promise.all(unique.map((chunk) => this.get(source, chunk)));
|
|
355
530
|
}
|
|
356
531
|
get(source, chunk) {
|
|
357
|
-
const key =
|
|
532
|
+
const key = this.key(chunk);
|
|
358
533
|
let existing = this.cache.get(key);
|
|
359
534
|
if (!existing) {
|
|
360
|
-
existing =
|
|
535
|
+
existing = deferredChecksum();
|
|
536
|
+
this.cache.set(key, existing);
|
|
537
|
+
void this.run(() => this.fillGroup(source, [chunk])).catch(() => undefined);
|
|
538
|
+
}
|
|
539
|
+
return existing.promise;
|
|
540
|
+
}
|
|
541
|
+
async fillGroup(source, chunks) {
|
|
542
|
+
const unresolved = new Set(chunks.map((chunk) => this.key(chunk)));
|
|
543
|
+
try {
|
|
544
|
+
await this.options.throwIfCancelled?.();
|
|
545
|
+
const misses = [];
|
|
546
|
+
for (const chunk of chunks) {
|
|
547
|
+
const key = this.key(chunk);
|
|
548
|
+
const deferred = this.cache.get(key);
|
|
549
|
+
if (!deferred)
|
|
550
|
+
continue;
|
|
551
|
+
const cached = await this.options.provider?.getContentMd5({ source, chunk, algorithm: "content-md5" }).catch(() => undefined);
|
|
552
|
+
if (cached) {
|
|
553
|
+
this.emit({ event: "checksum_cache_hit", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
|
|
554
|
+
deferred.resolve(cached);
|
|
555
|
+
unresolved.delete(key);
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
this.emit({ event: "checksum_cache_miss", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
|
|
559
|
+
misses.push(chunk);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (!misses.length)
|
|
563
|
+
return;
|
|
564
|
+
await this.options.throwIfCancelled?.();
|
|
565
|
+
const startedAt = Date.now();
|
|
566
|
+
const checksums = await computeChunksContentMd5({
|
|
361
567
|
source,
|
|
362
|
-
|
|
568
|
+
chunks: misses,
|
|
363
569
|
expiresIn: this.options.expiresIn,
|
|
364
|
-
fetchImpl: this.options.fetchImpl
|
|
365
|
-
|
|
366
|
-
|
|
570
|
+
fetchImpl: this.options.fetchImpl,
|
|
571
|
+
groupSizeBytes: this.options.groupSizeBytes
|
|
572
|
+
});
|
|
573
|
+
for (const chunk of misses) {
|
|
574
|
+
await this.options.throwIfCancelled?.();
|
|
575
|
+
const checksum = checksums.get(chunk.source_chunk_index);
|
|
576
|
+
if (!checksum)
|
|
577
|
+
throw new Error(`checksum missing for source chunk ${chunk.source_chunk_index}`);
|
|
578
|
+
await this.options.provider?.setContentMd5?.({ source, chunk, algorithm: "content-md5", contentMd5: checksum });
|
|
579
|
+
const deferred = this.cache.get(this.key(chunk));
|
|
580
|
+
deferred?.resolve(checksum);
|
|
581
|
+
unresolved.delete(this.key(chunk));
|
|
582
|
+
this.emit({ event: "checksum_cold_chunk_cached", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
|
|
583
|
+
}
|
|
584
|
+
this.emit({
|
|
585
|
+
event: "checksum_cold_group_completed",
|
|
586
|
+
sourceId: misses[0]?.source_id ?? source.id ?? "source",
|
|
587
|
+
chunkCount: misses.length,
|
|
588
|
+
bytes: misses.reduce((sum, chunk) => sum + chunk.chunk_size, 0),
|
|
589
|
+
durationMs: Date.now() - startedAt
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
for (const key of unresolved) {
|
|
594
|
+
this.cache.get(key)?.reject(error);
|
|
595
|
+
}
|
|
596
|
+
throw error;
|
|
367
597
|
}
|
|
368
|
-
|
|
598
|
+
}
|
|
599
|
+
uniqueChunks(chunks) {
|
|
600
|
+
const seen = new Set();
|
|
601
|
+
const output = [];
|
|
602
|
+
for (const chunk of chunks) {
|
|
603
|
+
const key = this.key(chunk);
|
|
604
|
+
if (seen.has(key))
|
|
605
|
+
continue;
|
|
606
|
+
seen.add(key);
|
|
607
|
+
output.push(chunk);
|
|
608
|
+
}
|
|
609
|
+
return output.sort((a, b) => a.source_offset - b.source_offset || a.source_chunk_index - b.source_chunk_index);
|
|
610
|
+
}
|
|
611
|
+
groupChunks(chunks) {
|
|
612
|
+
const groups = [];
|
|
613
|
+
let current = [];
|
|
614
|
+
let groupStart = 0;
|
|
615
|
+
let groupEnd = 0;
|
|
616
|
+
const maxBytes = Math.max(1, Math.floor(this.options.groupSizeBytes));
|
|
617
|
+
for (const chunk of chunks) {
|
|
618
|
+
const chunkStart = chunk.source_offset;
|
|
619
|
+
const chunkEnd = chunk.source_offset + chunk.chunk_size;
|
|
620
|
+
const startsNewGroup = current.length === 0 || chunkStart !== groupEnd || chunkEnd - groupStart > maxBytes;
|
|
621
|
+
if (startsNewGroup) {
|
|
622
|
+
if (current.length)
|
|
623
|
+
groups.push(current);
|
|
624
|
+
current = [chunk];
|
|
625
|
+
groupStart = chunkStart;
|
|
626
|
+
groupEnd = chunkEnd;
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
current.push(chunk);
|
|
630
|
+
groupEnd = chunkEnd;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (current.length)
|
|
634
|
+
groups.push(current);
|
|
635
|
+
return groups;
|
|
636
|
+
}
|
|
637
|
+
key(chunk) {
|
|
638
|
+
return `${chunk.source_id}:${chunk.source_chunk_index}`;
|
|
369
639
|
}
|
|
370
640
|
run(task) {
|
|
371
641
|
return new Promise((resolve, reject) => {
|
|
@@ -382,6 +652,20 @@ class ChunkChecksumCache {
|
|
|
382
652
|
this.queue.push(start);
|
|
383
653
|
});
|
|
384
654
|
}
|
|
655
|
+
emit(event) {
|
|
656
|
+
if (!this.options.onEvent)
|
|
657
|
+
return;
|
|
658
|
+
void Promise.resolve(this.options.onEvent(event)).catch(() => undefined);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
function deferredChecksum() {
|
|
662
|
+
let resolve;
|
|
663
|
+
let reject;
|
|
664
|
+
const promise = new Promise((res, rej) => {
|
|
665
|
+
resolve = res;
|
|
666
|
+
reject = rej;
|
|
667
|
+
});
|
|
668
|
+
return { promise, resolve, reject };
|
|
385
669
|
}
|
|
386
670
|
export function calculateOptimalChunkSize(totalSize) {
|
|
387
671
|
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_warmup_started" | "checksum_warmup_completed" | "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,26 +209,39 @@ 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.
|
|
188
219
|
*/
|
|
189
220
|
distribute?: boolean;
|
|
221
|
+
onPlanned?: (plan: TransferPlanResponse) => void | Promise<void>;
|
|
222
|
+
onBeforeTransferPrepare?: () => void | Promise<void>;
|
|
190
223
|
onPrepared?: (prepared: TransferPrepareResponse) => void | Promise<void>;
|
|
191
|
-
throwIfCancelled?: (transferId
|
|
224
|
+
throwIfCancelled?: (transferId?: string) => void | Promise<void>;
|
|
192
225
|
signedUrlFlow?: SignedUrlFlow;
|
|
226
|
+
checksumProvider?: ChunkChecksumProvider;
|
|
227
|
+
checksumReadConcurrency?: number;
|
|
228
|
+
checksumGroupSizeBytes?: number;
|
|
229
|
+
onChecksumEvent?: (event: ChunkChecksumEvent) => void | Promise<void>;
|
|
193
230
|
}
|
|
194
|
-
export interface
|
|
231
|
+
export interface PlanningHttpSource {
|
|
195
232
|
source_id: string;
|
|
196
233
|
type: "http";
|
|
197
234
|
provider?: string;
|
|
198
|
-
url
|
|
235
|
+
url?: string;
|
|
199
236
|
size: number;
|
|
200
237
|
filename?: string;
|
|
201
238
|
headers?: Record<string, string>;
|
|
202
239
|
expires_at?: string;
|
|
203
240
|
metadata?: Record<string, unknown>;
|
|
204
241
|
}
|
|
242
|
+
export interface PreparedHttpSource extends PlanningHttpSource {
|
|
243
|
+
url: string;
|
|
244
|
+
}
|
|
205
245
|
export interface PreparedDestination {
|
|
206
246
|
destination_id: string;
|
|
207
247
|
provider: string;
|
|
@@ -237,11 +277,25 @@ export interface SignedChunkRoute {
|
|
|
237
277
|
dest_headers?: Record<string, string>;
|
|
238
278
|
metadata?: Record<string, unknown>;
|
|
239
279
|
}
|
|
280
|
+
export interface TransferPlanResponse {
|
|
281
|
+
success: boolean;
|
|
282
|
+
chunk_size?: number;
|
|
283
|
+
total_size?: number;
|
|
284
|
+
total_sources?: number;
|
|
285
|
+
total_destinations?: number;
|
|
286
|
+
logical_chunks?: number;
|
|
287
|
+
total_chunks?: number;
|
|
288
|
+
chunk_plan: ChunkSigningPlanItem[];
|
|
289
|
+
signed_url_flow?: SignedUrlFlow;
|
|
290
|
+
error?: string;
|
|
291
|
+
message?: string;
|
|
292
|
+
}
|
|
240
293
|
export interface TransferPrepareResponse {
|
|
241
294
|
success: boolean;
|
|
242
295
|
transfer_id: string;
|
|
243
296
|
transfer_key?: string;
|
|
244
297
|
test_mode?: boolean;
|
|
298
|
+
experimental?: boolean;
|
|
245
299
|
chunk_size?: number;
|
|
246
300
|
total_size?: number;
|
|
247
301
|
total_sources?: number;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChunkDestinationSigningTarget, ChunkSigningPlanItem, PreparedDestination, PreparedHttpSource, ProviderDestinationConfig, ProviderSourceConfig, R2ProviderConfig, S3CompatibleProviderConfig, S3ProviderConfig, SignedChunkRoute } from "./models.js";
|
|
1
|
+
import type { ChunkDestinationSigningTarget, ChunkSigningPlanItem, PlanningHttpSource, PreparedDestination, PreparedHttpSource, ProviderDestinationConfig, ProviderSourceConfig, R2ProviderConfig, S3CompatibleProviderConfig, S3ProviderConfig, SignedChunkRoute } from "./models.js";
|
|
2
2
|
export type AnyS3CompatibleProviderConfig = S3ProviderConfig | R2ProviderConfig | S3CompatibleProviderConfig;
|
|
3
3
|
export declare function nowIso(): string;
|
|
4
4
|
export declare function expiresAtIso(expiresIn: number): string;
|
|
@@ -7,6 +7,10 @@ export declare function prepareProviderSource(source: ProviderSourceConfig, opti
|
|
|
7
7
|
expiresIn?: number;
|
|
8
8
|
fetchImpl?: typeof fetch;
|
|
9
9
|
}): Promise<PreparedHttpSource>;
|
|
10
|
+
export declare function prepareProviderSourceForPlan(source: ProviderSourceConfig, options?: {
|
|
11
|
+
index?: number;
|
|
12
|
+
fetchImpl?: typeof fetch;
|
|
13
|
+
}): Promise<PlanningHttpSource>;
|
|
10
14
|
export declare function prepareProviderDestination(destination: ProviderDestinationConfig, options?: {
|
|
11
15
|
index?: number;
|
|
12
16
|
}): PreparedDestination;
|
|
@@ -51,6 +55,13 @@ export declare function signDestinationRoute(input: {
|
|
|
51
55
|
deleteUrl?: string;
|
|
52
56
|
fetchImpl?: typeof fetch;
|
|
53
57
|
}): Promise<SignedChunkRoute>;
|
|
58
|
+
export declare function computeChunksContentMd5(input: {
|
|
59
|
+
source: ProviderSourceConfig;
|
|
60
|
+
chunks: ChunkSigningPlanItem[];
|
|
61
|
+
expiresIn: number;
|
|
62
|
+
fetchImpl?: typeof fetch;
|
|
63
|
+
groupSizeBytes?: number;
|
|
64
|
+
}): Promise<Map<number, string>>;
|
|
54
65
|
export declare function computeChunkContentMd5(input: {
|
|
55
66
|
source: ProviderSourceConfig;
|
|
56
67
|
chunk: ChunkSigningPlanItem;
|
package/dist/provider-signing.js
CHANGED
|
@@ -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:
|
|
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)) {
|
|
@@ -44,6 +50,42 @@ export async function prepareProviderSource(source, options = {}) {
|
|
|
44
50
|
}
|
|
45
51
|
return unsupportedProviderConfig(source);
|
|
46
52
|
}
|
|
53
|
+
export async function prepareProviderSourceForPlan(source, options = {}) {
|
|
54
|
+
const index = options.index ?? 0;
|
|
55
|
+
if (isS3CompatibleProvider(source)) {
|
|
56
|
+
const endpoint = s3CompatibleEndpoint(source);
|
|
57
|
+
const client = createS3CompatibleClient(source, endpoint);
|
|
58
|
+
const head = await client.send(new HeadObjectCommand({ Bucket: source.bucket, Key: source.key }));
|
|
59
|
+
return {
|
|
60
|
+
source_id: source.id ?? `src_${index}`,
|
|
61
|
+
type: "http",
|
|
62
|
+
provider: source.provider,
|
|
63
|
+
size: Number(head.ContentLength ?? 0),
|
|
64
|
+
filename: filename(source.key),
|
|
65
|
+
metadata: {
|
|
66
|
+
...s3CompatibleMetadata(source, endpoint),
|
|
67
|
+
content_length: Number(head.ContentLength ?? 0),
|
|
68
|
+
...(head.ETag ? { etag: head.ETag } : {}),
|
|
69
|
+
...(head.LastModified ? { last_modified: head.LastModified.toISOString() } : {}),
|
|
70
|
+
...(head.VersionId ? { version_id: head.VersionId } : {})
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (isHippiusProvider(source)) {
|
|
75
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
76
|
+
const baseUrl = source.base_url ?? "https://api.hippius.com";
|
|
77
|
+
const size = await hippiusObjectSize(fetchImpl, baseUrl, source.api_token, source.bucket, source.key);
|
|
78
|
+
return {
|
|
79
|
+
source_id: source.id ?? `src_${index}`,
|
|
80
|
+
type: "http",
|
|
81
|
+
provider: "hippius",
|
|
82
|
+
size,
|
|
83
|
+
filename: filename(source.key),
|
|
84
|
+
metadata: { bucket: source.bucket, key: source.key, base_url: baseUrl }
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return unsupportedProviderConfig(source);
|
|
88
|
+
}
|
|
47
89
|
export function prepareProviderDestination(destination, options = {}) {
|
|
48
90
|
const index = options.index ?? 0;
|
|
49
91
|
const destinationId = destination.id ?? `dst_${index}`;
|
|
@@ -221,8 +263,11 @@ export async function signDestinationRoute(input) {
|
|
|
221
263
|
};
|
|
222
264
|
}
|
|
223
265
|
function rangeHeaderForChunk(chunk) {
|
|
224
|
-
|
|
225
|
-
|
|
266
|
+
return rangeHeaderForRange(chunk.source_offset, chunk.chunk_size);
|
|
267
|
+
}
|
|
268
|
+
function rangeHeaderForRange(sourceOffset, chunkSize) {
|
|
269
|
+
const start = Math.max(0, Math.floor(sourceOffset));
|
|
270
|
+
const size = Math.max(1, Math.floor(chunkSize));
|
|
226
271
|
return `bytes=${start}-${start + size - 1}`;
|
|
227
272
|
}
|
|
228
273
|
async function signSourceRoute(input) {
|
|
@@ -249,6 +294,27 @@ async function signSourceRoute(input) {
|
|
|
249
294
|
}
|
|
250
295
|
return unsupportedProviderConfig(input.source);
|
|
251
296
|
}
|
|
297
|
+
async function signSourceRangeRoute(input) {
|
|
298
|
+
const range = rangeHeaderForRange(input.sourceOffset, input.chunkSize);
|
|
299
|
+
if (isS3CompatibleProvider(input.source)) {
|
|
300
|
+
const client = createS3CompatibleClient(input.source);
|
|
301
|
+
return {
|
|
302
|
+
url: await getSignedUrl(client, new GetObjectCommand({
|
|
303
|
+
Bucket: input.source.bucket,
|
|
304
|
+
Key: input.source.key,
|
|
305
|
+
Range: range
|
|
306
|
+
}), { expiresIn: input.expiresIn }),
|
|
307
|
+
headers: { Range: range }
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
if (isHippiusProvider(input.source)) {
|
|
311
|
+
return {
|
|
312
|
+
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),
|
|
313
|
+
headers: { Range: range }
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
return { url: input.fallbackUrl, headers: { Range: range } };
|
|
317
|
+
}
|
|
252
318
|
async function signDestinationUrl(input) {
|
|
253
319
|
if (isS3CompatibleProvider(input.destination)) {
|
|
254
320
|
const client = createS3CompatibleClient(input.destination);
|
|
@@ -268,27 +334,232 @@ async function signDestinationUrl(input) {
|
|
|
268
334
|
}
|
|
269
335
|
return unsupportedProviderConfig(input.destination);
|
|
270
336
|
}
|
|
337
|
+
export async function computeChunksContentMd5(input) {
|
|
338
|
+
const output = new Map();
|
|
339
|
+
const chunks = [...input.chunks].sort((a, b) => a.source_offset - b.source_offset || a.source_chunk_index - b.source_chunk_index);
|
|
340
|
+
const groups = groupContiguousChunks(chunks, input.groupSizeBytes ?? 256 * 1024 * 1024);
|
|
341
|
+
for (const group of groups) {
|
|
342
|
+
const groupOutput = await retryChecksumRead(() => computeChunkGroupContentMd5({
|
|
343
|
+
source: input.source,
|
|
344
|
+
chunks: group,
|
|
345
|
+
expiresIn: input.expiresIn,
|
|
346
|
+
fetchImpl: input.fetchImpl
|
|
347
|
+
}));
|
|
348
|
+
for (const [sourceChunkIndex, checksum] of groupOutput) {
|
|
349
|
+
output.set(sourceChunkIndex, checksum);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return output;
|
|
353
|
+
}
|
|
271
354
|
export async function computeChunkContentMd5(input) {
|
|
272
|
-
const
|
|
355
|
+
const checksums = await computeChunksContentMd5({
|
|
273
356
|
source: input.source,
|
|
274
|
-
|
|
275
|
-
|
|
357
|
+
chunks: [input.chunk],
|
|
358
|
+
expiresIn: input.expiresIn,
|
|
359
|
+
fetchImpl: input.fetchImpl,
|
|
360
|
+
groupSizeBytes: input.chunk.chunk_size
|
|
361
|
+
});
|
|
362
|
+
const checksum = checksums.get(input.chunk.source_chunk_index);
|
|
363
|
+
if (!checksum) {
|
|
364
|
+
throw new Error(`chunk checksum missing for source chunk ${input.chunk.source_chunk_index}`);
|
|
365
|
+
}
|
|
366
|
+
return checksum;
|
|
367
|
+
}
|
|
368
|
+
function groupContiguousChunks(chunks, maxGroupSizeBytes) {
|
|
369
|
+
const groups = [];
|
|
370
|
+
let current = [];
|
|
371
|
+
let groupStart = 0;
|
|
372
|
+
let groupEnd = 0;
|
|
373
|
+
const maxBytes = Math.max(1, Math.floor(maxGroupSizeBytes));
|
|
374
|
+
for (const chunk of chunks) {
|
|
375
|
+
const chunkStart = Math.max(0, Math.floor(chunk.source_offset));
|
|
376
|
+
const chunkEnd = chunkStart + Math.max(1, Math.floor(chunk.chunk_size));
|
|
377
|
+
const startsNewGroup = current.length === 0 ||
|
|
378
|
+
chunkStart !== groupEnd ||
|
|
379
|
+
chunkEnd - groupStart > maxBytes;
|
|
380
|
+
if (startsNewGroup) {
|
|
381
|
+
if (current.length)
|
|
382
|
+
groups.push(current);
|
|
383
|
+
current = [chunk];
|
|
384
|
+
groupStart = chunkStart;
|
|
385
|
+
groupEnd = chunkEnd;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
current.push(chunk);
|
|
389
|
+
groupEnd = chunkEnd;
|
|
390
|
+
}
|
|
391
|
+
if (current.length)
|
|
392
|
+
groups.push(current);
|
|
393
|
+
return groups;
|
|
394
|
+
}
|
|
395
|
+
async function computeChunkGroupContentMd5(input) {
|
|
396
|
+
if (!input.chunks.length)
|
|
397
|
+
return new Map();
|
|
398
|
+
const chunks = [...input.chunks].sort((a, b) => a.source_offset - b.source_offset || a.source_chunk_index - b.source_chunk_index);
|
|
399
|
+
const first = chunks[0];
|
|
400
|
+
const last = chunks[chunks.length - 1];
|
|
401
|
+
const sourceOffset = first.source_offset;
|
|
402
|
+
const totalSize = last.source_offset + last.chunk_size - sourceOffset;
|
|
403
|
+
if (isS3CompatibleProvider(input.source)) {
|
|
404
|
+
const client = createS3CompatibleClient(input.source);
|
|
405
|
+
const response = await client.send(new GetObjectCommand({
|
|
406
|
+
Bucket: input.source.bucket,
|
|
407
|
+
Key: input.source.key,
|
|
408
|
+
Range: `bytes=${sourceOffset}-${sourceOffset + totalSize - 1}`
|
|
409
|
+
}));
|
|
410
|
+
if (!response.Body) {
|
|
411
|
+
throw new Error(`chunk checksum read returned no body for ${input.source.bucket}/${input.source.key}`);
|
|
412
|
+
}
|
|
413
|
+
return hashChunkGroupContentMd5(chunks, totalSize, bodyToAsyncBytes(response.Body));
|
|
414
|
+
}
|
|
415
|
+
const sourceRoute = await signSourceRangeRoute({
|
|
416
|
+
source: input.source,
|
|
417
|
+
fallbackUrl: first.source_url,
|
|
418
|
+
sourceOffset,
|
|
419
|
+
chunkSize: totalSize,
|
|
276
420
|
expiresIn: input.expiresIn,
|
|
277
421
|
fetchImpl: input.fetchImpl
|
|
278
422
|
});
|
|
279
423
|
const fetchImpl = input.fetchImpl ?? globalThis.fetch;
|
|
280
424
|
const response = await fetchImpl(sourceRoute.url, { method: "GET", headers: sourceRoute.headers });
|
|
281
425
|
if (!response.ok) {
|
|
282
|
-
|
|
426
|
+
const message = `chunk checksum fetch failed with ${response.status}: ${await response.text()}`;
|
|
427
|
+
throw Object.assign(new Error(message), { status: response.status });
|
|
428
|
+
}
|
|
429
|
+
if (response.body) {
|
|
430
|
+
return hashChunkGroupContentMd5(chunks, totalSize, readableStreamToAsyncBytes(response.body));
|
|
431
|
+
}
|
|
432
|
+
return hashChunkGroupContentMd5(chunks, totalSize, [new Uint8Array(await response.arrayBuffer())]);
|
|
433
|
+
}
|
|
434
|
+
async function hashChunkGroupContentMd5(chunks, totalSize, body) {
|
|
435
|
+
const output = new Map();
|
|
436
|
+
let chunkIndex = 0;
|
|
437
|
+
let bytesInChunk = 0;
|
|
438
|
+
let totalRead = 0;
|
|
439
|
+
let hash = createHash("md5");
|
|
440
|
+
const consume = (value) => {
|
|
441
|
+
let offset = 0;
|
|
442
|
+
while (offset < value.byteLength) {
|
|
443
|
+
const chunk = chunks[chunkIndex];
|
|
444
|
+
if (!chunk) {
|
|
445
|
+
throw new Error(`chunk checksum fetch size mismatch: read more than ${totalSize} bytes`);
|
|
446
|
+
}
|
|
447
|
+
const remainingInChunk = chunk.chunk_size - bytesInChunk;
|
|
448
|
+
const take = Math.min(remainingInChunk, value.byteLength - offset);
|
|
449
|
+
hash.update(value.subarray(offset, offset + take));
|
|
450
|
+
offset += take;
|
|
451
|
+
totalRead += take;
|
|
452
|
+
bytesInChunk += take;
|
|
453
|
+
if (bytesInChunk === chunk.chunk_size) {
|
|
454
|
+
output.set(chunk.source_chunk_index, hash.digest("base64"));
|
|
455
|
+
chunkIndex += 1;
|
|
456
|
+
bytesInChunk = 0;
|
|
457
|
+
hash = createHash("md5");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
for await (const value of body) {
|
|
462
|
+
consume(value);
|
|
463
|
+
}
|
|
464
|
+
if (totalRead !== totalSize || output.size !== chunks.length) {
|
|
465
|
+
throw new Error(`chunk checksum fetch size mismatch: expected ${totalSize}, got ${totalRead}`);
|
|
466
|
+
}
|
|
467
|
+
return output;
|
|
468
|
+
}
|
|
469
|
+
async function* readableStreamToAsyncBytes(stream) {
|
|
470
|
+
const reader = stream.getReader();
|
|
471
|
+
try {
|
|
472
|
+
while (true) {
|
|
473
|
+
const { done, value } = await reader.read();
|
|
474
|
+
if (done)
|
|
475
|
+
break;
|
|
476
|
+
if (value)
|
|
477
|
+
yield value;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
reader.releaseLock();
|
|
283
482
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
483
|
+
}
|
|
484
|
+
async function* bodyToAsyncBytes(body) {
|
|
485
|
+
if (body instanceof Uint8Array) {
|
|
486
|
+
yield body;
|
|
487
|
+
return;
|
|
287
488
|
}
|
|
288
|
-
|
|
489
|
+
if (typeof body === "string") {
|
|
490
|
+
yield new TextEncoder().encode(body);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (body && typeof body.transformToByteArray === "function") {
|
|
494
|
+
yield new Uint8Array(await body.transformToByteArray());
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (body && typeof body.arrayBuffer === "function") {
|
|
498
|
+
yield new Uint8Array(await body.arrayBuffer());
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (body && typeof body.getReader === "function") {
|
|
502
|
+
yield* readableStreamToAsyncBytes(body);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (body && Symbol.asyncIterator in Object(body)) {
|
|
506
|
+
for await (const value of body) {
|
|
507
|
+
yield toUint8Array(value);
|
|
508
|
+
}
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
throw new Error("provider checksum read returned an unsupported body stream");
|
|
512
|
+
}
|
|
513
|
+
function toUint8Array(value) {
|
|
514
|
+
if (value instanceof Uint8Array)
|
|
515
|
+
return value;
|
|
516
|
+
if (typeof value === "string")
|
|
517
|
+
return new TextEncoder().encode(value);
|
|
518
|
+
if (ArrayBuffer.isView(value)) {
|
|
519
|
+
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
520
|
+
}
|
|
521
|
+
if (value instanceof ArrayBuffer)
|
|
522
|
+
return new Uint8Array(value);
|
|
523
|
+
throw new Error("provider checksum stream yielded an unsupported chunk");
|
|
524
|
+
}
|
|
525
|
+
async function retryChecksumRead(task) {
|
|
526
|
+
let lastError;
|
|
527
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
528
|
+
try {
|
|
529
|
+
return await task();
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
lastError = error;
|
|
533
|
+
const status = typeof error === "object" && error && "status" in error ? Number(error.status) : undefined;
|
|
534
|
+
if (status && status >= 400 && status < 500 && status !== 408 && status !== 429)
|
|
535
|
+
break;
|
|
536
|
+
if (attempt < 2)
|
|
537
|
+
await sleep(250 * (attempt + 1));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
throw lastError;
|
|
541
|
+
}
|
|
542
|
+
function sleep(ms) {
|
|
543
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
544
|
+
}
|
|
545
|
+
const s3ClientCache = new Map();
|
|
546
|
+
function s3ClientCacheKey(source, endpoint) {
|
|
547
|
+
return JSON.stringify({
|
|
548
|
+
provider: source.provider,
|
|
549
|
+
endpoint,
|
|
550
|
+
region: s3CompatibleRegion(source),
|
|
551
|
+
forcePathStyle: s3CompatibleForcePathStyle(source, endpoint),
|
|
552
|
+
accessKeyId: source.access_key_id,
|
|
553
|
+
secretAccessKey: source.secret_access_key,
|
|
554
|
+
sessionToken: "session_token" in source ? source.session_token : undefined
|
|
555
|
+
});
|
|
289
556
|
}
|
|
290
557
|
function createS3CompatibleClient(source, endpoint = s3CompatibleEndpoint(source)) {
|
|
291
|
-
|
|
558
|
+
const cacheKey = s3ClientCacheKey(source, endpoint);
|
|
559
|
+
const cached = s3ClientCache.get(cacheKey);
|
|
560
|
+
if (cached)
|
|
561
|
+
return cached;
|
|
562
|
+
const client = new S3Client({
|
|
292
563
|
region: s3CompatibleRegion(source),
|
|
293
564
|
endpoint,
|
|
294
565
|
forcePathStyle: s3CompatibleForcePathStyle(source, endpoint),
|
|
@@ -298,6 +569,8 @@ function createS3CompatibleClient(source, endpoint = s3CompatibleEndpoint(source
|
|
|
298
569
|
sessionToken: "session_token" in source ? source.session_token : undefined
|
|
299
570
|
}
|
|
300
571
|
});
|
|
572
|
+
s3ClientCache.set(cacheKey, client);
|
|
573
|
+
return client;
|
|
301
574
|
}
|
|
302
575
|
export function isS3CompatibleProvider(config) {
|
|
303
576
|
if (config.provider === "s3" || config.provider === "r2") {
|