@beam-network/sdk 0.2.20 → 0.2.22
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 +1 -1
- package/dist/client.d.ts +1 -0
- package/dist/client.js +164 -78
- package/dist/models.d.ts +29 -2
- package/dist/multipart-limits.d.ts +4 -0
- package/dist/multipart-limits.js +12 -0
- package/dist/nats-control.d.ts +31 -3
- package/dist/nats-control.js +113 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -95,6 +95,6 @@ For low-level raw transfer configs, use `createRawTransfer`; lifecycle transport
|
|
|
95
95
|
|
|
96
96
|
## Route Streaming And Payload Size
|
|
97
97
|
|
|
98
|
-
Provider transfers
|
|
98
|
+
Provider transfers materialize the compact Runtime plan lazily, sign up to 64 routes concurrently by default, and stream a 256-route bootstrap batch followed by 2,048-route steady batches. Set `routeSigningConcurrency` on `BeamClientOptions` to tune the signing bound. Completed batches are sent FIFO while signing continues. Shared route fields use compact dictionaries, and encoded MessagePack requests split under `maxPayloadBytes`, which defaults to 24 MiB.
|
|
99
99
|
|
|
100
100
|
NATS requires this guard because the broker rejects messages above its configured `max_payload`. This limit applies only to lifecycle/control metadata; transfer file bytes do not flow through NATS.
|
package/dist/client.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export declare class BeamClient {
|
|
|
11
11
|
readonly natsUrl: string;
|
|
12
12
|
private readonly fetchImpl;
|
|
13
13
|
private readonly control;
|
|
14
|
+
private readonly routeSigningConcurrency;
|
|
14
15
|
private readonly recoverySigners;
|
|
15
16
|
constructor(options?: BeamClientOptions);
|
|
16
17
|
close(): Promise<void>;
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
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";
|
|
2
|
+
import { BeamTransferControl, BEAM_DEFAULT_NATS_URL, compactSignedRoutes } from "./nats-control.js";
|
|
3
|
+
import { directMultipartInitialPartNumber } from "./multipart-limits.js";
|
|
3
4
|
export { BEAM_DEFAULT_NATS_URL };
|
|
4
5
|
export class BeamApiError extends Error {
|
|
5
6
|
status;
|
|
@@ -16,6 +17,7 @@ export class BeamClient {
|
|
|
16
17
|
natsUrl;
|
|
17
18
|
fetchImpl;
|
|
18
19
|
control;
|
|
20
|
+
routeSigningConcurrency;
|
|
19
21
|
recoverySigners = new Map();
|
|
20
22
|
constructor(options = {}) {
|
|
21
23
|
if (!options.apiKey?.trim()) {
|
|
@@ -23,6 +25,7 @@ export class BeamClient {
|
|
|
23
25
|
}
|
|
24
26
|
this.apiKey = options.apiKey;
|
|
25
27
|
this.natsUrl = options.natsUrl ?? options.natsWsUrl ?? BEAM_DEFAULT_NATS_URL;
|
|
28
|
+
this.routeSigningConcurrency = positiveInteger(options.routeSigningConcurrency ?? 64, "routeSigningConcurrency");
|
|
26
29
|
this.control = new BeamTransferControl({
|
|
27
30
|
apiKey: this.apiKey,
|
|
28
31
|
natsUrl: options.natsUrl,
|
|
@@ -156,12 +159,13 @@ export class BeamClient {
|
|
|
156
159
|
const signedUrlFlow = prepared.signed_url_flow ?? requestedSignedUrlFlow;
|
|
157
160
|
const routeStream = new RouteStreamSender(this.control, {
|
|
158
161
|
transferId: prepared.transfer_id,
|
|
159
|
-
totalRoutes:
|
|
160
|
-
totalChunks: prepared.
|
|
162
|
+
totalRoutes: prepared.plan_descriptor.delivery_route_count,
|
|
163
|
+
totalChunks: prepared.plan_descriptor.logical_chunk_count,
|
|
161
164
|
signedUrlFlow,
|
|
162
165
|
autoDistribute: input.distribute !== false
|
|
163
166
|
});
|
|
164
167
|
const multipartUploads = new Map();
|
|
168
|
+
const multipartUploadPromises = new Map();
|
|
165
169
|
await this.startProviderRouteRecoverySigner({
|
|
166
170
|
prepared,
|
|
167
171
|
sourcesById,
|
|
@@ -174,71 +178,83 @@ export class BeamClient {
|
|
|
174
178
|
try {
|
|
175
179
|
await routeStream.begin();
|
|
176
180
|
routeStreamStarted = true;
|
|
177
|
-
|
|
181
|
+
const pendingRoutes = [];
|
|
182
|
+
const flushOldestRoute = async () => {
|
|
183
|
+
const pending = pendingRoutes.shift();
|
|
184
|
+
if (pending)
|
|
185
|
+
routeStream.addRoute(await pending);
|
|
186
|
+
};
|
|
187
|
+
for (const chunk of materializePlanChunks(prepared.plan_descriptor)) {
|
|
178
188
|
await input.throwIfCancelled?.(prepared.transfer_id);
|
|
179
189
|
for (const target of chunk.destinations) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
190
|
+
pendingRoutes.push((async () => {
|
|
191
|
+
await input.throwIfCancelled?.(prepared.transfer_id);
|
|
192
|
+
const destination = destinationsById.get(target.destination_id);
|
|
193
|
+
if (!destination)
|
|
194
|
+
throw new Error(`BeamCore returned unknown destination_id: ${target.destination_id}`);
|
|
195
|
+
const source = sourcesById.get(chunk.source_id);
|
|
196
|
+
if (!source)
|
|
197
|
+
throw new Error(`BeamCore returned unknown source_id: ${chunk.source_id}`);
|
|
198
|
+
const metadata = target.metadata ?? {};
|
|
199
|
+
const finalObjectKey = typeof metadata.final_object_key === "string" ? metadata.final_object_key : target.object_key;
|
|
200
|
+
if (!finalObjectKey)
|
|
201
|
+
throw new Error("destination signing target is missing object_key");
|
|
202
|
+
const multipartKey = `${prepared.transfer_id}:${target.destination_id}:${chunk.source_id}:${finalObjectKey}`;
|
|
203
|
+
let uploadPromise = multipartUploadPromises.get(multipartKey);
|
|
204
|
+
if (!uploadPromise) {
|
|
205
|
+
uploadPromise = (async () => {
|
|
206
|
+
const multipartCreatedAt = nowIso();
|
|
207
|
+
const upload = isHippiusDestination(destination)
|
|
208
|
+
? {
|
|
209
|
+
destination,
|
|
210
|
+
objectKey: finalObjectKey,
|
|
211
|
+
uploadId: "",
|
|
212
|
+
completeUrl: "",
|
|
213
|
+
abortUrl: "",
|
|
214
|
+
listUrl: "",
|
|
215
|
+
multipartGroupId: multipartKey,
|
|
216
|
+
multipartCreatedAt
|
|
217
|
+
}
|
|
218
|
+
: await (async () => {
|
|
219
|
+
const uploadId = await createMultipartUpload(destination, finalObjectKey);
|
|
220
|
+
return {
|
|
221
|
+
destination,
|
|
222
|
+
objectKey: finalObjectKey,
|
|
223
|
+
uploadId,
|
|
224
|
+
completeUrl: await signCompleteMultipartUpload(destination, finalObjectKey, uploadId, expiresIn),
|
|
225
|
+
abortUrl: await signAbortMultipartUpload(destination, finalObjectKey, uploadId, expiresIn),
|
|
226
|
+
listUrl: await signListMultipartUpload(destination, finalObjectKey, uploadId, expiresIn),
|
|
227
|
+
multipartGroupId: multipartKey,
|
|
228
|
+
multipartCreatedAt
|
|
229
|
+
};
|
|
230
|
+
})();
|
|
231
|
+
multipartUploads.set(multipartKey, upload);
|
|
232
|
+
return upload;
|
|
233
|
+
})();
|
|
234
|
+
multipartUploadPromises.set(multipartKey, uploadPromise);
|
|
223
235
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}));
|
|
236
|
+
const upload = await uploadPromise;
|
|
237
|
+
return this.signProviderRoute({
|
|
238
|
+
chunk,
|
|
239
|
+
target,
|
|
240
|
+
source,
|
|
241
|
+
destination,
|
|
242
|
+
expiresIn,
|
|
243
|
+
upload,
|
|
244
|
+
transferId: prepared.transfer_id,
|
|
245
|
+
finalObjectKey,
|
|
246
|
+
partNumber: typeof metadata.part_number === "number"
|
|
247
|
+
? metadata.part_number
|
|
248
|
+
: directMultipartInitialPartNumber(chunk.chunk_index),
|
|
249
|
+
signedUrlFlow
|
|
250
|
+
});
|
|
251
|
+
})());
|
|
252
|
+
if (pendingRoutes.length >= this.routeSigningConcurrency)
|
|
253
|
+
await flushOldestRoute();
|
|
240
254
|
}
|
|
241
255
|
}
|
|
256
|
+
while (pendingRoutes.length)
|
|
257
|
+
await flushOldestRoute();
|
|
242
258
|
const attached = await routeStream.complete();
|
|
243
259
|
if (!attached.success) {
|
|
244
260
|
throw new Error(attached.error ?? attached.message ?? "route stream failed");
|
|
@@ -338,9 +354,8 @@ export class BeamClient {
|
|
|
338
354
|
async startProviderRouteRecoverySigner(input) {
|
|
339
355
|
this.stopRecoverySigner(input.prepared.transfer_id);
|
|
340
356
|
const stop = await this.control.serveRouteRecoverySigner(input.prepared.transfer_id, async (payload) => {
|
|
341
|
-
const routes =
|
|
342
|
-
|
|
343
|
-
const chunk = input.prepared.chunk_plan.find((candidate) => candidate.chunk_index === requested.chunk_index && candidate.source_id === requested.source_id);
|
|
357
|
+
const routes = await mapOrderedWithConcurrency(payload.chunks, this.routeSigningConcurrency, async (requested) => {
|
|
358
|
+
const chunk = materializePlanChunk(input.prepared.plan_descriptor, requested.source_id, requested.chunk_index);
|
|
344
359
|
if (!chunk) {
|
|
345
360
|
throw new Error(`recovery signing chunk not found: ${requested.source_id}:${requested.chunk_index}`);
|
|
346
361
|
}
|
|
@@ -371,7 +386,7 @@ export class BeamClient {
|
|
|
371
386
|
attempt_slot: requested.attempt_slot,
|
|
372
387
|
route_generation_id: requested.route_generation_id
|
|
373
388
|
};
|
|
374
|
-
|
|
389
|
+
return this.signProviderRoute({
|
|
375
390
|
chunk,
|
|
376
391
|
target: {
|
|
377
392
|
...target,
|
|
@@ -386,13 +401,13 @@ export class BeamClient {
|
|
|
386
401
|
finalObjectKey: requested.final_object_key,
|
|
387
402
|
partNumber: requested.part_number,
|
|
388
403
|
signedUrlFlow: input.signedUrlFlow
|
|
389
|
-
})
|
|
390
|
-
}
|
|
404
|
+
});
|
|
405
|
+
});
|
|
391
406
|
return {
|
|
392
407
|
transfer_id: payload.transfer_id,
|
|
393
408
|
route_generation_id: payload.route_generation_id,
|
|
394
409
|
signed_at: nowIso(),
|
|
395
|
-
|
|
410
|
+
route_batch: compactSignedRoutes(routes)
|
|
396
411
|
};
|
|
397
412
|
});
|
|
398
413
|
this.recoverySigners.set(input.prepared.transfer_id, stop);
|
|
@@ -441,9 +456,8 @@ export class BeamClient {
|
|
|
441
456
|
}
|
|
442
457
|
}
|
|
443
458
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const ROUTE_STREAM_TARGET_BATCH_ROUTES = 128;
|
|
459
|
+
const ROUTE_STREAM_BOOTSTRAP_BATCH_ROUTES = 256;
|
|
460
|
+
const ROUTE_STREAM_STEADY_BATCH_ROUTES = 2_048;
|
|
447
461
|
class RouteStreamSender {
|
|
448
462
|
control;
|
|
449
463
|
options;
|
|
@@ -451,6 +465,7 @@ class RouteStreamSender {
|
|
|
451
465
|
checksum = new RouteKeysChecksum();
|
|
452
466
|
batch = [];
|
|
453
467
|
batchIndex = 0;
|
|
468
|
+
routeCount = 0;
|
|
454
469
|
sendTail = Promise.resolve();
|
|
455
470
|
sendError = null;
|
|
456
471
|
constructor(control, options) {
|
|
@@ -469,8 +484,15 @@ class RouteStreamSender {
|
|
|
469
484
|
}), this.options.transferId);
|
|
470
485
|
}
|
|
471
486
|
addRoute(route) {
|
|
472
|
-
|
|
473
|
-
|
|
487
|
+
const normalizedRoute = route.delivery_index === undefined
|
|
488
|
+
? { ...route, delivery_index: this.routeCount }
|
|
489
|
+
: route;
|
|
490
|
+
this.routeCount += 1;
|
|
491
|
+
this.batch.push(normalizedRoute);
|
|
492
|
+
const target = this.batchIndex === 0
|
|
493
|
+
? ROUTE_STREAM_BOOTSTRAP_BATCH_ROUTES
|
|
494
|
+
: ROUTE_STREAM_STEADY_BATCH_ROUTES;
|
|
495
|
+
if (this.batch.length >= target) {
|
|
474
496
|
this.enqueueFlush();
|
|
475
497
|
}
|
|
476
498
|
}
|
|
@@ -512,7 +534,7 @@ class RouteStreamSender {
|
|
|
512
534
|
stream_id: this.streamId,
|
|
513
535
|
batch_id: `${this.streamId}:${batchIndex}`,
|
|
514
536
|
batch_index: batchIndex,
|
|
515
|
-
|
|
537
|
+
route_batch: compactSignedRoutes(chunk),
|
|
516
538
|
route_count: chunk.length,
|
|
517
539
|
route_keys_checksum: chunkChecksum.value()
|
|
518
540
|
}, this.options.transferId);
|
|
@@ -555,8 +577,72 @@ function routeKeyForSignedRoute(route) {
|
|
|
555
577
|
function countDistinctRouteChunks(routes) {
|
|
556
578
|
return new Set(routes.map((route) => `${route.source_id}:${route.chunk_index}`)).size;
|
|
557
579
|
}
|
|
558
|
-
function
|
|
559
|
-
|
|
580
|
+
function* materializePlanChunks(descriptor) {
|
|
581
|
+
for (const source of descriptor.sources) {
|
|
582
|
+
for (let sourceChunkIndex = 0; sourceChunkIndex < source.chunk_count; sourceChunkIndex += 1) {
|
|
583
|
+
yield materializePlanChunk(descriptor, source.source_id, source.global_chunk_start + sourceChunkIndex);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
async function mapOrderedWithConcurrency(values, concurrency, map) {
|
|
588
|
+
const results = new Array(values.length);
|
|
589
|
+
let nextIndex = 0;
|
|
590
|
+
const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
|
|
591
|
+
while (true) {
|
|
592
|
+
const index = nextIndex;
|
|
593
|
+
nextIndex += 1;
|
|
594
|
+
if (index >= values.length)
|
|
595
|
+
return;
|
|
596
|
+
results[index] = await map(values[index], index);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
await Promise.all(workers);
|
|
600
|
+
return results;
|
|
601
|
+
}
|
|
602
|
+
function positiveInteger(value, label) {
|
|
603
|
+
if (!Number.isInteger(value) || value < 1)
|
|
604
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
605
|
+
return value;
|
|
606
|
+
}
|
|
607
|
+
function materializePlanChunk(descriptor, sourceId, chunkIndex) {
|
|
608
|
+
const source = descriptor.sources.find((candidate) => candidate.source_id === sourceId);
|
|
609
|
+
if (!source)
|
|
610
|
+
throw new Error(`plan source not found: ${sourceId}`);
|
|
611
|
+
const sourceChunkIndex = chunkIndex - source.global_chunk_start;
|
|
612
|
+
if (sourceChunkIndex < 0 || sourceChunkIndex >= source.chunk_count) {
|
|
613
|
+
throw new Error(`plan chunk is outside source range: ${sourceId}:${chunkIndex}`);
|
|
614
|
+
}
|
|
615
|
+
const sourceOffset = sourceChunkIndex * descriptor.chunk_size;
|
|
616
|
+
const chunkSize = Math.min(descriptor.chunk_size, source.size - sourceOffset);
|
|
617
|
+
return {
|
|
618
|
+
chunk_index: chunkIndex,
|
|
619
|
+
source_id: sourceId,
|
|
620
|
+
source_chunk_index: sourceChunkIndex,
|
|
621
|
+
source_offset: sourceOffset,
|
|
622
|
+
chunk_size: chunkSize,
|
|
623
|
+
source_url: source.url,
|
|
624
|
+
destinations: descriptor.destinations.map((destination) => {
|
|
625
|
+
const finalObjectKey = destination.final_object_keys[sourceId];
|
|
626
|
+
if (!finalObjectKey)
|
|
627
|
+
throw new Error(`plan final object key not found: ${sourceId}:${destination.destination_id}`);
|
|
628
|
+
const deliveryIndex = chunkIndex * descriptor.destinations.length + destination.destination_index;
|
|
629
|
+
return {
|
|
630
|
+
destination_id: destination.destination_id,
|
|
631
|
+
delivery_index: deliveryIndex,
|
|
632
|
+
provider: destination.provider,
|
|
633
|
+
object_key: `${finalObjectKey}/${descriptor.plan_nonce}/chunk-${String(sourceChunkIndex).padStart(6, "0")}`,
|
|
634
|
+
metadata: {
|
|
635
|
+
...(destination.metadata ?? {}),
|
|
636
|
+
final_object_key: finalObjectKey,
|
|
637
|
+
part_number: directMultipartInitialPartNumber(chunkIndex),
|
|
638
|
+
logical_attempt_index: 0,
|
|
639
|
+
attempt_slot: 0,
|
|
640
|
+
route_generation_id: `initial-${chunkIndex}-${destination.destination_id}`,
|
|
641
|
+
delivery_index: deliveryIndex
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
})
|
|
645
|
+
};
|
|
560
646
|
}
|
|
561
647
|
async function sha256Bytes(value) {
|
|
562
648
|
const input = new TextEncoder().encode(value);
|
package/dist/models.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface BeamClientOptions {
|
|
|
8
8
|
transferRuntimeShardCount?: number;
|
|
9
9
|
requestTimeoutMs?: number;
|
|
10
10
|
maxPayloadBytes?: number;
|
|
11
|
+
routeSigningConcurrency?: number;
|
|
11
12
|
fetch?: typeof fetch;
|
|
12
13
|
}
|
|
13
14
|
export interface SourceConfig {
|
|
@@ -240,10 +241,36 @@ export interface ChunkSigningPlanItem {
|
|
|
240
241
|
source_url: string;
|
|
241
242
|
destinations: ChunkDestinationSigningTarget[];
|
|
242
243
|
}
|
|
244
|
+
export interface CompactTransferPlanSource extends PreparedHttpSource {
|
|
245
|
+
global_chunk_start: number;
|
|
246
|
+
chunk_count: number;
|
|
247
|
+
}
|
|
248
|
+
export interface CompactTransferPlanDestination extends PreparedDestination {
|
|
249
|
+
destination_index: number;
|
|
250
|
+
final_object_keys: Record<string, string>;
|
|
251
|
+
}
|
|
252
|
+
export interface CompactTransferPlanDescriptor {
|
|
253
|
+
version: "compact-transfer-plan/v1";
|
|
254
|
+
plan_nonce: string;
|
|
255
|
+
chunk_size: number;
|
|
256
|
+
sources: CompactTransferPlanSource[];
|
|
257
|
+
destinations: CompactTransferPlanDestination[];
|
|
258
|
+
logical_chunk_count: number;
|
|
259
|
+
delivery_route_count: number;
|
|
260
|
+
multipart_attempt_slots: number;
|
|
261
|
+
formulas: {
|
|
262
|
+
source_offset: "source_chunk_index * chunk_size";
|
|
263
|
+
delivery_index: "chunk_index * destination_count + destination_index";
|
|
264
|
+
part_number: "chunk_index * multipart_attempt_slots + attempt_slot + 1";
|
|
265
|
+
route_generation_id: "initial-{chunk_index}-{destination_id}";
|
|
266
|
+
staging_object_key: "{final_object_key}/{plan_nonce}/chunk-{source_chunk_index:6}";
|
|
267
|
+
};
|
|
268
|
+
}
|
|
243
269
|
export interface SignedChunkRoute {
|
|
244
270
|
source_id: string;
|
|
245
271
|
destination_id: string;
|
|
246
272
|
chunk_index: number;
|
|
273
|
+
delivery_index?: number;
|
|
247
274
|
source_url: string;
|
|
248
275
|
dest_url: string;
|
|
249
276
|
source_offset: number;
|
|
@@ -261,7 +288,7 @@ export interface TransferPlanResponse {
|
|
|
261
288
|
total_destinations?: number;
|
|
262
289
|
logical_chunks?: number;
|
|
263
290
|
total_chunks?: number;
|
|
264
|
-
|
|
291
|
+
plan_descriptor: CompactTransferPlanDescriptor;
|
|
265
292
|
signed_url_flow?: SignedUrlFlow;
|
|
266
293
|
error?: string;
|
|
267
294
|
message?: string;
|
|
@@ -277,7 +304,7 @@ export interface TransferPrepareResponse {
|
|
|
277
304
|
total_destinations?: number;
|
|
278
305
|
logical_chunks?: number;
|
|
279
306
|
total_chunks?: number;
|
|
280
|
-
|
|
307
|
+
plan_descriptor: CompactTransferPlanDescriptor;
|
|
281
308
|
signed_url_flow?: SignedUrlFlow;
|
|
282
309
|
error?: string;
|
|
283
310
|
message?: string;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const DIRECT_MULTIPART_ATTEMPT_SLOT_COUNT = 2;
|
|
2
|
+
export declare const DIRECT_MULTIPART_MAX_PART_NUMBER = 10000;
|
|
3
|
+
export declare const DIRECT_MULTIPART_MAX_LOGICAL_CHUNKS: number;
|
|
4
|
+
export declare function directMultipartInitialPartNumber(chunkIndex: number): number;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const DIRECT_MULTIPART_ATTEMPT_SLOT_COUNT = 2;
|
|
2
|
+
export const DIRECT_MULTIPART_MAX_PART_NUMBER = 10_000;
|
|
3
|
+
export const DIRECT_MULTIPART_MAX_LOGICAL_CHUNKS = Math.floor(DIRECT_MULTIPART_MAX_PART_NUMBER / DIRECT_MULTIPART_ATTEMPT_SLOT_COUNT);
|
|
4
|
+
export function directMultipartInitialPartNumber(chunkIndex) {
|
|
5
|
+
if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
|
|
6
|
+
throw new Error("chunk_index must be a non-negative integer");
|
|
7
|
+
}
|
|
8
|
+
if (chunkIndex >= DIRECT_MULTIPART_MAX_LOGICAL_CHUNKS) {
|
|
9
|
+
throw new Error(`chunk_index must be less than ${DIRECT_MULTIPART_MAX_LOGICAL_CHUNKS}`);
|
|
10
|
+
}
|
|
11
|
+
return chunkIndex * DIRECT_MULTIPART_ATTEMPT_SLOT_COUNT + 1;
|
|
12
|
+
}
|
package/dist/nats-control.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import type { SignedChunkRoute } from "./models.js";
|
|
2
|
+
export declare const TRANSFER_CLIENT_CONTROL_SCHEMA_VERSION = "transfer-client-control/v2";
|
|
2
3
|
export declare const BEAM_DEFAULT_NATS_URL = "tls://nats.b1m.ai:4222";
|
|
3
4
|
export declare const BEAM_DEFAULT_NATS_WS_URL = "wss://nats.b1m.ai:443";
|
|
4
5
|
export declare const BEAM_DEFAULT_MAX_PAYLOAD_BYTES: number;
|
|
@@ -33,7 +34,33 @@ export interface RouteRecoverySignReplyPayload {
|
|
|
33
34
|
transfer_id: string;
|
|
34
35
|
route_generation_id: string;
|
|
35
36
|
signed_at?: string;
|
|
36
|
-
|
|
37
|
+
route_batch: CompactSignedRouteBatch;
|
|
38
|
+
}
|
|
39
|
+
export interface CompactSignedRouteBatch {
|
|
40
|
+
source_chunks: Array<{
|
|
41
|
+
source_ref: number;
|
|
42
|
+
source_id: string;
|
|
43
|
+
chunk_index: number;
|
|
44
|
+
source_url: string;
|
|
45
|
+
source_offset: number;
|
|
46
|
+
chunk_size: number;
|
|
47
|
+
expires_at?: string;
|
|
48
|
+
headers?: Record<string, string>;
|
|
49
|
+
}>;
|
|
50
|
+
multipart_groups: Array<{
|
|
51
|
+
group_ref: number;
|
|
52
|
+
metadata: Record<string, unknown>;
|
|
53
|
+
}>;
|
|
54
|
+
routes: Array<{
|
|
55
|
+
source_ref: number;
|
|
56
|
+
destination_id: string;
|
|
57
|
+
delivery_index: number;
|
|
58
|
+
dest_url: string;
|
|
59
|
+
expires_at?: string;
|
|
60
|
+
dest_headers?: Record<string, string>;
|
|
61
|
+
multipart_group_ref?: number;
|
|
62
|
+
metadata?: Record<string, unknown>;
|
|
63
|
+
}>;
|
|
37
64
|
}
|
|
38
65
|
export interface TransferControlOptions {
|
|
39
66
|
apiKey: string;
|
|
@@ -61,7 +88,7 @@ export declare class BeamTransferControl {
|
|
|
61
88
|
serveRouteRecoverySigner(transferId: string, handler: (payload: RouteRecoverySignRequestPayload) => Promise<RouteRecoverySignReplyPayload>): Promise<() => void>;
|
|
62
89
|
close(): Promise<void>;
|
|
63
90
|
request<T>(messageType: TransferClientMessageType, payload: Record<string, unknown>, transferId?: string): Promise<T>;
|
|
64
|
-
splitRoutesForPayload(messageType: TransferClientMessageType, basePayload: Record<string, unknown>, routes:
|
|
91
|
+
splitRoutesForPayload(messageType: TransferClientMessageType, basePayload: Record<string, unknown>, routes: SignedChunkRoute[]): SignedChunkRoute[][];
|
|
65
92
|
private connection;
|
|
66
93
|
private resolveAuthToken;
|
|
67
94
|
private authSubject;
|
|
@@ -69,4 +96,5 @@ export declare class BeamTransferControl {
|
|
|
69
96
|
private buildRouteRecoveryReply;
|
|
70
97
|
private requestSubject;
|
|
71
98
|
}
|
|
99
|
+
export declare function compactSignedRoutes(routes: SignedChunkRoute[]): CompactSignedRouteBatch;
|
|
72
100
|
export declare function transferShardId(transferId: string, shardCount: number): number;
|
package/dist/nats-control.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { encode, decode } from "@msgpack/msgpack";
|
|
2
2
|
import { connect } from "nats";
|
|
3
|
-
export const TRANSFER_CLIENT_CONTROL_SCHEMA_VERSION = "transfer-client-control/
|
|
3
|
+
export const TRANSFER_CLIENT_CONTROL_SCHEMA_VERSION = "transfer-client-control/v2";
|
|
4
4
|
export const BEAM_DEFAULT_NATS_URL = "tls://nats.b1m.ai:4222";
|
|
5
5
|
export const BEAM_DEFAULT_NATS_WS_URL = "wss://nats.b1m.ai:443";
|
|
6
6
|
// NATS enforces max_payload per message. This guard splits signed-route control
|
|
@@ -134,33 +134,28 @@ export class BeamTransferControl {
|
|
|
134
134
|
return (decoded.payload ?? {});
|
|
135
135
|
}
|
|
136
136
|
splitRoutesForPayload(messageType, basePayload, routes) {
|
|
137
|
-
const
|
|
138
|
-
let current = [];
|
|
139
|
-
for (const route of routes) {
|
|
140
|
-
const candidate = [...current, route];
|
|
137
|
+
const split = (candidate) => {
|
|
141
138
|
const encoded = encode({
|
|
142
139
|
schema_version: TRANSFER_CLIENT_CONTROL_SCHEMA_VERSION,
|
|
143
140
|
environment: this.environment,
|
|
144
141
|
key_prefix: this.keyPrefix,
|
|
145
142
|
shard_id: 0,
|
|
146
143
|
message_type: messageType,
|
|
147
|
-
request_id:
|
|
144
|
+
request_id: "00000000-0000-4000-8000-000000000000",
|
|
148
145
|
auth_token: "x".repeat(512),
|
|
149
146
|
occurred_at: new Date().toISOString(),
|
|
150
147
|
producer: "sdk",
|
|
151
|
-
payload: { ...basePayload,
|
|
148
|
+
payload: { ...basePayload, route_batch: compactSignedRoutes(candidate) }
|
|
152
149
|
});
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
150
|
+
if (encoded.byteLength <= this.maxPayloadBytes)
|
|
151
|
+
return [candidate];
|
|
152
|
+
if (candidate.length === 1) {
|
|
153
|
+
throw new Error(`single signed route is ${encoded.byteLength} bytes, above maxPayloadBytes=${this.maxPayloadBytes}`);
|
|
156
154
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (current.length > 0)
|
|
162
|
-
chunks.push(current);
|
|
163
|
-
return chunks;
|
|
155
|
+
const midpoint = Math.ceil(candidate.length / 2);
|
|
156
|
+
return [...split(candidate.slice(0, midpoint)), ...split(candidate.slice(midpoint))];
|
|
157
|
+
};
|
|
158
|
+
return routes.length ? split(routes) : [];
|
|
164
159
|
}
|
|
165
160
|
async connection() {
|
|
166
161
|
if (this.nc && !this.nc.isClosed())
|
|
@@ -214,6 +209,107 @@ export class BeamTransferControl {
|
|
|
214
209
|
return `${this.subjectPrefix}.${this.environment}.sdk.${this.keyPrefix}.shard.${shardId}.${messageType.replace(/\./g, "_")}`;
|
|
215
210
|
}
|
|
216
211
|
}
|
|
212
|
+
const ROUTE_ATTEMPT_METADATA_KEYS = new Set([
|
|
213
|
+
"part_number",
|
|
214
|
+
"logical_attempt_index",
|
|
215
|
+
"attempt_slot",
|
|
216
|
+
"route_generation_id",
|
|
217
|
+
"staging_object_key",
|
|
218
|
+
"copy_url",
|
|
219
|
+
"copy_source",
|
|
220
|
+
"copy_source_range",
|
|
221
|
+
"delete_url",
|
|
222
|
+
"part_verify_url",
|
|
223
|
+
"list_page_url"
|
|
224
|
+
]);
|
|
225
|
+
const ROUTE_IDENTITY_METADATA_KEYS = [
|
|
226
|
+
"source_id",
|
|
227
|
+
"destination_id",
|
|
228
|
+
"chunk_index",
|
|
229
|
+
"route_chunk_index",
|
|
230
|
+
"delivery_index"
|
|
231
|
+
];
|
|
232
|
+
export function compactSignedRoutes(routes) {
|
|
233
|
+
const source_chunks = [];
|
|
234
|
+
const sourceRefs = new Map();
|
|
235
|
+
const multipart_groups = [];
|
|
236
|
+
const groupRefs = new Map();
|
|
237
|
+
const compactRoutes = [];
|
|
238
|
+
for (const [routeIndex, route] of routes.entries()) {
|
|
239
|
+
const sourceKey = JSON.stringify([
|
|
240
|
+
route.source_id,
|
|
241
|
+
route.chunk_index,
|
|
242
|
+
route.source_url,
|
|
243
|
+
route.source_offset,
|
|
244
|
+
route.chunk_size,
|
|
245
|
+
route.expires_at ?? null,
|
|
246
|
+
route.headers ?? null
|
|
247
|
+
]);
|
|
248
|
+
let sourceRef = sourceRefs.get(sourceKey);
|
|
249
|
+
if (sourceRef === undefined) {
|
|
250
|
+
sourceRef = source_chunks.length;
|
|
251
|
+
sourceRefs.set(sourceKey, sourceRef);
|
|
252
|
+
source_chunks.push(compact({
|
|
253
|
+
source_ref: sourceRef,
|
|
254
|
+
source_id: route.source_id,
|
|
255
|
+
chunk_index: route.chunk_index,
|
|
256
|
+
source_url: route.source_url,
|
|
257
|
+
source_offset: route.source_offset,
|
|
258
|
+
chunk_size: route.chunk_size,
|
|
259
|
+
expires_at: route.expires_at,
|
|
260
|
+
headers: route.headers
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
const metadata = { ...(route.metadata ?? {}) };
|
|
264
|
+
const deliveryIndex = route.delivery_index ?? nonNegativeInteger(metadata.delivery_index) ?? routeIndex;
|
|
265
|
+
for (const key of ROUTE_IDENTITY_METADATA_KEYS)
|
|
266
|
+
delete metadata[key];
|
|
267
|
+
const groupBaseKey = multipartGroupKey(route, metadata);
|
|
268
|
+
const groupMetadata = Object.fromEntries(Object.entries(metadata).filter(([key]) => !ROUTE_ATTEMPT_METADATA_KEYS.has(key)));
|
|
269
|
+
const groupKey = groupBaseKey ? `${groupBaseKey}:${JSON.stringify(groupMetadata)}` : null;
|
|
270
|
+
let groupRef;
|
|
271
|
+
if (groupKey) {
|
|
272
|
+
groupRef = groupRefs.get(groupKey);
|
|
273
|
+
if (groupRef === undefined) {
|
|
274
|
+
groupRef = multipart_groups.length;
|
|
275
|
+
groupRefs.set(groupKey, groupRef);
|
|
276
|
+
multipart_groups.push({ group_ref: groupRef, metadata: groupMetadata });
|
|
277
|
+
}
|
|
278
|
+
for (const key of Object.keys(metadata)) {
|
|
279
|
+
if (!ROUTE_ATTEMPT_METADATA_KEYS.has(key))
|
|
280
|
+
delete metadata[key];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
compactRoutes.push(compact({
|
|
284
|
+
source_ref: sourceRef,
|
|
285
|
+
destination_id: route.destination_id,
|
|
286
|
+
delivery_index: deliveryIndex,
|
|
287
|
+
dest_url: route.dest_url,
|
|
288
|
+
expires_at: route.expires_at,
|
|
289
|
+
dest_headers: route.dest_headers,
|
|
290
|
+
multipart_group_ref: groupRef,
|
|
291
|
+
metadata: Object.keys(metadata).length ? metadata : undefined
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
return { source_chunks, multipart_groups, routes: compactRoutes };
|
|
295
|
+
}
|
|
296
|
+
function multipartGroupKey(route, metadata) {
|
|
297
|
+
const groupId = typeof metadata.multipart_group_id === "string" ? metadata.multipart_group_id : "";
|
|
298
|
+
if (groupId)
|
|
299
|
+
return `id:${groupId}`;
|
|
300
|
+
const uploadId = typeof metadata.upload_id === "string" ? metadata.upload_id : "";
|
|
301
|
+
const finalObjectKey = typeof metadata.final_object_key === "string" ? metadata.final_object_key : "";
|
|
302
|
+
return uploadId || finalObjectKey
|
|
303
|
+
? JSON.stringify([route.source_id, route.destination_id, uploadId, finalObjectKey])
|
|
304
|
+
: null;
|
|
305
|
+
}
|
|
306
|
+
function nonNegativeInteger(value) {
|
|
307
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
308
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
309
|
+
}
|
|
310
|
+
function compact(value) {
|
|
311
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
312
|
+
}
|
|
217
313
|
export function transferShardId(transferId, shardCount) {
|
|
218
314
|
let hash = 2166136261;
|
|
219
315
|
for (let i = 0; i < transferId.length; i += 1) {
|