@beam-network/sdk 0.2.15 → 0.2.17
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/README.md +7 -1
- package/dist/client.d.ts +11 -6
- package/dist/client.js +273 -427
- package/dist/models.d.ts +14 -40
- package/dist/nats-control.d.ts +72 -0
- package/dist/nats-control.js +227 -0
- package/dist/provider-signing.d.ts +0 -16
- package/dist/provider-signing.js +0 -301
- package/package.json +35 -33
package/dist/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { abortMultipartUpload,
|
|
2
|
-
|
|
1
|
+
import { abortMultipartUpload, createMultipartUpload, nowIso, prepareProviderDestination, prepareProviderSource, listPartsPageMarker, listPartsVerifyMarker, signAbortMultipartUpload, signCompleteMultipartUpload, signDeleteObject, signDestinationRoute, signListMultipartUpload, signListObjects, signUploadPartCopy } from "./provider-signing.js";
|
|
2
|
+
import { BeamTransferControl, BEAM_DEFAULT_NATS_URL } from "./nats-control.js";
|
|
3
|
+
export { BEAM_DEFAULT_NATS_URL };
|
|
3
4
|
export class BeamApiError extends Error {
|
|
4
5
|
status;
|
|
5
6
|
body;
|
|
@@ -11,84 +12,110 @@ export class BeamApiError extends Error {
|
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
export class BeamClient {
|
|
14
|
-
baseUrl;
|
|
15
15
|
apiKey;
|
|
16
|
+
natsUrl;
|
|
16
17
|
fetchImpl;
|
|
18
|
+
control;
|
|
19
|
+
recoverySigners = new Map();
|
|
17
20
|
constructor(options = {}) {
|
|
18
21
|
if (!options.apiKey?.trim()) {
|
|
19
22
|
throw new Error("apiKey is required.");
|
|
20
23
|
}
|
|
21
|
-
this.baseUrl = normalizeBaseUrl(resolveBaseUrl(options));
|
|
22
24
|
this.apiKey = options.apiKey;
|
|
25
|
+
this.natsUrl = options.natsUrl ?? options.natsWsUrl ?? BEAM_DEFAULT_NATS_URL;
|
|
26
|
+
this.control = new BeamTransferControl({
|
|
27
|
+
apiKey: this.apiKey,
|
|
28
|
+
natsUrl: options.natsUrl,
|
|
29
|
+
natsWsUrl: options.natsWsUrl,
|
|
30
|
+
environment: options.environment,
|
|
31
|
+
subjectPrefix: options.transferClientSubjectPrefix,
|
|
32
|
+
transferRuntimeShardCount: options.transferRuntimeShardCount,
|
|
33
|
+
requestTimeoutMs: options.requestTimeoutMs,
|
|
34
|
+
maxPayloadBytes: options.maxPayloadBytes
|
|
35
|
+
});
|
|
23
36
|
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
24
37
|
if (!this.fetchImpl) {
|
|
25
38
|
throw new Error("A fetch implementation is required.");
|
|
26
39
|
}
|
|
27
40
|
}
|
|
41
|
+
async close() {
|
|
42
|
+
this.stopAllRecoverySigners();
|
|
43
|
+
return this.control.close();
|
|
44
|
+
}
|
|
28
45
|
createRawTransfer(input) {
|
|
29
46
|
const body = compact({
|
|
47
|
+
transfer_id: newTransferId(),
|
|
30
48
|
sources: input.sources,
|
|
31
49
|
destinations: input.destinations,
|
|
32
50
|
total_size: input.totalSize,
|
|
33
|
-
chunk_size: input.chunkSize
|
|
51
|
+
chunk_size: input.chunkSize,
|
|
34
52
|
name: input.name,
|
|
35
53
|
merkle_root: input.merkleRoot,
|
|
36
54
|
chunk_hashes: input.chunkHashes,
|
|
37
55
|
callbacks: input.callbacks,
|
|
38
56
|
test_mode: input.testMode || undefined,
|
|
39
|
-
experimental: input.experimental || undefined,
|
|
40
57
|
progressive_mode: input.progressiveMode || undefined,
|
|
41
58
|
signed_url_flow: input.signedUrlFlow
|
|
42
59
|
});
|
|
43
|
-
return this.request("
|
|
60
|
+
return this.control.request("transfer.create", body);
|
|
44
61
|
}
|
|
45
62
|
createTransfer(input) {
|
|
46
63
|
return this.prepareProviderTransfer(input);
|
|
47
64
|
}
|
|
48
65
|
transferStatus(transferId) {
|
|
49
66
|
validateId(transferId, "transferId");
|
|
50
|
-
return this.request("
|
|
67
|
+
return this.control.request("transfer.status", { transfer_id: transferId }, transferId);
|
|
51
68
|
}
|
|
52
69
|
distributeTransfer(transferId) {
|
|
53
70
|
validateId(transferId, "transferId");
|
|
54
|
-
return this.request("
|
|
55
|
-
transfer_id: transferId
|
|
56
|
-
});
|
|
71
|
+
return this.control.request("transfer.distribute", { transfer_id: transferId }, transferId);
|
|
57
72
|
}
|
|
58
|
-
cancelTransfer(transferId) {
|
|
73
|
+
async cancelTransfer(transferId) {
|
|
59
74
|
validateId(transferId, "transferId");
|
|
60
|
-
|
|
75
|
+
try {
|
|
76
|
+
return await this.control.request("transfer.cancel", { transfer_id: transferId }, transferId);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
this.stopRecoverySigner(transferId);
|
|
80
|
+
}
|
|
61
81
|
}
|
|
62
82
|
planTransfer(input) {
|
|
63
|
-
return this.request("
|
|
83
|
+
return this.control.request("transfer.plan", compact({
|
|
64
84
|
sources: input.sources,
|
|
65
85
|
destinations: input.destinations,
|
|
66
86
|
name: input.name,
|
|
67
87
|
test_mode: input.testMode || undefined,
|
|
68
|
-
experimental: input.experimental || undefined,
|
|
69
88
|
urls_expires_at: input.urlsExpiresAt,
|
|
70
89
|
signed_url_flow: input.signedUrlFlow
|
|
71
90
|
}));
|
|
72
91
|
}
|
|
73
92
|
prepareTransfer(input) {
|
|
74
|
-
|
|
93
|
+
const transferId = newTransferId();
|
|
94
|
+
return this.control.request("transfer.prepare", compact({
|
|
95
|
+
transfer_id: transferId,
|
|
75
96
|
sources: input.sources,
|
|
76
97
|
destinations: input.destinations,
|
|
77
98
|
name: input.name,
|
|
78
99
|
test_mode: input.testMode || undefined,
|
|
79
|
-
experimental: input.experimental || undefined,
|
|
80
100
|
urls_expires_at: input.urlsExpiresAt,
|
|
81
101
|
signed_url_flow: input.signedUrlFlow
|
|
82
|
-
}));
|
|
102
|
+
}), transferId);
|
|
83
103
|
}
|
|
84
|
-
attachSignedUrls(transferId, input) {
|
|
104
|
+
async attachSignedUrls(transferId, input) {
|
|
85
105
|
validateId(transferId, "transferId");
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
106
|
+
const stream = new RouteStreamSender(this.control, {
|
|
107
|
+
transferId,
|
|
108
|
+
totalRoutes: input.chunkRoutes.length,
|
|
109
|
+
totalChunks: countDistinctRouteChunks(input.chunkRoutes),
|
|
110
|
+
urlsExpiresAt: input.urlsExpiresAt,
|
|
111
|
+
signedUrlFlow: input.signedUrlFlow ?? "signed_url_v1",
|
|
112
|
+
autoDistribute: input.autoDistribute ?? true
|
|
113
|
+
});
|
|
114
|
+
await stream.begin();
|
|
115
|
+
for (const route of input.chunkRoutes) {
|
|
116
|
+
await stream.addRoute(route);
|
|
117
|
+
}
|
|
118
|
+
return stream.complete();
|
|
92
119
|
}
|
|
93
120
|
async prepareProviderTransfer(input) {
|
|
94
121
|
const expiresIn = input.expiresIn ?? 3600;
|
|
@@ -102,93 +129,19 @@ export class BeamClient {
|
|
|
102
129
|
}
|
|
103
130
|
destinationsById.set(preparedDestination.destination_id, destination);
|
|
104
131
|
});
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
132
|
+
await input.throwIfCancelled?.();
|
|
133
|
+
const preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
|
|
134
|
+
await input.throwIfCancelled?.();
|
|
135
|
+
await input.onBeforeTransferPrepare?.();
|
|
136
|
+
const prepared = await this.prepareTransfer({
|
|
137
|
+
sources: preparedSources,
|
|
138
|
+
destinations: preparedDestinations,
|
|
139
|
+
name: input.name,
|
|
140
|
+
testMode: input.testMode,
|
|
141
|
+
signedUrlFlow: requestedSignedUrlFlow
|
|
114
142
|
});
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (requestedSignedUrlFlow === "signed_url_v1") {
|
|
118
|
-
await input.throwIfCancelled?.();
|
|
119
|
-
const plannedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSourceForPlan(source, { index, fetchImpl: this.fetchImpl })));
|
|
120
|
-
const planned = await this.planTransfer({
|
|
121
|
-
sources: plannedSources,
|
|
122
|
-
destinations: preparedDestinations,
|
|
123
|
-
name: input.name,
|
|
124
|
-
testMode: input.testMode,
|
|
125
|
-
experimental: input.experimental,
|
|
126
|
-
signedUrlFlow: requestedSignedUrlFlow
|
|
127
|
-
});
|
|
128
|
-
if (!planned.success) {
|
|
129
|
-
return failedPlanAsPrepareResponse(planned);
|
|
130
|
-
}
|
|
131
|
-
await input.onPlanned?.(planned);
|
|
132
|
-
await input.throwIfCancelled?.();
|
|
133
|
-
const plannedSourcesById = mapSourcesByPreparedId(plannedSources, input.sources);
|
|
134
|
-
const chunksBySource = checksumChunksBySource(planned.chunk_plan, destinationsById);
|
|
135
|
-
const totalWarmupChunks = [...chunksBySource.values()].reduce((sum, chunks) => sum + chunks.length, 0);
|
|
136
|
-
const totalWarmupBytes = [...chunksBySource.values()].reduce((sum, chunks) => sum + chunks.reduce((chunkSum, chunk) => chunkSum + chunk.chunk_size, 0), 0);
|
|
137
|
-
if (totalWarmupChunks > 0) {
|
|
138
|
-
const startedAt = Date.now();
|
|
139
|
-
await emitChecksumEvent(input, {
|
|
140
|
-
event: "checksum_warmup_started",
|
|
141
|
-
sourceId: "all",
|
|
142
|
-
chunkCount: totalWarmupChunks,
|
|
143
|
-
bytes: totalWarmupBytes
|
|
144
|
-
});
|
|
145
|
-
for (const [sourceId, chunks] of chunksBySource) {
|
|
146
|
-
await input.throwIfCancelled?.();
|
|
147
|
-
const source = plannedSourcesById.get(sourceId);
|
|
148
|
-
if (!source) {
|
|
149
|
-
throw new Error(`BeamCore planned unknown source_id: ${sourceId}`);
|
|
150
|
-
}
|
|
151
|
-
await checksumCache.ensureMany(source, chunks);
|
|
152
|
-
}
|
|
153
|
-
await emitChecksumEvent(input, {
|
|
154
|
-
event: "checksum_warmup_completed",
|
|
155
|
-
sourceId: "all",
|
|
156
|
-
chunkCount: totalWarmupChunks,
|
|
157
|
-
bytes: totalWarmupBytes,
|
|
158
|
-
durationMs: Date.now() - startedAt
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
await input.throwIfCancelled?.();
|
|
162
|
-
checksumCache.seal();
|
|
163
|
-
preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
|
|
164
|
-
await input.onBeforeTransferPrepare?.();
|
|
165
|
-
prepared = await this.prepareTransfer({
|
|
166
|
-
sources: preparedSources,
|
|
167
|
-
destinations: preparedDestinations,
|
|
168
|
-
name: input.name,
|
|
169
|
-
testMode: input.testMode,
|
|
170
|
-
experimental: input.experimental,
|
|
171
|
-
signedUrlFlow: requestedSignedUrlFlow
|
|
172
|
-
});
|
|
173
|
-
if (!prepared.success) {
|
|
174
|
-
return prepared;
|
|
175
|
-
}
|
|
176
|
-
assertPreparedTransferMatchesPlan(planned, prepared);
|
|
177
|
-
}
|
|
178
|
-
else {
|
|
179
|
-
preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
|
|
180
|
-
await input.onBeforeTransferPrepare?.();
|
|
181
|
-
prepared = await this.prepareTransfer({
|
|
182
|
-
sources: preparedSources,
|
|
183
|
-
destinations: preparedDestinations,
|
|
184
|
-
name: input.name,
|
|
185
|
-
testMode: input.testMode,
|
|
186
|
-
experimental: input.experimental,
|
|
187
|
-
signedUrlFlow: requestedSignedUrlFlow
|
|
188
|
-
});
|
|
189
|
-
if (!prepared.success) {
|
|
190
|
-
return prepared;
|
|
191
|
-
}
|
|
143
|
+
if (!prepared.success) {
|
|
144
|
+
return prepared;
|
|
192
145
|
}
|
|
193
146
|
await input.onPrepared?.(prepared);
|
|
194
147
|
await input.throwIfCancelled?.(prepared.transfer_id);
|
|
@@ -201,13 +154,26 @@ export class BeamClient {
|
|
|
201
154
|
sourcesById.set(preparedSource.source_id, source);
|
|
202
155
|
});
|
|
203
156
|
const signedUrlFlow = prepared.signed_url_flow ?? requestedSignedUrlFlow;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
157
|
+
const routeStream = new RouteStreamSender(this.control, {
|
|
158
|
+
transferId: prepared.transfer_id,
|
|
159
|
+
totalRoutes: countRoutesInChunkPlan(prepared.chunk_plan),
|
|
160
|
+
totalChunks: prepared.logical_chunks ?? prepared.total_chunks ?? prepared.chunk_plan.length,
|
|
161
|
+
signedUrlFlow,
|
|
162
|
+
autoDistribute: input.distribute !== false
|
|
163
|
+
});
|
|
209
164
|
const multipartUploads = new Map();
|
|
165
|
+
await this.startProviderRouteRecoverySigner({
|
|
166
|
+
prepared,
|
|
167
|
+
sourcesById,
|
|
168
|
+
destinationsById,
|
|
169
|
+
multipartUploads,
|
|
170
|
+
expiresIn,
|
|
171
|
+
signedUrlFlow
|
|
172
|
+
});
|
|
173
|
+
let routeStreamStarted = false;
|
|
210
174
|
try {
|
|
175
|
+
await routeStream.begin();
|
|
176
|
+
routeStreamStarted = true;
|
|
211
177
|
for (const chunk of prepared.chunk_plan) {
|
|
212
178
|
await input.throwIfCancelled?.(prepared.transfer_id);
|
|
213
179
|
for (const target of chunk.destinations) {
|
|
@@ -257,7 +223,7 @@ export class BeamClient {
|
|
|
257
223
|
}
|
|
258
224
|
multipartUploads.set(multipartKey, upload);
|
|
259
225
|
}
|
|
260
|
-
|
|
226
|
+
await routeStream.addRoute(await this.signProviderRoute({
|
|
261
227
|
chunk,
|
|
262
228
|
target,
|
|
263
229
|
source,
|
|
@@ -268,35 +234,24 @@ export class BeamClient {
|
|
|
268
234
|
finalObjectKey,
|
|
269
235
|
partNumber: typeof metadata.part_number === "number"
|
|
270
236
|
? metadata.part_number
|
|
271
|
-
: chunk.
|
|
272
|
-
signedUrlFlow
|
|
273
|
-
checksumCache
|
|
237
|
+
: chunk.chunk_index * 3 + 1,
|
|
238
|
+
signedUrlFlow
|
|
274
239
|
}));
|
|
275
240
|
}
|
|
276
241
|
}
|
|
277
|
-
|
|
278
|
-
catch (error) {
|
|
279
|
-
await abortCreatedUploads(multipartUploads);
|
|
280
|
-
throw error;
|
|
281
|
-
}
|
|
282
|
-
try {
|
|
283
|
-
await input.throwIfCancelled?.(prepared.transfer_id);
|
|
284
|
-
const attached = await this.attachSignedUrls(prepared.transfer_id, {
|
|
285
|
-
chunkRoutes: routes,
|
|
286
|
-
transferKey: prepared.transfer_key
|
|
287
|
-
});
|
|
242
|
+
const attached = await routeStream.complete();
|
|
288
243
|
if (!attached.success) {
|
|
289
|
-
throw new Error(attached.error ?? attached.message ?? "
|
|
244
|
+
throw new Error(attached.error ?? attached.message ?? "route stream failed");
|
|
290
245
|
}
|
|
291
246
|
}
|
|
292
247
|
catch (error) {
|
|
248
|
+
this.stopRecoverySigner(prepared.transfer_id);
|
|
293
249
|
await abortCreatedUploads(multipartUploads);
|
|
250
|
+
if (routeStreamStarted) {
|
|
251
|
+
await cancelTransferAfterRouteStreamFailure(this, prepared.transfer_id, error);
|
|
252
|
+
}
|
|
294
253
|
throw error;
|
|
295
254
|
}
|
|
296
|
-
if (input.distribute !== false) {
|
|
297
|
-
await input.throwIfCancelled?.(prepared.transfer_id);
|
|
298
|
-
await this.distributeTransfer(prepared.transfer_id);
|
|
299
|
-
}
|
|
300
255
|
return prepared;
|
|
301
256
|
}
|
|
302
257
|
async signProviderRoute(input) {
|
|
@@ -317,7 +272,6 @@ export class BeamClient {
|
|
|
317
272
|
throw new Error("destination signing target is missing object_key");
|
|
318
273
|
}
|
|
319
274
|
if (input.signedUrlFlow === "signed_url_v1") {
|
|
320
|
-
const contentMd5 = await input.checksumCache.get(input.source, input.chunk);
|
|
321
275
|
const partVerifyUrl = await signListMultipartUpload(input.destination, input.finalObjectKey, input.upload.uploadId, input.expiresIn, { maxParts: 1, partNumberMarker: listPartsVerifyMarker(input.partNumber) });
|
|
322
276
|
const listPageUrl = await signListMultipartUpload(input.destination, input.finalObjectKey, input.upload.uploadId, input.expiresIn, { maxParts: 1000, partNumberMarker: listPartsPageMarker(input.partNumber) });
|
|
323
277
|
return signDestinationRoute({
|
|
@@ -334,7 +288,6 @@ export class BeamClient {
|
|
|
334
288
|
listUrl: input.upload.listUrl,
|
|
335
289
|
partVerifyUrl,
|
|
336
290
|
listPageUrl,
|
|
337
|
-
contentMd5,
|
|
338
291
|
transferId: input.transferId,
|
|
339
292
|
multipartGroupId: input.upload.multipartGroupId,
|
|
340
293
|
multipartCreatedAt: input.upload.multipartCreatedAt,
|
|
@@ -381,6 +334,80 @@ export class BeamClient {
|
|
|
381
334
|
fetchImpl: this.fetchImpl
|
|
382
335
|
});
|
|
383
336
|
}
|
|
337
|
+
async startProviderRouteRecoverySigner(input) {
|
|
338
|
+
this.stopRecoverySigner(input.prepared.transfer_id);
|
|
339
|
+
const stop = await this.control.serveRouteRecoverySigner(input.prepared.transfer_id, async (payload) => {
|
|
340
|
+
const routes = [];
|
|
341
|
+
for (const requested of payload.chunks) {
|
|
342
|
+
const chunk = input.prepared.chunk_plan.find((candidate) => candidate.chunk_index === requested.chunk_index && candidate.source_id === requested.source_id);
|
|
343
|
+
if (!chunk) {
|
|
344
|
+
throw new Error(`recovery signing chunk not found: ${requested.source_id}:${requested.chunk_index}`);
|
|
345
|
+
}
|
|
346
|
+
const target = chunk.destinations.find((candidate) => candidate.destination_id === requested.destination_id);
|
|
347
|
+
if (!target) {
|
|
348
|
+
throw new Error(`recovery signing destination not found: ${requested.destination_id}`);
|
|
349
|
+
}
|
|
350
|
+
const source = input.sourcesById.get(requested.source_id);
|
|
351
|
+
const destination = input.destinationsById.get(requested.destination_id);
|
|
352
|
+
if (!source || !destination) {
|
|
353
|
+
throw new Error("recovery signing provider configuration missing");
|
|
354
|
+
}
|
|
355
|
+
const upload = input.multipartUploads.get(requested.multipart_group_id) ?? input.multipartUploads.get(`${input.prepared.transfer_id}:${requested.destination_id}:${requested.source_id}:${requested.final_object_key}`);
|
|
356
|
+
if (!upload) {
|
|
357
|
+
throw new Error(`recovery signing multipart upload state missing for ${requested.multipart_group_id}`);
|
|
358
|
+
}
|
|
359
|
+
const metadata = {
|
|
360
|
+
...(target.metadata ?? {}),
|
|
361
|
+
...(requested.destination_metadata ?? {}),
|
|
362
|
+
transfer_id: input.prepared.transfer_id,
|
|
363
|
+
final_object_key: requested.final_object_key,
|
|
364
|
+
upload_id: requested.upload_id ?? upload.uploadId,
|
|
365
|
+
multipart_group_id: requested.multipart_group_id,
|
|
366
|
+
multipart_created_at: requested.multipart_created_at ?? upload.multipartCreatedAt,
|
|
367
|
+
delivery_index: requested.delivery_index,
|
|
368
|
+
part_number: requested.part_number,
|
|
369
|
+
logical_attempt_index: requested.logical_attempt_index,
|
|
370
|
+
attempt_slot: requested.attempt_slot,
|
|
371
|
+
route_generation_id: requested.route_generation_id
|
|
372
|
+
};
|
|
373
|
+
routes.push(await this.signProviderRoute({
|
|
374
|
+
chunk,
|
|
375
|
+
target: {
|
|
376
|
+
...target,
|
|
377
|
+
object_key: target.object_key ?? requested.final_object_key,
|
|
378
|
+
metadata
|
|
379
|
+
},
|
|
380
|
+
source,
|
|
381
|
+
destination,
|
|
382
|
+
expiresIn: input.expiresIn,
|
|
383
|
+
upload,
|
|
384
|
+
transferId: input.prepared.transfer_id,
|
|
385
|
+
finalObjectKey: requested.final_object_key,
|
|
386
|
+
partNumber: requested.part_number,
|
|
387
|
+
signedUrlFlow: input.signedUrlFlow
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
transfer_id: payload.transfer_id,
|
|
392
|
+
route_generation_id: payload.route_generation_id,
|
|
393
|
+
signed_at: nowIso(),
|
|
394
|
+
chunk_routes: routes
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
this.recoverySigners.set(input.prepared.transfer_id, stop);
|
|
398
|
+
}
|
|
399
|
+
stopRecoverySigner(transferId) {
|
|
400
|
+
const stop = this.recoverySigners.get(transferId);
|
|
401
|
+
if (!stop)
|
|
402
|
+
return;
|
|
403
|
+
stop();
|
|
404
|
+
this.recoverySigners.delete(transferId);
|
|
405
|
+
}
|
|
406
|
+
stopAllRecoverySigners() {
|
|
407
|
+
for (const transferId of [...this.recoverySigners.keys()]) {
|
|
408
|
+
this.stopRecoverySigner(transferId);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
384
411
|
async createAndDistribute(input) {
|
|
385
412
|
const transfer = await this.createRawTransfer(input);
|
|
386
413
|
if (transfer.success) {
|
|
@@ -395,12 +422,15 @@ export class BeamClient {
|
|
|
395
422
|
while (true) {
|
|
396
423
|
const status = await this.transferStatus(transferId);
|
|
397
424
|
if (status.status === "completed") {
|
|
425
|
+
this.stopRecoverySigner(transferId);
|
|
398
426
|
return status;
|
|
399
427
|
}
|
|
400
428
|
if (status.status === "failed") {
|
|
429
|
+
this.stopRecoverySigner(transferId);
|
|
401
430
|
throw new Error(`Transfer failed: ${status.error_message ?? "unknown error"}`);
|
|
402
431
|
}
|
|
403
432
|
if (status.status === "cancelled") {
|
|
433
|
+
this.stopRecoverySigner(transferId);
|
|
404
434
|
throw new Error("Transfer cancelled");
|
|
405
435
|
}
|
|
406
436
|
if (Date.now() - startedAt >= timeoutMs) {
|
|
@@ -409,315 +439,131 @@ export class BeamClient {
|
|
|
409
439
|
await sleep(pollIntervalMs);
|
|
410
440
|
}
|
|
411
441
|
}
|
|
412
|
-
async request(method, path, body) {
|
|
413
|
-
const headers = {
|
|
414
|
-
Accept: "application/json",
|
|
415
|
-
"X-Api-Key": this.apiKey
|
|
416
|
-
};
|
|
417
|
-
let payload;
|
|
418
|
-
if (body !== undefined) {
|
|
419
|
-
headers["Content-Type"] = "application/json";
|
|
420
|
-
payload = JSON.stringify(body);
|
|
421
|
-
}
|
|
422
|
-
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
423
|
-
method,
|
|
424
|
-
headers,
|
|
425
|
-
body: payload
|
|
426
|
-
});
|
|
427
|
-
const text = await response.text();
|
|
428
|
-
if (!response.ok) {
|
|
429
|
-
throw new BeamApiError(`BEAM API request failed with ${response.status}`, response.status, text);
|
|
430
|
-
}
|
|
431
|
-
return (text ? JSON.parse(text) : {});
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
function checksumChunksBySource(chunkPlan, destinationsById) {
|
|
435
|
-
const chunksBySource = new Map();
|
|
436
|
-
for (const chunk of chunkPlan) {
|
|
437
|
-
const needsChecksum = chunk.destinations.some((target) => {
|
|
438
|
-
const destination = destinationsById.get(target.destination_id);
|
|
439
|
-
return destination ? isS3CompatibleProvider(destination) : false;
|
|
440
|
-
});
|
|
441
|
-
if (!needsChecksum)
|
|
442
|
-
continue;
|
|
443
|
-
const chunks = chunksBySource.get(chunk.source_id) ?? [];
|
|
444
|
-
chunks.push(chunk);
|
|
445
|
-
chunksBySource.set(chunk.source_id, chunks);
|
|
446
|
-
}
|
|
447
|
-
return chunksBySource;
|
|
448
|
-
}
|
|
449
|
-
function mapSourcesByPreparedId(preparedSources, providerSources) {
|
|
450
|
-
const sourcesById = new Map();
|
|
451
|
-
preparedSources.forEach((preparedSource, index) => {
|
|
452
|
-
const source = providerSources[index];
|
|
453
|
-
if (!source) {
|
|
454
|
-
throw new Error(`missing provider source config for planned source ${preparedSource.source_id}`);
|
|
455
|
-
}
|
|
456
|
-
sourcesById.set(preparedSource.source_id, source);
|
|
457
|
-
});
|
|
458
|
-
return sourcesById;
|
|
459
|
-
}
|
|
460
|
-
function failedPlanAsPrepareResponse(plan) {
|
|
461
|
-
return {
|
|
462
|
-
success: false,
|
|
463
|
-
transfer_id: "",
|
|
464
|
-
chunk_size: plan.chunk_size,
|
|
465
|
-
total_size: plan.total_size,
|
|
466
|
-
total_sources: plan.total_sources,
|
|
467
|
-
total_destinations: plan.total_destinations,
|
|
468
|
-
logical_chunks: plan.logical_chunks,
|
|
469
|
-
total_chunks: plan.total_chunks,
|
|
470
|
-
chunk_plan: plan.chunk_plan ?? [],
|
|
471
|
-
signed_url_flow: plan.signed_url_flow,
|
|
472
|
-
error: plan.error,
|
|
473
|
-
message: plan.message
|
|
474
|
-
};
|
|
475
442
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
plan.logical_chunks === prepared.logical_chunks &&
|
|
482
|
-
plan.total_chunks === prepared.total_chunks;
|
|
483
|
-
if (!summaryMatches || planSignature(plan.chunk_plan) !== planSignature(prepared.chunk_plan)) {
|
|
484
|
-
throw new Error("prepared transfer chunk plan does not match preflight checksum plan");
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
function planSignature(chunks) {
|
|
488
|
-
return JSON.stringify(chunks.map((chunk) => ({
|
|
489
|
-
chunk_index: chunk.chunk_index,
|
|
490
|
-
source_id: chunk.source_id,
|
|
491
|
-
source_chunk_index: chunk.source_chunk_index,
|
|
492
|
-
source_offset: chunk.source_offset,
|
|
493
|
-
chunk_size: chunk.chunk_size,
|
|
494
|
-
destinations: chunk.destinations.map((target) => {
|
|
495
|
-
const metadata = target.metadata ?? {};
|
|
496
|
-
return {
|
|
497
|
-
destination_id: target.destination_id,
|
|
498
|
-
provider: target.provider,
|
|
499
|
-
final_object_key: typeof metadata.final_object_key === "string" ? metadata.final_object_key : target.object_key,
|
|
500
|
-
part_number: typeof metadata.part_number === "number" ? metadata.part_number : undefined,
|
|
501
|
-
delivery_index: typeof metadata.delivery_index === "number" ? metadata.delivery_index : undefined
|
|
502
|
-
};
|
|
503
|
-
})
|
|
504
|
-
})));
|
|
505
|
-
}
|
|
506
|
-
async function emitChecksumEvent(input, event) {
|
|
507
|
-
if (!input.onChecksumEvent)
|
|
508
|
-
return;
|
|
509
|
-
await input.onChecksumEvent(event);
|
|
510
|
-
}
|
|
511
|
-
class ChunkChecksumCache {
|
|
443
|
+
// Target route-stream batches at 512 signed routes. The transport still splits
|
|
444
|
+
// earlier when the encoded NATS request would exceed maxPayloadBytes.
|
|
445
|
+
const ROUTE_STREAM_TARGET_BATCH_ROUTES = 512;
|
|
446
|
+
class RouteStreamSender {
|
|
447
|
+
control;
|
|
512
448
|
options;
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
constructor(options) {
|
|
449
|
+
streamId = newTransferId();
|
|
450
|
+
checksum = new RouteKeysChecksum();
|
|
451
|
+
batch = [];
|
|
452
|
+
batchIndex = 0;
|
|
453
|
+
constructor(control, options) {
|
|
454
|
+
this.control = control;
|
|
518
455
|
this.options = options;
|
|
519
456
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
async ensureMany(source, chunks) {
|
|
537
|
-
const unique = this.uniqueChunks(chunks);
|
|
538
|
-
this.preloadMany(source, unique);
|
|
539
|
-
await Promise.all(unique.map((chunk) => this.get(source, chunk)));
|
|
540
|
-
}
|
|
541
|
-
get(source, chunk) {
|
|
542
|
-
const key = this.key(chunk);
|
|
543
|
-
let existing = this.cache.get(key);
|
|
544
|
-
if (!existing) {
|
|
545
|
-
if (this.sealed) {
|
|
546
|
-
return Promise.reject(new Error(`checksum cache missing ${key} after preflight warmup; refusing post-prepare source read`));
|
|
547
|
-
}
|
|
548
|
-
existing = deferredChecksum();
|
|
549
|
-
this.cache.set(key, existing);
|
|
550
|
-
void this.run(() => this.fillGroup(source, [chunk])).catch(() => undefined);
|
|
457
|
+
async begin() {
|
|
458
|
+
await this.control.request("transfer.route_stream.begin", compact({
|
|
459
|
+
transfer_id: this.options.transferId,
|
|
460
|
+
stream_id: this.streamId,
|
|
461
|
+
total_routes: this.options.totalRoutes,
|
|
462
|
+
total_chunks: this.options.totalChunks,
|
|
463
|
+
urls_expires_at: this.options.urlsExpiresAt,
|
|
464
|
+
signed_url_flow: this.options.signedUrlFlow,
|
|
465
|
+
auto_distribute: this.options.autoDistribute
|
|
466
|
+
}), this.options.transferId);
|
|
467
|
+
}
|
|
468
|
+
async addRoute(route) {
|
|
469
|
+
await this.checksum.add(routeKeyForSignedRoute(route));
|
|
470
|
+
this.batch.push(route);
|
|
471
|
+
if (this.batch.length >= ROUTE_STREAM_TARGET_BATCH_ROUTES) {
|
|
472
|
+
await this.flush();
|
|
551
473
|
}
|
|
552
|
-
return existing.promise;
|
|
553
474
|
}
|
|
554
|
-
async
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
}
|
|
575
|
-
if (!misses.length)
|
|
576
|
-
return;
|
|
577
|
-
await this.options.throwIfCancelled?.();
|
|
578
|
-
const startedAt = Date.now();
|
|
579
|
-
const checksums = await computeChunksContentMd5({
|
|
580
|
-
source,
|
|
581
|
-
chunks: misses,
|
|
582
|
-
expiresIn: this.options.expiresIn,
|
|
583
|
-
fetchImpl: this.options.fetchImpl,
|
|
584
|
-
groupSizeBytes: this.options.groupSizeBytes,
|
|
585
|
-
readTimeoutMs: this.options.readTimeoutMs
|
|
586
|
-
});
|
|
587
|
-
for (const chunk of misses) {
|
|
588
|
-
await this.options.throwIfCancelled?.();
|
|
589
|
-
const checksum = checksums.get(chunk.source_chunk_index);
|
|
590
|
-
if (!checksum)
|
|
591
|
-
throw new Error(`checksum missing for source chunk ${chunk.source_chunk_index}`);
|
|
592
|
-
await this.options.provider?.setContentMd5?.({ source, chunk, algorithm: "content-md5", contentMd5: checksum });
|
|
593
|
-
const deferred = this.cache.get(this.key(chunk));
|
|
594
|
-
deferred?.resolve(checksum);
|
|
595
|
-
unresolved.delete(this.key(chunk));
|
|
596
|
-
await this.emit({ event: "checksum_cold_chunk_cached", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
|
|
597
|
-
}
|
|
598
|
-
await this.emit({
|
|
599
|
-
event: "checksum_cold_group_completed",
|
|
600
|
-
sourceId: misses[0]?.source_id ?? source.id ?? "source",
|
|
601
|
-
chunkCount: misses.length,
|
|
602
|
-
bytes: misses.reduce((sum, chunk) => sum + chunk.chunk_size, 0),
|
|
603
|
-
durationMs: Date.now() - startedAt
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
catch (error) {
|
|
607
|
-
for (const key of unresolved) {
|
|
608
|
-
this.cache.get(key)?.reject(error);
|
|
609
|
-
}
|
|
610
|
-
throw error;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
uniqueChunks(chunks) {
|
|
614
|
-
const seen = new Set();
|
|
615
|
-
const output = [];
|
|
475
|
+
async complete() {
|
|
476
|
+
await this.flush();
|
|
477
|
+
return this.control.request("transfer.route_stream.complete", {
|
|
478
|
+
transfer_id: this.options.transferId,
|
|
479
|
+
stream_id: this.streamId,
|
|
480
|
+
expected_batches: this.batchIndex,
|
|
481
|
+
expected_routes: this.options.totalRoutes,
|
|
482
|
+
route_keys_checksum: this.checksum.value()
|
|
483
|
+
}, this.options.transferId);
|
|
484
|
+
}
|
|
485
|
+
async flush() {
|
|
486
|
+
if (!this.batch.length)
|
|
487
|
+
return;
|
|
488
|
+
const routes = this.batch.splice(0, this.batch.length);
|
|
489
|
+
const chunks = this.control.splitRoutesForPayload("transfer.route_stream.batch", {
|
|
490
|
+
transfer_id: this.options.transferId,
|
|
491
|
+
stream_id: this.streamId,
|
|
492
|
+
batch_id: `${this.streamId}:estimate`,
|
|
493
|
+
batch_index: this.batchIndex
|
|
494
|
+
}, routes);
|
|
616
495
|
for (const chunk of chunks) {
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
496
|
+
const batchIndex = this.batchIndex;
|
|
497
|
+
this.batchIndex += 1;
|
|
498
|
+
await this.control.request("transfer.route_stream.batch", {
|
|
499
|
+
transfer_id: this.options.transferId,
|
|
500
|
+
stream_id: this.streamId,
|
|
501
|
+
batch_id: `${this.streamId}:${batchIndex}`,
|
|
502
|
+
batch_index: batchIndex,
|
|
503
|
+
chunk_routes: chunk,
|
|
504
|
+
route_count: chunk.length,
|
|
505
|
+
route_keys_checksum: await routeKeysChecksum(chunk.map(routeKeyForSignedRoute))
|
|
506
|
+
}, this.options.transferId);
|
|
622
507
|
}
|
|
623
|
-
return output.sort((a, b) => a.source_offset - b.source_offset || a.source_chunk_index - b.source_chunk_index);
|
|
624
508
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
const
|
|
631
|
-
for (
|
|
632
|
-
|
|
633
|
-
const chunkEnd = chunk.source_offset + chunk.chunk_size;
|
|
634
|
-
const startsNewGroup = current.length === 0 || chunkStart !== groupEnd || chunkEnd - groupStart > maxBytes;
|
|
635
|
-
if (startsNewGroup) {
|
|
636
|
-
if (current.length)
|
|
637
|
-
groups.push(current);
|
|
638
|
-
current = [chunk];
|
|
639
|
-
groupStart = chunkStart;
|
|
640
|
-
groupEnd = chunkEnd;
|
|
641
|
-
}
|
|
642
|
-
else {
|
|
643
|
-
current.push(chunk);
|
|
644
|
-
groupEnd = chunkEnd;
|
|
645
|
-
}
|
|
509
|
+
}
|
|
510
|
+
class RouteKeysChecksum {
|
|
511
|
+
bytes = new Uint8Array(32);
|
|
512
|
+
count = 0;
|
|
513
|
+
async add(routeKeyValue) {
|
|
514
|
+
const digest = await sha256Bytes(routeKeyValue);
|
|
515
|
+
for (let index = 0; index < this.bytes.length; index += 1) {
|
|
516
|
+
this.bytes[index] ^= digest[index] ?? 0;
|
|
646
517
|
}
|
|
647
|
-
|
|
648
|
-
groups.push(current);
|
|
649
|
-
return groups;
|
|
650
|
-
}
|
|
651
|
-
key(chunk) {
|
|
652
|
-
return `${chunk.source_id}:${chunk.source_chunk_index}`;
|
|
518
|
+
this.count += 1;
|
|
653
519
|
}
|
|
654
|
-
|
|
655
|
-
this.
|
|
520
|
+
value() {
|
|
521
|
+
return `sha256-xor-v1:${this.count}:${hex(this.bytes)}`;
|
|
656
522
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
missing.push(this.key(chunk));
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
if (missing.length) {
|
|
666
|
-
throw new Error(`checksum cache missing ${missing.length} chunk(s) after preflight warmup; refusing post-prepare source reads`);
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
run(task) {
|
|
670
|
-
return new Promise((resolve, reject) => {
|
|
671
|
-
const start = () => {
|
|
672
|
-
this.active += 1;
|
|
673
|
-
task().then(resolve, reject).finally(() => {
|
|
674
|
-
this.active -= 1;
|
|
675
|
-
this.queue.shift()?.();
|
|
676
|
-
});
|
|
677
|
-
};
|
|
678
|
-
if (this.active < Math.max(1, this.options.concurrency))
|
|
679
|
-
start();
|
|
680
|
-
else
|
|
681
|
-
this.queue.push(start);
|
|
682
|
-
});
|
|
523
|
+
}
|
|
524
|
+
async function routeKeysChecksum(routeKeys) {
|
|
525
|
+
const checksum = new RouteKeysChecksum();
|
|
526
|
+
for (const routeKeyValue of routeKeys) {
|
|
527
|
+
await checksum.add(routeKeyValue);
|
|
683
528
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
529
|
+
return checksum.value();
|
|
530
|
+
}
|
|
531
|
+
function routeKeyForSignedRoute(route) {
|
|
532
|
+
return `${route.source_id}:${route.destination_id}:${route.chunk_index}`;
|
|
533
|
+
}
|
|
534
|
+
function countDistinctRouteChunks(routes) {
|
|
535
|
+
return new Set(routes.map((route) => `${route.source_id}:${route.chunk_index}`)).size;
|
|
536
|
+
}
|
|
537
|
+
function countRoutesInChunkPlan(chunks) {
|
|
538
|
+
return chunks.reduce((sum, chunk) => sum + chunk.destinations.length, 0);
|
|
539
|
+
}
|
|
540
|
+
async function sha256Bytes(value) {
|
|
541
|
+
const input = new TextEncoder().encode(value);
|
|
542
|
+
if (globalThis.crypto?.subtle) {
|
|
543
|
+
return new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", input));
|
|
688
544
|
}
|
|
545
|
+
const { createHash } = await import("node:crypto");
|
|
546
|
+
return new Uint8Array(createHash("sha256").update(value).digest());
|
|
689
547
|
}
|
|
690
|
-
function
|
|
691
|
-
|
|
692
|
-
let reject;
|
|
693
|
-
const promise = new Promise((res, rej) => {
|
|
694
|
-
resolve = res;
|
|
695
|
-
reject = rej;
|
|
696
|
-
});
|
|
697
|
-
return { promise, resolve, reject };
|
|
548
|
+
function hex(bytes) {
|
|
549
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
698
550
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const minChunk = 5 * mb;
|
|
703
|
-
const maxChunk = 5 * mb;
|
|
704
|
-
const targetChunks = 10;
|
|
705
|
-
if (totalSize <= minChunk) {
|
|
706
|
-
return totalSize;
|
|
551
|
+
async function cancelTransferAfterRouteStreamFailure(client, transferId, cause) {
|
|
552
|
+
try {
|
|
553
|
+
await client.cancelTransfer(transferId);
|
|
707
554
|
}
|
|
708
|
-
|
|
709
|
-
|
|
555
|
+
catch (cancelError) {
|
|
556
|
+
throw new Error(`route streaming failed (${errorMessage(cause)}) and transfer cancellation failed (${errorMessage(cancelError)})`);
|
|
710
557
|
}
|
|
711
|
-
return maxChunk;
|
|
712
558
|
}
|
|
713
|
-
function
|
|
714
|
-
|
|
715
|
-
return options.baseUrl;
|
|
716
|
-
}
|
|
717
|
-
return BEAM_DEFAULT_URL;
|
|
559
|
+
function errorMessage(error) {
|
|
560
|
+
return error instanceof Error ? error.message : String(error);
|
|
718
561
|
}
|
|
719
|
-
function
|
|
720
|
-
|
|
562
|
+
function newTransferId() {
|
|
563
|
+
const cryptoApi = globalThis.crypto;
|
|
564
|
+
if (cryptoApi?.randomUUID)
|
|
565
|
+
return cryptoApi.randomUUID();
|
|
566
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
721
567
|
}
|
|
722
568
|
function compact(input) {
|
|
723
569
|
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|