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