@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 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 stream signed routes over NATS in target batches of 128 routes. Signing continues while completed batches are sent in order. The SDK still splits a batch earlier when the encoded MessagePack request would exceed `maxPayloadBytes`, which defaults to 24 MiB.
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: countRoutesInChunkPlan(prepared.chunk_plan),
160
- totalChunks: prepared.logical_chunks ?? prepared.total_chunks ?? prepared.chunk_plan.length,
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
- for (const chunk of prepared.chunk_plan) {
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
- const destination = destinationsById.get(target.destination_id);
181
- if (!destination) {
182
- throw new Error(`BeamCore returned unknown destination_id: ${target.destination_id}`);
183
- }
184
- const source = sourcesById.get(chunk.source_id);
185
- if (!source) {
186
- throw new Error(`BeamCore returned unknown source_id: ${chunk.source_id}`);
187
- }
188
- const metadata = target.metadata ?? {};
189
- const finalObjectKey = typeof metadata.final_object_key === "string"
190
- ? metadata.final_object_key
191
- : target.object_key;
192
- if (!finalObjectKey) {
193
- throw new Error("destination signing target is missing object_key");
194
- }
195
- const multipartKey = `${prepared.transfer_id}:${target.destination_id}:${chunk.source_id}:${finalObjectKey}`;
196
- let upload = multipartUploads.get(multipartKey);
197
- if (!upload) {
198
- const multipartCreatedAt = nowIso();
199
- if (isHippiusDestination(destination)) {
200
- upload = {
201
- destination,
202
- objectKey: finalObjectKey,
203
- uploadId: "",
204
- completeUrl: "",
205
- abortUrl: "",
206
- listUrl: "",
207
- multipartGroupId: multipartKey,
208
- multipartCreatedAt
209
- };
210
- }
211
- else {
212
- const uploadId = await createMultipartUpload(destination, finalObjectKey);
213
- upload = {
214
- destination,
215
- objectKey: finalObjectKey,
216
- uploadId,
217
- completeUrl: await signCompleteMultipartUpload(destination, finalObjectKey, uploadId, expiresIn),
218
- abortUrl: await signAbortMultipartUpload(destination, finalObjectKey, uploadId, expiresIn),
219
- listUrl: await signListMultipartUpload(destination, finalObjectKey, uploadId, expiresIn),
220
- multipartGroupId: multipartKey,
221
- multipartCreatedAt
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
- multipartUploads.set(multipartKey, upload);
225
- }
226
- routeStream.addRoute(await this.signProviderRoute({
227
- chunk,
228
- target,
229
- source,
230
- destination,
231
- expiresIn,
232
- upload,
233
- transferId: prepared.transfer_id,
234
- finalObjectKey,
235
- partNumber: typeof metadata.part_number === "number"
236
- ? metadata.part_number
237
- : chunk.chunk_index * 3 + 1,
238
- signedUrlFlow
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
- for (const requested of payload.chunks) {
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
- routes.push(await this.signProviderRoute({
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
- chunk_routes: routes
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
- // Target route-stream batches at 128 signed routes. The transport still splits
445
- // earlier when the encoded NATS request would exceed maxPayloadBytes.
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
- this.batch.push(route);
473
- if (this.batch.length >= ROUTE_STREAM_TARGET_BATCH_ROUTES) {
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
- chunk_routes: chunk,
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 countRoutesInChunkPlan(chunks) {
559
- return chunks.reduce((sum, chunk) => sum + chunk.destinations.length, 0);
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
- chunk_plan: ChunkSigningPlanItem[];
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
- chunk_plan: ChunkSigningPlanItem[];
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
+ }
@@ -1,4 +1,5 @@
1
- export declare const TRANSFER_CLIENT_CONTROL_SCHEMA_VERSION = "transfer-client-control/v1";
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
- chunk_routes: unknown[];
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: unknown[]): unknown[][];
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;
@@ -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/v1";
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 chunks = [];
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: randomId(),
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, chunk_routes: candidate }
148
+ payload: { ...basePayload, route_batch: compactSignedRoutes(candidate) }
152
149
  });
153
- if (current.length > 0 && encoded.byteLength > this.maxPayloadBytes) {
154
- chunks.push(current);
155
- current = [route];
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
- else {
158
- current = candidate;
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beam-network/sdk",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "description": "TypeScript SDK for BEAM transfer creation and management.",
5
5
  "type": "module",
6
6
  "license": "MIT",