@beam-network/sdk 0.2.9 → 0.2.10
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 +2 -1
- package/dist/client.js +96 -5
- package/dist/models.d.ts +6 -0
- package/dist/provider-signing.d.ts +16 -2
- package/dist/provider-signing.js +43 -4
- 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, 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;
|
|
@@ -21,6 +21,7 @@ export declare class BeamClient {
|
|
|
21
21
|
name?: string;
|
|
22
22
|
testMode?: boolean;
|
|
23
23
|
urlsExpiresAt?: string;
|
|
24
|
+
signedUrlFlow?: SignedUrlFlow;
|
|
24
25
|
}): Promise<TransferPrepareResponse>;
|
|
25
26
|
attachSignedUrls(transferId: string, input: {
|
|
26
27
|
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, computeChunkContentMd5, 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,8 @@ export class BeamClient {
|
|
|
36
36
|
chunk_hashes: input.chunkHashes,
|
|
37
37
|
callbacks: input.callbacks,
|
|
38
38
|
test_mode: input.testMode || undefined,
|
|
39
|
-
progressive_mode: input.progressiveMode || undefined
|
|
39
|
+
progressive_mode: input.progressiveMode || undefined,
|
|
40
|
+
signed_url_flow: input.signedUrlFlow
|
|
40
41
|
});
|
|
41
42
|
return this.request("POST", "/transfers/create", body);
|
|
42
43
|
}
|
|
@@ -63,7 +64,8 @@ export class BeamClient {
|
|
|
63
64
|
destinations: input.destinations,
|
|
64
65
|
name: input.name,
|
|
65
66
|
test_mode: input.testMode || undefined,
|
|
66
|
-
urls_expires_at: input.urlsExpiresAt
|
|
67
|
+
urls_expires_at: input.urlsExpiresAt,
|
|
68
|
+
signed_url_flow: input.signedUrlFlow
|
|
67
69
|
}));
|
|
68
70
|
}
|
|
69
71
|
attachSignedUrls(transferId, input) {
|
|
@@ -83,7 +85,8 @@ export class BeamClient {
|
|
|
83
85
|
sources: preparedSources,
|
|
84
86
|
destinations: preparedDestinations,
|
|
85
87
|
name: input.name,
|
|
86
|
-
testMode: input.testMode
|
|
88
|
+
testMode: input.testMode,
|
|
89
|
+
signedUrlFlow: input.signedUrlFlow
|
|
87
90
|
});
|
|
88
91
|
if (!prepared.success) {
|
|
89
92
|
return prepared;
|
|
@@ -98,6 +101,25 @@ export class BeamClient {
|
|
|
98
101
|
preparedSources.forEach((preparedSource, index) => {
|
|
99
102
|
sourcesById.set(preparedSource.source_id, input.sources[index]);
|
|
100
103
|
});
|
|
104
|
+
const signedUrlFlow = prepared.signed_url_flow ?? input.signedUrlFlow ?? "signed_url_v1";
|
|
105
|
+
const checksumCache = new ChunkChecksumCache({
|
|
106
|
+
concurrency: 4,
|
|
107
|
+
expiresIn,
|
|
108
|
+
fetchImpl: this.fetchImpl
|
|
109
|
+
});
|
|
110
|
+
if (signedUrlFlow === "signed_url_v1") {
|
|
111
|
+
for (const chunk of prepared.chunk_plan) {
|
|
112
|
+
const needsChecksum = chunk.destinations.some((target) => {
|
|
113
|
+
const destination = destinationsById.get(target.destination_id);
|
|
114
|
+
return destination ? isS3CompatibleProvider(destination) : false;
|
|
115
|
+
});
|
|
116
|
+
if (!needsChecksum)
|
|
117
|
+
continue;
|
|
118
|
+
const source = sourcesById.get(chunk.source_id);
|
|
119
|
+
if (source)
|
|
120
|
+
checksumCache.preload(source, chunk);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
101
123
|
const routes = [];
|
|
102
124
|
const multipartUploads = new Map();
|
|
103
125
|
try {
|
|
@@ -161,7 +183,9 @@ export class BeamClient {
|
|
|
161
183
|
finalObjectKey,
|
|
162
184
|
partNumber: typeof metadata.part_number === "number"
|
|
163
185
|
? metadata.part_number
|
|
164
|
-
: chunk.source_chunk_index + 1
|
|
186
|
+
: chunk.source_chunk_index + 1,
|
|
187
|
+
signedUrlFlow,
|
|
188
|
+
checksumCache
|
|
165
189
|
}));
|
|
166
190
|
}
|
|
167
191
|
}
|
|
@@ -207,6 +231,32 @@ export class BeamClient {
|
|
|
207
231
|
if (!input.target.object_key) {
|
|
208
232
|
throw new Error("destination signing target is missing object_key");
|
|
209
233
|
}
|
|
234
|
+
if (input.signedUrlFlow === "signed_url_v1") {
|
|
235
|
+
const contentMd5 = await input.checksumCache.get(input.source, input.chunk);
|
|
236
|
+
const partVerifyUrl = await signListMultipartUpload(input.destination, input.finalObjectKey, input.upload.uploadId, input.expiresIn, { maxParts: 1, partNumberMarker: listPartsVerifyMarker(input.partNumber) });
|
|
237
|
+
const listPageUrl = await signListMultipartUpload(input.destination, input.finalObjectKey, input.upload.uploadId, input.expiresIn, { maxParts: 1000, partNumberMarker: listPartsPageMarker(input.partNumber) });
|
|
238
|
+
return signDestinationRoute({
|
|
239
|
+
chunk: input.chunk,
|
|
240
|
+
target: input.target,
|
|
241
|
+
source: input.source,
|
|
242
|
+
destination: input.destination,
|
|
243
|
+
expiresIn: input.expiresIn,
|
|
244
|
+
uploadId: input.upload.uploadId,
|
|
245
|
+
partNumber: input.partNumber,
|
|
246
|
+
multipartObjectKey: input.finalObjectKey,
|
|
247
|
+
completeUrl: input.upload.completeUrl,
|
|
248
|
+
abortUrl: input.upload.abortUrl,
|
|
249
|
+
listUrl: input.upload.listUrl,
|
|
250
|
+
partVerifyUrl,
|
|
251
|
+
listPageUrl,
|
|
252
|
+
contentMd5,
|
|
253
|
+
transferId: input.transferId,
|
|
254
|
+
multipartGroupId: input.upload.multipartGroupId,
|
|
255
|
+
multipartCreatedAt: input.upload.multipartCreatedAt,
|
|
256
|
+
commitMethod: "upload_part",
|
|
257
|
+
fetchImpl: this.fetchImpl
|
|
258
|
+
});
|
|
259
|
+
}
|
|
210
260
|
const stagingObjectKey = buildStagingObjectKey({
|
|
211
261
|
finalObjectKey: input.finalObjectKey,
|
|
212
262
|
transferId: input.transferId,
|
|
@@ -292,6 +342,47 @@ export class BeamClient {
|
|
|
292
342
|
return (text ? JSON.parse(text) : {});
|
|
293
343
|
}
|
|
294
344
|
}
|
|
345
|
+
class ChunkChecksumCache {
|
|
346
|
+
options;
|
|
347
|
+
queue = [];
|
|
348
|
+
cache = new Map();
|
|
349
|
+
active = 0;
|
|
350
|
+
constructor(options) {
|
|
351
|
+
this.options = options;
|
|
352
|
+
}
|
|
353
|
+
preload(source, chunk) {
|
|
354
|
+
void this.get(source, chunk).catch(() => undefined);
|
|
355
|
+
}
|
|
356
|
+
get(source, chunk) {
|
|
357
|
+
const key = `${chunk.source_id}:${chunk.source_chunk_index}`;
|
|
358
|
+
let existing = this.cache.get(key);
|
|
359
|
+
if (!existing) {
|
|
360
|
+
existing = this.run(() => computeChunkContentMd5({
|
|
361
|
+
source,
|
|
362
|
+
chunk,
|
|
363
|
+
expiresIn: this.options.expiresIn,
|
|
364
|
+
fetchImpl: this.options.fetchImpl
|
|
365
|
+
}));
|
|
366
|
+
this.cache.set(key, existing);
|
|
367
|
+
}
|
|
368
|
+
return existing;
|
|
369
|
+
}
|
|
370
|
+
run(task) {
|
|
371
|
+
return new Promise((resolve, reject) => {
|
|
372
|
+
const start = () => {
|
|
373
|
+
this.active += 1;
|
|
374
|
+
task().then(resolve, reject).finally(() => {
|
|
375
|
+
this.active -= 1;
|
|
376
|
+
this.queue.shift()?.();
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
if (this.active < Math.max(1, this.options.concurrency))
|
|
380
|
+
start();
|
|
381
|
+
else
|
|
382
|
+
this.queue.push(start);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
295
386
|
export function calculateOptimalChunkSize(totalSize) {
|
|
296
387
|
const mb = 1024 * 1024;
|
|
297
388
|
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;
|
|
@@ -44,6 +45,7 @@ export interface TransferCreateRequest {
|
|
|
44
45
|
callbacks?: CallbackConfig[];
|
|
45
46
|
test_mode?: boolean;
|
|
46
47
|
progressive_mode?: boolean;
|
|
48
|
+
signed_url_flow?: SignedUrlFlow;
|
|
47
49
|
}
|
|
48
50
|
export interface RawTransferCreateInput {
|
|
49
51
|
sources: SourceConfig[];
|
|
@@ -59,6 +61,7 @@ export interface RawTransferCreateInput {
|
|
|
59
61
|
*/
|
|
60
62
|
testMode?: boolean;
|
|
61
63
|
progressiveMode?: boolean;
|
|
64
|
+
signedUrlFlow?: SignedUrlFlow;
|
|
62
65
|
}
|
|
63
66
|
export interface TransferCreateResponse {
|
|
64
67
|
success: boolean;
|
|
@@ -186,6 +189,7 @@ export interface ProviderTransferCreateInput {
|
|
|
186
189
|
distribute?: boolean;
|
|
187
190
|
onPrepared?: (prepared: TransferPrepareResponse) => void | Promise<void>;
|
|
188
191
|
throwIfCancelled?: (transferId: string) => void | Promise<void>;
|
|
192
|
+
signedUrlFlow?: SignedUrlFlow;
|
|
189
193
|
}
|
|
190
194
|
export interface PreparedHttpSource {
|
|
191
195
|
source_id: string;
|
|
@@ -230,6 +234,7 @@ export interface SignedChunkRoute {
|
|
|
230
234
|
chunk_size: number;
|
|
231
235
|
expires_at?: string;
|
|
232
236
|
headers?: Record<string, string>;
|
|
237
|
+
dest_headers?: Record<string, string>;
|
|
233
238
|
metadata?: Record<string, unknown>;
|
|
234
239
|
}
|
|
235
240
|
export interface TransferPrepareResponse {
|
|
@@ -244,6 +249,7 @@ export interface TransferPrepareResponse {
|
|
|
244
249
|
logical_chunks?: number;
|
|
245
250
|
total_chunks?: number;
|
|
246
251
|
chunk_plan: ChunkSigningPlanItem[];
|
|
252
|
+
signed_url_flow?: SignedUrlFlow;
|
|
247
253
|
error?: string;
|
|
248
254
|
message?: string;
|
|
249
255
|
}
|
|
@@ -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,12 @@ export declare function signDestinationRoute(input: {
|
|
|
43
51
|
deleteUrl?: string;
|
|
44
52
|
fetchImpl?: typeof fetch;
|
|
45
53
|
}): Promise<SignedChunkRoute>;
|
|
54
|
+
export declare function computeChunkContentMd5(input: {
|
|
55
|
+
source: ProviderSourceConfig;
|
|
56
|
+
chunk: ChunkSigningPlanItem;
|
|
57
|
+
expiresIn: number;
|
|
58
|
+
fetchImpl?: typeof fetch;
|
|
59
|
+
}): Promise<string>;
|
|
46
60
|
export declare function isS3CompatibleProvider(config: ProviderSourceConfig | ProviderDestinationConfig): config is AnyS3CompatibleProviderConfig;
|
|
47
61
|
export declare function s3CompatibleEndpoint(source: AnyS3CompatibleProviderConfig): string | undefined;
|
|
48
62
|
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() {
|
|
@@ -96,13 +97,26 @@ export async function signAbortMultipartUpload(destination, objectKey, uploadId,
|
|
|
96
97
|
}
|
|
97
98
|
throw new Error(`multipart abort is not supported for ${destination.provider}`);
|
|
98
99
|
}
|
|
99
|
-
export async function signListMultipartUpload(destination, objectKey, uploadId, expiresIn) {
|
|
100
|
+
export async function signListMultipartUpload(destination, objectKey, uploadId, expiresIn, options = {}) {
|
|
100
101
|
if (isS3CompatibleProvider(destination)) {
|
|
101
102
|
const client = createS3CompatibleClient(destination);
|
|
102
|
-
return getSignedUrl(client, new ListPartsCommand({
|
|
103
|
+
return getSignedUrl(client, new ListPartsCommand({
|
|
104
|
+
Bucket: destination.bucket,
|
|
105
|
+
Key: objectKey,
|
|
106
|
+
UploadId: uploadId,
|
|
107
|
+
MaxParts: options.maxParts,
|
|
108
|
+
PartNumberMarker: options.partNumberMarker === undefined ? undefined : String(options.partNumberMarker)
|
|
109
|
+
}), { expiresIn });
|
|
103
110
|
}
|
|
104
111
|
throw new Error(`multipart list-parts is not supported for ${destination.provider}`);
|
|
105
112
|
}
|
|
113
|
+
export function listPartsPageMarker(partNumber, pageSize = 1000) {
|
|
114
|
+
const marker = Math.floor((Math.max(1, partNumber) - 1) / pageSize) * pageSize;
|
|
115
|
+
return marker > 0 ? marker : undefined;
|
|
116
|
+
}
|
|
117
|
+
export function listPartsVerifyMarker(partNumber) {
|
|
118
|
+
return partNumber > 1 ? partNumber - 1 : undefined;
|
|
119
|
+
}
|
|
106
120
|
export async function signUploadPartCopy(destination, finalObjectKey, stagingObjectKey, uploadId, partNumber, expiresIn, copySourceRange) {
|
|
107
121
|
if (isS3CompatibleProvider(destination)) {
|
|
108
122
|
const client = createS3CompatibleClient(destination);
|
|
@@ -163,7 +177,8 @@ export async function signDestinationRoute(input) {
|
|
|
163
177
|
uploadId: input.uploadId,
|
|
164
178
|
partNumber: input.partNumber,
|
|
165
179
|
useStagingPut: input.commitMethod === "upload_part_copy",
|
|
166
|
-
fetchImpl: input.fetchImpl
|
|
180
|
+
fetchImpl: input.fetchImpl,
|
|
181
|
+
contentMd5: input.contentMd5
|
|
167
182
|
});
|
|
168
183
|
const sourceRoute = await signSourceRoute({
|
|
169
184
|
source: input.source,
|
|
@@ -182,6 +197,7 @@ export async function signDestinationRoute(input) {
|
|
|
182
197
|
chunk_size: input.chunk.chunk_size,
|
|
183
198
|
expires_at: expiresAtIso(input.expiresIn),
|
|
184
199
|
headers: sourceRoute.headers,
|
|
200
|
+
...(input.contentMd5 ? { dest_headers: { "Content-MD5": input.contentMd5 } } : {}),
|
|
185
201
|
metadata: {
|
|
186
202
|
...(input.target.metadata ?? {}),
|
|
187
203
|
...(input.transferId ? { transfer_id: input.transferId } : {}),
|
|
@@ -195,6 +211,9 @@ export async function signDestinationRoute(input) {
|
|
|
195
211
|
...(input.deleteUrl ? { delete_url: input.deleteUrl } : {}),
|
|
196
212
|
...(input.uploadId ? { upload_id: input.uploadId } : {}),
|
|
197
213
|
...(input.partNumber ? { part_number: input.partNumber } : {}),
|
|
214
|
+
...(input.contentMd5 ? { checksum_algorithm: "content-md5", checksum_value: input.contentMd5 } : {}),
|
|
215
|
+
...(input.partVerifyUrl ? { part_verify_url: input.partVerifyUrl } : {}),
|
|
216
|
+
...(input.listPageUrl ? { list_page_url: input.listPageUrl } : {}),
|
|
198
217
|
...(input.completeUrl ? { complete_url: input.completeUrl } : {}),
|
|
199
218
|
...(input.abortUrl ? { abort_url: input.abortUrl } : {}),
|
|
200
219
|
...(input.listUrl ? { list_url: input.listUrl } : {})
|
|
@@ -238,7 +257,8 @@ async function signDestinationUrl(input) {
|
|
|
238
257
|
Bucket: input.destination.bucket,
|
|
239
258
|
Key: input.uploadPartObjectKey,
|
|
240
259
|
UploadId: input.uploadId,
|
|
241
|
-
PartNumber: input.partNumber
|
|
260
|
+
PartNumber: input.partNumber,
|
|
261
|
+
ContentMD5: input.contentMd5
|
|
242
262
|
}), { expiresIn: input.expiresIn });
|
|
243
263
|
}
|
|
244
264
|
return getSignedUrl(client, new PutObjectCommand({ Bucket: input.destination.bucket, Key: input.objectKey }), { expiresIn: input.expiresIn });
|
|
@@ -248,6 +268,25 @@ async function signDestinationUrl(input) {
|
|
|
248
268
|
}
|
|
249
269
|
return unsupportedProviderConfig(input.destination);
|
|
250
270
|
}
|
|
271
|
+
export async function computeChunkContentMd5(input) {
|
|
272
|
+
const sourceRoute = await signSourceRoute({
|
|
273
|
+
source: input.source,
|
|
274
|
+
fallbackUrl: input.chunk.source_url,
|
|
275
|
+
chunk: input.chunk,
|
|
276
|
+
expiresIn: input.expiresIn,
|
|
277
|
+
fetchImpl: input.fetchImpl
|
|
278
|
+
});
|
|
279
|
+
const fetchImpl = input.fetchImpl ?? globalThis.fetch;
|
|
280
|
+
const response = await fetchImpl(sourceRoute.url, { method: "GET", headers: sourceRoute.headers });
|
|
281
|
+
if (!response.ok) {
|
|
282
|
+
throw new Error(`chunk checksum fetch failed with ${response.status}: ${await response.text()}`);
|
|
283
|
+
}
|
|
284
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
285
|
+
if (body.length !== input.chunk.chunk_size) {
|
|
286
|
+
throw new Error(`chunk checksum fetch size mismatch: expected ${input.chunk.chunk_size}, got ${body.length}`);
|
|
287
|
+
}
|
|
288
|
+
return createHash("md5").update(body).digest("base64");
|
|
289
|
+
}
|
|
251
290
|
function createS3CompatibleClient(source, endpoint = s3CompatibleEndpoint(source)) {
|
|
252
291
|
return new S3Client({
|
|
253
292
|
region: s3CompatibleRegion(source),
|