@beam-network/sdk 0.2.21 → 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,5 @@
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
3
  import { directMultipartInitialPartNumber } from "./multipart-limits.js";
4
4
  export { BEAM_DEFAULT_NATS_URL };
5
5
  export class BeamApiError extends Error {
@@ -17,6 +17,7 @@ export class BeamClient {
17
17
  natsUrl;
18
18
  fetchImpl;
19
19
  control;
20
+ routeSigningConcurrency;
20
21
  recoverySigners = new Map();
21
22
  constructor(options = {}) {
22
23
  if (!options.apiKey?.trim()) {
@@ -24,6 +25,7 @@ export class BeamClient {
24
25
  }
25
26
  this.apiKey = options.apiKey;
26
27
  this.natsUrl = options.natsUrl ?? options.natsWsUrl ?? BEAM_DEFAULT_NATS_URL;
28
+ this.routeSigningConcurrency = positiveInteger(options.routeSigningConcurrency ?? 64, "routeSigningConcurrency");
27
29
  this.control = new BeamTransferControl({
28
30
  apiKey: this.apiKey,
29
31
  natsUrl: options.natsUrl,
@@ -157,12 +159,13 @@ export class BeamClient {
157
159
  const signedUrlFlow = prepared.signed_url_flow ?? requestedSignedUrlFlow;
158
160
  const routeStream = new RouteStreamSender(this.control, {
159
161
  transferId: prepared.transfer_id,
160
- totalRoutes: countRoutesInChunkPlan(prepared.chunk_plan),
161
- 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,
162
164
  signedUrlFlow,
163
165
  autoDistribute: input.distribute !== false
164
166
  });
165
167
  const multipartUploads = new Map();
168
+ const multipartUploadPromises = new Map();
166
169
  await this.startProviderRouteRecoverySigner({
167
170
  prepared,
168
171
  sourcesById,
@@ -175,71 +178,83 @@ export class BeamClient {
175
178
  try {
176
179
  await routeStream.begin();
177
180
  routeStreamStarted = true;
178
- 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)) {
179
188
  await input.throwIfCancelled?.(prepared.transfer_id);
180
189
  for (const target of chunk.destinations) {
181
- const destination = destinationsById.get(target.destination_id);
182
- if (!destination) {
183
- throw new Error(`BeamCore returned unknown destination_id: ${target.destination_id}`);
184
- }
185
- const source = sourcesById.get(chunk.source_id);
186
- if (!source) {
187
- throw new Error(`BeamCore returned unknown source_id: ${chunk.source_id}`);
188
- }
189
- const metadata = target.metadata ?? {};
190
- const finalObjectKey = typeof metadata.final_object_key === "string"
191
- ? metadata.final_object_key
192
- : target.object_key;
193
- if (!finalObjectKey) {
194
- throw new Error("destination signing target is missing object_key");
195
- }
196
- const multipartKey = `${prepared.transfer_id}:${target.destination_id}:${chunk.source_id}:${finalObjectKey}`;
197
- let upload = multipartUploads.get(multipartKey);
198
- if (!upload) {
199
- const multipartCreatedAt = nowIso();
200
- if (isHippiusDestination(destination)) {
201
- upload = {
202
- destination,
203
- objectKey: finalObjectKey,
204
- uploadId: "",
205
- completeUrl: "",
206
- abortUrl: "",
207
- listUrl: "",
208
- multipartGroupId: multipartKey,
209
- multipartCreatedAt
210
- };
211
- }
212
- else {
213
- const uploadId = await createMultipartUpload(destination, finalObjectKey);
214
- upload = {
215
- destination,
216
- objectKey: finalObjectKey,
217
- uploadId,
218
- completeUrl: await signCompleteMultipartUpload(destination, finalObjectKey, uploadId, expiresIn),
219
- abortUrl: await signAbortMultipartUpload(destination, finalObjectKey, uploadId, expiresIn),
220
- listUrl: await signListMultipartUpload(destination, finalObjectKey, uploadId, expiresIn),
221
- multipartGroupId: multipartKey,
222
- multipartCreatedAt
223
- };
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);
224
235
  }
225
- multipartUploads.set(multipartKey, upload);
226
- }
227
- routeStream.addRoute(await this.signProviderRoute({
228
- chunk,
229
- target,
230
- source,
231
- destination,
232
- expiresIn,
233
- upload,
234
- transferId: prepared.transfer_id,
235
- finalObjectKey,
236
- partNumber: typeof metadata.part_number === "number"
237
- ? metadata.part_number
238
- : directMultipartInitialPartNumber(chunk.chunk_index),
239
- signedUrlFlow
240
- }));
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();
241
254
  }
242
255
  }
256
+ while (pendingRoutes.length)
257
+ await flushOldestRoute();
243
258
  const attached = await routeStream.complete();
244
259
  if (!attached.success) {
245
260
  throw new Error(attached.error ?? attached.message ?? "route stream failed");
@@ -339,9 +354,8 @@ export class BeamClient {
339
354
  async startProviderRouteRecoverySigner(input) {
340
355
  this.stopRecoverySigner(input.prepared.transfer_id);
341
356
  const stop = await this.control.serveRouteRecoverySigner(input.prepared.transfer_id, async (payload) => {
342
- const routes = [];
343
- for (const requested of payload.chunks) {
344
- 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);
345
359
  if (!chunk) {
346
360
  throw new Error(`recovery signing chunk not found: ${requested.source_id}:${requested.chunk_index}`);
347
361
  }
@@ -372,7 +386,7 @@ export class BeamClient {
372
386
  attempt_slot: requested.attempt_slot,
373
387
  route_generation_id: requested.route_generation_id
374
388
  };
375
- routes.push(await this.signProviderRoute({
389
+ return this.signProviderRoute({
376
390
  chunk,
377
391
  target: {
378
392
  ...target,
@@ -387,13 +401,13 @@ export class BeamClient {
387
401
  finalObjectKey: requested.final_object_key,
388
402
  partNumber: requested.part_number,
389
403
  signedUrlFlow: input.signedUrlFlow
390
- }));
391
- }
404
+ });
405
+ });
392
406
  return {
393
407
  transfer_id: payload.transfer_id,
394
408
  route_generation_id: payload.route_generation_id,
395
409
  signed_at: nowIso(),
396
- chunk_routes: routes
410
+ route_batch: compactSignedRoutes(routes)
397
411
  };
398
412
  });
399
413
  this.recoverySigners.set(input.prepared.transfer_id, stop);
@@ -442,9 +456,8 @@ export class BeamClient {
442
456
  }
443
457
  }
444
458
  }
445
- // Target route-stream batches at 128 signed routes. The transport still splits
446
- // earlier when the encoded NATS request would exceed maxPayloadBytes.
447
- const ROUTE_STREAM_TARGET_BATCH_ROUTES = 128;
459
+ const ROUTE_STREAM_BOOTSTRAP_BATCH_ROUTES = 256;
460
+ const ROUTE_STREAM_STEADY_BATCH_ROUTES = 2_048;
448
461
  class RouteStreamSender {
449
462
  control;
450
463
  options;
@@ -452,6 +465,7 @@ class RouteStreamSender {
452
465
  checksum = new RouteKeysChecksum();
453
466
  batch = [];
454
467
  batchIndex = 0;
468
+ routeCount = 0;
455
469
  sendTail = Promise.resolve();
456
470
  sendError = null;
457
471
  constructor(control, options) {
@@ -470,8 +484,15 @@ class RouteStreamSender {
470
484
  }), this.options.transferId);
471
485
  }
472
486
  addRoute(route) {
473
- this.batch.push(route);
474
- 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) {
475
496
  this.enqueueFlush();
476
497
  }
477
498
  }
@@ -513,7 +534,7 @@ class RouteStreamSender {
513
534
  stream_id: this.streamId,
514
535
  batch_id: `${this.streamId}:${batchIndex}`,
515
536
  batch_index: batchIndex,
516
- chunk_routes: chunk,
537
+ route_batch: compactSignedRoutes(chunk),
517
538
  route_count: chunk.length,
518
539
  route_keys_checksum: chunkChecksum.value()
519
540
  }, this.options.transferId);
@@ -556,8 +577,72 @@ function routeKeyForSignedRoute(route) {
556
577
  function countDistinctRouteChunks(routes) {
557
578
  return new Set(routes.map((route) => `${route.source_id}:${route.chunk_index}`)).size;
558
579
  }
559
- function countRoutesInChunkPlan(chunks) {
560
- 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
+ };
561
646
  }
562
647
  async function sha256Bytes(value) {
563
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;
@@ -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.21",
3
+ "version": "0.2.22",
4
4
  "description": "TypeScript SDK for BEAM transfer creation and management.",
5
5
  "type": "module",
6
6
  "license": "MIT",