@beam-network/sdk 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,3 @@
1
1
  export * from "./client.js";
2
- export * from "./errors.js";
3
2
  export * from "./models.js";
4
3
  export * from "./provider-signing.js";
package/dist/index.js CHANGED
@@ -1,4 +1,3 @@
1
1
  export * from "./client.js";
2
- export * from "./errors.js";
3
2
  export * from "./models.js";
4
3
  export * from "./provider-signing.js";
package/dist/models.d.ts CHANGED
@@ -1,27 +1,4 @@
1
1
  export type SignedUrlFlow = "signed_url_v1" | "signed_url_v2";
2
- export type CreditVerdict = "unlimited" | "ok" | "low" | "exhausted" | "unknown";
3
- /**
4
- * Credit state for the API key that made the request.
5
- *
6
- * The verdict is computed server-side and is the single source of truth: never
7
- * derive one client-side from `remaining` and `low_threshold`, or the SDK will
8
- * warn at a different point than Beam actually blocks.
9
- */
10
- export interface CreditStatus {
11
- verdict: CreditVerdict;
12
- /** Credits left in the organization pool. Null when unlimited or unknown. */
13
- remaining: number | null;
14
- /** Balance below which Beam warns. Display only. */
15
- low_threshold: number | null;
16
- billing_key_id: string | null;
17
- billing_org_id: string | null;
18
- }
19
- export interface CreditWarning {
20
- code: string;
21
- message: string;
22
- detail?: unknown;
23
- credits?: CreditStatus;
24
- }
25
2
  export interface BeamClientOptions {
26
3
  apiKey?: string;
27
4
  natsUrl?: string;
@@ -33,12 +10,6 @@ export interface BeamClientOptions {
33
10
  maxPayloadBytes?: number;
34
11
  routeSigningConcurrency?: number;
35
12
  fetch?: typeof fetch;
36
- /**
37
- * Called when Beam reports a credit problem, including once at connect time
38
- * from the auth token. Without a handler the SDK logs one line per verdict
39
- * change rather than per request.
40
- */
41
- onCreditWarning?: (warning: CreditWarning) => void;
42
13
  }
43
14
  export interface SourceConfig {
44
15
  type: string;
@@ -99,10 +70,10 @@ export interface RawTransferCreateInput {
99
70
  progressiveMode?: boolean;
100
71
  signedUrlFlow?: SignedUrlFlow;
101
72
  idempotencyKey?: string;
73
+ /** Internal recovery generation; callers normally leave this unset. */
74
+ routeGenerationId?: string;
102
75
  }
103
76
  export interface TransferCreateResponse {
104
- /** Credit state after this request. */
105
- credits?: CreditStatus;
106
77
  success: boolean;
107
78
  transfer_id: string;
108
79
  transfer_key?: string;
@@ -116,8 +87,6 @@ export interface TransferCreateResponse {
116
87
  message?: string;
117
88
  }
118
89
  export interface TransferCancelResponse {
119
- /** Credit state after this request. */
120
- credits?: CreditStatus;
121
90
  success: boolean;
122
91
  message?: string;
123
92
  }
@@ -148,8 +117,6 @@ export interface DestinationStatusInfo {
148
117
  location?: string;
149
118
  }
150
119
  export interface TransferStatusInfo {
151
- /** Credit state after this request. */
152
- credits?: CreditStatus;
153
120
  transfer_id: string;
154
121
  name?: string;
155
122
  status: string;
@@ -180,7 +147,7 @@ export interface TransferStatusInfo {
180
147
  };
181
148
  }
182
149
  export interface TransferTerminalEvent {
183
- schema_version: "transfer-client-control/v4";
150
+ schema_version: "transfer-client-control/v5";
184
151
  producer: "transfer-runtime";
185
152
  transfer_id: string;
186
153
  status: "completed" | "failed" | "cancelled";
@@ -211,23 +178,6 @@ export interface R2ProviderConfig {
211
178
  account_id?: string;
212
179
  endpoint_url?: string;
213
180
  }
214
- export interface S3CompatibleProviderCapabilities {
215
- /**
216
- * Whether the provider's ListParts / ListObjects response includes a per-part
217
- * LastModified timestamp. Defaults to true (S3-spec compliant). Providers that
218
- * omit it (e.g. Hippius) declare false so Beam finalization substitutes a
219
- * Beam-side timestamp instead of looping into multipart recovery.
220
- */
221
- multipart_part_last_modified?: boolean;
222
- /**
223
- * Per-destination override for Beam Core's assignment-recovery deadline
224
- * (TRANSFER_ASSIGNMENT_TIMEOUT_MS), in milliseconds. Omit to use the global
225
- * default (15s). Slower providers (e.g. Hippius) declare a larger value so
226
- * their assignment waves aren't reassigned prematurely. Only integers >= 1000
227
- * are honoured by Beam Core.
228
- */
229
- assignment_timeout_ms?: number;
230
- }
231
181
  export interface S3CompatibleProviderConfig {
232
182
  provider: string;
233
183
  driver?: "s3-compatible";
@@ -241,7 +191,6 @@ export interface S3CompatibleProviderConfig {
241
191
  session_token?: string;
242
192
  force_path_style?: boolean;
243
193
  account_id?: string;
244
- capabilities?: S3CompatibleProviderCapabilities;
245
194
  }
246
195
  export interface HippiusProviderConfig {
247
196
  provider: "hippius";
@@ -289,6 +238,8 @@ export interface ProviderTransferCreateInput {
289
238
  signedUrlFlow?: SignedUrlFlow;
290
239
  /** Stable caller identity used to derive the transfer and lifecycle request ids. */
291
240
  idempotencyKey?: string;
241
+ /** Internal recovery generation; callers normally omit this. */
242
+ routeGenerationId?: string;
292
243
  }
293
244
  export interface PlanningHttpSource {
294
245
  source_id: string;
@@ -385,8 +336,6 @@ export interface MultipartGroupManifest {
385
336
  urls_expires_at: string;
386
337
  }
387
338
  export interface TransferPlanResponse {
388
- /** Credit state after this request. */
389
- credits?: CreditStatus;
390
339
  success: boolean;
391
340
  chunk_size?: number;
392
341
  total_size?: number;
@@ -396,12 +345,12 @@ export interface TransferPlanResponse {
396
345
  total_chunks?: number;
397
346
  plan_descriptor: CompactTransferPlanDescriptor;
398
347
  signed_url_flow: SignedUrlFlow;
348
+ plan_fingerprint: string;
349
+ coordinate_checksum: string;
399
350
  error?: string;
400
351
  message?: string;
401
352
  }
402
353
  export interface TransferPrepareResponse {
403
- /** Credit state after this request. */
404
- credits?: CreditStatus;
405
354
  success: boolean;
406
355
  transfer_id: string;
407
356
  transfer_key?: string;
@@ -414,6 +363,9 @@ export interface TransferPrepareResponse {
414
363
  total_chunks?: number;
415
364
  plan_descriptor: CompactTransferPlanDescriptor;
416
365
  signed_url_flow: SignedUrlFlow;
366
+ plan_fingerprint: string;
367
+ coordinate_checksum: string;
368
+ route_generation_id: string;
417
369
  error?: string;
418
370
  message?: string;
419
371
  }
@@ -1,9 +1,10 @@
1
- import type { CreditStatus, CreditWarning, MultipartGroupManifest, SignedChunkRoute, TransferTerminalSignalWaiter } from "./models.js";
2
- export declare const TRANSFER_CLIENT_CONTROL_SCHEMA_VERSION = "transfer-client-control/v4";
1
+ import { type ConnectionOptions } from "nats";
2
+ import type { MultipartGroupManifest, SignedChunkRoute, TransferTerminalSignalWaiter } from "./models.js";
3
+ export declare const TRANSFER_CLIENT_CONTROL_SCHEMA_VERSION = "transfer-client-control/v5";
3
4
  export declare const BEAM_DEFAULT_NATS_URL = "tls://nats.b1m.ai:4222";
4
5
  export declare const BEAM_DEFAULT_NATS_WS_URL = "wss://nats.b1m.ai:443";
5
6
  export declare const BEAM_DEFAULT_MAX_PAYLOAD_BYTES: number;
6
- export type TransferClientMessageType = "transfer.plan" | "transfer.create" | "transfer.prepare" | "transfer.route_stream.begin" | "transfer.route_stream.manifest" | "transfer.route_stream.batch" | "transfer.route_stream.complete" | "transfer.distribute" | "transfer.status" | "transfer.cancel";
7
+ export type TransferClientMessageType = "runtime.hello" | "transfer.plan" | "transfer.create" | "transfer.prepare" | "transfer.route_stream.begin" | "transfer.route_stream.manifest" | "transfer.route_stream.batch" | "transfer.route_stream.complete" | "transfer.distribute" | "transfer.status" | "transfer.cancel" | "transfer.resume";
7
8
  export interface CompactSignedRouteBatch {
8
9
  source_chunks: Array<{
9
10
  source_ref: number;
@@ -28,6 +29,7 @@ export interface CompactSignedRouteBatch {
28
29
  }
29
30
  export interface RouteStreamBeginPayload {
30
31
  transfer_id: string;
32
+ route_generation_id: string;
31
33
  stream_id: string;
32
34
  total_routes: number;
33
35
  total_chunks: number;
@@ -38,10 +40,16 @@ export interface RouteStreamBeginPayload {
38
40
  }
39
41
  export interface RouteStreamManifestPayload {
40
42
  transfer_id: string;
43
+ route_generation_id: string;
41
44
  stream_id: string;
42
45
  manifest_batch_id: string;
43
46
  groups: MultipartGroupManifest[];
44
47
  }
48
+ export declare function buildConnectionOptions(options: {
49
+ natsUrl: string;
50
+ apiKey: string;
51
+ keyPrefix: string;
52
+ }): ConnectionOptions;
45
53
  export interface TransferControlOptions {
46
54
  apiKey: string;
47
55
  natsUrl?: string;
@@ -51,11 +59,18 @@ export interface TransferControlOptions {
51
59
  transferRuntimeShardCount?: number;
52
60
  requestTimeoutMs?: number;
53
61
  maxPayloadBytes?: number;
54
- onCreditWarning?: (warning: CreditWarning) => void;
55
62
  }
56
63
  export interface LifecycleRequestOptions {
57
64
  transferId?: string;
58
65
  idempotencyKey?: string;
66
+ shardId?: number;
67
+ }
68
+ export interface TransferRecoveryLease {
69
+ transferId: string;
70
+ planFingerprint: string;
71
+ coordinateChecksum: string;
72
+ replayRoutes(routeGenerationId: string): Promise<void>;
73
+ disposeSecrets?(): void;
59
74
  }
60
75
  export declare class BeamTransferControl {
61
76
  readonly environment: string;
@@ -73,24 +88,24 @@ export declare class BeamTransferControl {
73
88
  private authTokenExpiresAt;
74
89
  private authResolvePromise;
75
90
  private readonly terminalWaitCancellations;
76
- private lastCredits;
77
- private reportedVerdict;
78
- private readonly onCreditWarning;
79
- /** Last credit state Beam reported, for display without issuing a request. */
80
- get creditStatus(): CreditStatus | null;
81
- /**
82
- * Records the credit state from a reply or auth token and surfaces a warning.
83
- *
84
- * Fires on verdict transitions, not per request, so a long-running client does
85
- * not emit the same warning on every call.
86
- */
87
- private observeCredits;
91
+ private readonly recoveryLeases;
92
+ private readonly recoveryOperations;
93
+ private readonly recoveryRequestedEpoch;
94
+ private readonly runtimeEpochs;
95
+ private helloTimer;
88
96
  constructor(options: TransferControlOptions);
89
97
  close(): Promise<void>;
98
+ registerRecoveryLease(lease: TransferRecoveryLease): () => void;
99
+ releaseRecoveryLease(transferId: string): void;
100
+ continueRecoveryLease(transferId: string): void;
90
101
  request<T>(messageType: TransferClientMessageType, payload: Record<string, unknown>, options?: LifecycleRequestOptions): Promise<T>;
91
102
  openTerminalSignalWaiter(transferId: string): Promise<TransferTerminalSignalWaiter>;
92
103
  splitRoutesForPayload(messageType: TransferClientMessageType, basePayload: Record<string, unknown>, routes: SignedChunkRoute[]): SignedChunkRoute[][];
93
104
  private connection;
105
+ private ensureHelloMonitor;
106
+ private pollRuntimeHello;
107
+ private observeRuntimeEpoch;
108
+ private recoverLease;
94
109
  private resolveAuthToken;
95
110
  private resolveAuthTokenFresh;
96
111
  private authSubject;
@@ -99,3 +114,5 @@ export declare class BeamTransferControl {
99
114
  }
100
115
  export declare function compactSignedRoutes(routes: SignedChunkRoute[]): CompactSignedRouteBatch;
101
116
  export declare function transferShardId(transferId: string, shardCount: number): number;
117
+ export declare function isRetryableLifecycleError(error: unknown): boolean;
118
+ export declare function isRecoverableRouteStreamError(error: unknown): boolean;
@@ -1,18 +1,22 @@
1
1
  import { encode, decode } from "@msgpack/msgpack";
2
2
  import { connect } from "nats";
3
- import { BeamApiError, BeamInsufficientCreditsError } from "./errors.js";
4
- export const TRANSFER_CLIENT_CONTROL_SCHEMA_VERSION = "transfer-client-control/v4";
3
+ export const TRANSFER_CLIENT_CONTROL_SCHEMA_VERSION = "transfer-client-control/v5";
5
4
  export const BEAM_DEFAULT_NATS_URL = "tls://nats.b1m.ai:4222";
6
5
  export const BEAM_DEFAULT_NATS_WS_URL = "wss://nats.b1m.ai:443";
7
6
  // NATS enforces max_payload per message. This guard splits signed-route control
8
7
  // messages before the broker rejects them; transfer bytes never flow through NATS.
9
8
  export const BEAM_DEFAULT_MAX_PAYLOAD_BYTES = 24 * 1024 * 1024;
10
- function buildConnectionOptions(options) {
9
+ export function buildConnectionOptions(options) {
11
10
  const connectionOptions = {
12
11
  servers: options.natsUrl,
13
12
  user: options.keyPrefix,
14
13
  pass: options.apiKey,
15
- name: `beam-sdk-${options.keyPrefix}`
14
+ name: `beam-sdk-${options.keyPrefix}`,
15
+ reconnect: true,
16
+ maxReconnectAttempts: -1,
17
+ reconnectTimeWait: 1_000,
18
+ reconnectJitter: 500,
19
+ reconnectJitterTLS: 1_000
16
20
  };
17
21
  if (options.natsUrl.startsWith("tls://")) {
18
22
  const tlsOptions = { handshakeFirst: true };
@@ -26,6 +30,14 @@ function buildConnectionOptions(options) {
26
30
  }
27
31
  return connectionOptions;
28
32
  }
33
+ class LifecycleRequestError extends Error {
34
+ status;
35
+ constructor(status, message) {
36
+ super(message);
37
+ this.status = status;
38
+ this.name = "LifecycleRequestError";
39
+ }
40
+ }
29
41
  export class BeamTransferControl {
30
42
  environment;
31
43
  keyPrefix;
@@ -42,42 +54,11 @@ export class BeamTransferControl {
42
54
  authTokenExpiresAt = 0;
43
55
  authResolvePromise = null;
44
56
  terminalWaitCancellations = new Set();
45
- lastCredits = null;
46
- reportedVerdict = null;
47
- onCreditWarning;
48
- /** Last credit state Beam reported, for display without issuing a request. */
49
- get creditStatus() {
50
- return this.lastCredits;
51
- }
52
- /**
53
- * Records the credit state from a reply or auth token and surfaces a warning.
54
- *
55
- * Fires on verdict transitions, not per request, so a long-running client does
56
- * not emit the same warning on every call.
57
- */
58
- observeCredits(credits, warnings) {
59
- if (!credits)
60
- return this.lastCredits;
61
- this.lastCredits = credits;
62
- if (credits.verdict === this.reportedVerdict)
63
- return credits;
64
- this.reportedVerdict = credits.verdict;
65
- if (credits.verdict !== "low" && credits.verdict !== "exhausted")
66
- return credits;
67
- const warning = warnings?.[0] ?? {
68
- code: credits.verdict === "exhausted" ? "insufficient_credits" : "credits_low",
69
- message: credits.remaining === null
70
- ? "Beam credit balance is low."
71
- : `Beam credit balance is low: ${credits.remaining} credits remaining.`,
72
- credits
73
- };
74
- if (this.onCreditWarning) {
75
- this.onCreditWarning({ ...warning, credits });
76
- return credits;
77
- }
78
- console.warn(`[beam] ${warning.message}`);
79
- return credits;
80
- }
57
+ recoveryLeases = new Map();
58
+ recoveryOperations = new Map();
59
+ recoveryRequestedEpoch = new Map();
60
+ runtimeEpochs = new Map();
61
+ helloTimer = null;
81
62
  constructor(options) {
82
63
  this.apiKey = options.apiKey;
83
64
  this.keyPrefix = options.apiKey.slice(0, 12);
@@ -87,7 +68,6 @@ export class BeamTransferControl {
87
68
  this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
88
69
  this.maxPayloadBytes = options.maxPayloadBytes ?? BEAM_DEFAULT_MAX_PAYLOAD_BYTES;
89
70
  this.natsUrl = options.natsUrl ?? options.natsWsUrl ?? BEAM_DEFAULT_NATS_URL;
90
- this.onCreditWarning = options.onCreditWarning;
91
71
  if (/^https?:\/\//i.test(this.natsUrl) || /^wss?:\/\//i.test(this.natsUrl) && !options.natsWsUrl) {
92
72
  throw new Error("Beam SDK lifecycle endpoint must be nats://, tls://, or configured as natsWsUrl.");
93
73
  }
@@ -99,6 +79,13 @@ export class BeamTransferControl {
99
79
  this.closed = true;
100
80
  for (const cancel of [...this.terminalWaitCancellations])
101
81
  cancel();
82
+ if (this.helloTimer)
83
+ clearInterval(this.helloTimer);
84
+ this.helloTimer = null;
85
+ for (const lease of this.recoveryLeases.values())
86
+ lease.disposeSecrets?.();
87
+ this.recoveryLeases.clear();
88
+ this.recoveryRequestedEpoch.clear();
102
89
  if (this.connectionPromise) {
103
90
  try {
104
91
  await this.connectionPromise;
@@ -112,9 +99,53 @@ export class BeamTransferControl {
112
99
  if (nc && !nc.isClosed())
113
100
  await nc.drain();
114
101
  }
102
+ registerRecoveryLease(lease) {
103
+ if (this.closed)
104
+ throw new Error("NATS lifecycle control is closed");
105
+ const previous = this.recoveryLeases.get(lease.transferId);
106
+ if (previous && previous !== lease)
107
+ previous.disposeSecrets?.();
108
+ this.recoveryLeases.set(lease.transferId, lease);
109
+ this.ensureHelloMonitor();
110
+ const shardId = transferShardId(lease.transferId, this.shardCount);
111
+ void this.pollRuntimeHello(shardId);
112
+ return () => {
113
+ const current = this.recoveryLeases.get(lease.transferId);
114
+ if (current !== lease)
115
+ return;
116
+ this.recoveryLeases.delete(lease.transferId);
117
+ this.recoveryRequestedEpoch.delete(lease.transferId);
118
+ lease.disposeSecrets?.();
119
+ if (!this.recoveryLeases.size && this.helloTimer) {
120
+ clearInterval(this.helloTimer);
121
+ this.helloTimer = null;
122
+ }
123
+ };
124
+ }
125
+ releaseRecoveryLease(transferId) {
126
+ const lease = this.recoveryLeases.get(transferId);
127
+ if (!lease)
128
+ return;
129
+ this.recoveryLeases.delete(transferId);
130
+ this.recoveryRequestedEpoch.delete(transferId);
131
+ lease.disposeSecrets?.();
132
+ if (!this.recoveryLeases.size && this.helloTimer) {
133
+ clearInterval(this.helloTimer);
134
+ this.helloTimer = null;
135
+ }
136
+ }
137
+ continueRecoveryLease(transferId) {
138
+ const lease = this.recoveryLeases.get(transferId);
139
+ if (!lease || this.closed)
140
+ return;
141
+ const shardId = transferShardId(transferId, this.shardCount);
142
+ const epoch = this.runtimeEpochs.get(shardId);
143
+ this.recoveryRequestedEpoch.set(transferId, epoch ? `${epoch.runtimeEpoch}:${epoch.transportEpoch}` : `foreground:${randomId()}`);
144
+ void this.recoverLease(lease);
145
+ }
115
146
  async request(messageType, payload, options = {}) {
116
147
  const authToken = await this.resolveAuthToken();
117
- const shardId = options.transferId ? transferShardId(options.transferId, this.shardCount) : 0;
148
+ const shardId = options.shardId ?? (options.transferId ? transferShardId(options.transferId, this.shardCount) : 0);
118
149
  const requestId = options.idempotencyKey
119
150
  ? await stableRequestId(`${messageType}:${options.idempotencyKey}`)
120
151
  : randomId();
@@ -142,19 +173,11 @@ export class BeamTransferControl {
142
173
  const nc = await this.connection();
143
174
  const response = await nc.request(subject, bytes, { timeout: this.requestTimeoutMs });
144
175
  const decoded = decode(response.data);
145
- const credits = this.observeCredits(decoded.credits, decoded.warnings);
146
- if (decoded.ok) {
147
- const payload = (decoded.payload ?? {});
148
- // Credits ride on the envelope, not the payload, so they reach every
149
- // response type without a per-message-type model change.
150
- return (credits ? { ...payload, credits } : payload);
151
- }
176
+ this.observeRuntimeEpoch(shardId, decoded.runtime_epoch, decoded.transport_epoch);
177
+ if (decoded.ok)
178
+ return (decoded.payload ?? {});
152
179
  const body = JSON.stringify(decoded.error ?? {});
153
- const code = decoded.error?.code ?? "lifecycle_error";
154
- const message = decoded.error?.message ?? body;
155
- const error = decoded.status === 402
156
- ? new BeamInsufficientCreditsError(message, code, credits, body)
157
- : new BeamApiError(`Beam lifecycle request failed with ${decoded.status}: ${body}`, decoded.status, body);
180
+ const error = new LifecycleRequestError(decoded.status, `Beam lifecycle request failed with ${decoded.status}: ${body}`);
158
181
  if (!isRetryableLifecycleStatus(decoded.status) || attempt + 1 >= LIFECYCLE_REQUEST_MAX_ATTEMPTS) {
159
182
  throw error;
160
183
  }
@@ -329,6 +352,96 @@ export class BeamTransferControl {
329
352
  this.connectionPromise = null;
330
353
  }
331
354
  }
355
+ ensureHelloMonitor() {
356
+ if (this.helloTimer || this.closed)
357
+ return;
358
+ this.helloTimer = setInterval(() => {
359
+ const shards = new Set([...this.recoveryLeases.keys()].map((transferId) => transferShardId(transferId, this.shardCount)));
360
+ for (const shardId of shards)
361
+ void this.pollRuntimeHello(shardId);
362
+ }, 5_000);
363
+ this.helloTimer.unref?.();
364
+ }
365
+ async pollRuntimeHello(shardId) {
366
+ try {
367
+ await this.request("runtime.hello", {}, { idempotencyKey: `runtime:hello:${shardId}`, shardId });
368
+ }
369
+ catch {
370
+ // Infinite NATS reconnect continues in the background; the next hello reconciles epochs.
371
+ }
372
+ }
373
+ observeRuntimeEpoch(shardId, runtimeEpoch, transportEpoch) {
374
+ if (!runtimeEpoch || !transportEpoch)
375
+ return;
376
+ const previous = this.runtimeEpochs.get(shardId);
377
+ this.runtimeEpochs.set(shardId, { runtimeEpoch, transportEpoch });
378
+ if (!previous)
379
+ return;
380
+ if (previous.runtimeEpoch === runtimeEpoch && previous.transportEpoch === transportEpoch)
381
+ return;
382
+ if (previous.runtimeEpoch !== runtimeEpoch) {
383
+ this.authToken = null;
384
+ this.authTokenExpiresAt = 0;
385
+ }
386
+ for (const lease of this.recoveryLeases.values()) {
387
+ if (transferShardId(lease.transferId, this.shardCount) === shardId) {
388
+ this.recoveryRequestedEpoch.set(lease.transferId, `${runtimeEpoch}:${transportEpoch}`);
389
+ void this.recoverLease(lease);
390
+ }
391
+ }
392
+ }
393
+ recoverLease(lease) {
394
+ const existing = this.recoveryOperations.get(lease.transferId);
395
+ if (existing)
396
+ return existing;
397
+ let operation;
398
+ operation = (async () => {
399
+ let attempt = 0;
400
+ while (!this.closed && this.recoveryLeases.get(lease.transferId) === lease) {
401
+ const requestedEpoch = this.recoveryRequestedEpoch.get(lease.transferId);
402
+ const routeGenerationId = randomId();
403
+ try {
404
+ const response = await this.request("transfer.resume", {
405
+ transfer_id: lease.transferId,
406
+ plan_fingerprint: lease.planFingerprint,
407
+ coordinate_checksum: lease.coordinateChecksum,
408
+ route_generation_id: routeGenerationId
409
+ }, { transferId: lease.transferId, idempotencyKey: `transfer:${lease.transferId}:resume:${routeGenerationId}` });
410
+ if (response.recovery === "terminal") {
411
+ this.releaseRecoveryLease(lease.transferId);
412
+ if (this.recoveryOperations.get(lease.transferId) === operation) {
413
+ this.recoveryOperations.delete(lease.transferId);
414
+ }
415
+ return;
416
+ }
417
+ if (response.route_replay_required || response.recovery === "route_replay_required") {
418
+ await lease.replayRoutes(routeGenerationId);
419
+ }
420
+ if (this.recoveryRequestedEpoch.get(lease.transferId) !== requestedEpoch) {
421
+ attempt = 0;
422
+ continue;
423
+ }
424
+ if (this.recoveryOperations.get(lease.transferId) === operation) {
425
+ this.recoveryOperations.delete(lease.transferId);
426
+ }
427
+ return;
428
+ }
429
+ catch (error) {
430
+ if (!isRetryableLifecycleError(error)) {
431
+ this.releaseRecoveryLease(lease.transferId);
432
+ return;
433
+ }
434
+ attempt += 1;
435
+ await sleepWithJitter(Math.min(30_000, 500 * (2 ** Math.min(attempt, 6))));
436
+ }
437
+ }
438
+ })().finally(() => {
439
+ if (this.recoveryOperations.get(lease.transferId) === operation)
440
+ this.recoveryOperations.delete(lease.transferId);
441
+ });
442
+ this.recoveryOperations.set(lease.transferId, operation);
443
+ return operation;
444
+ }
332
445
  async resolveAuthToken() {
333
446
  const now = Math.floor(Date.now() / 1000);
334
447
  if (this.authToken && this.authTokenExpiresAt - 5 > now)
@@ -358,9 +471,6 @@ export class BeamTransferControl {
358
471
  const claims = decodeJwtPayload(parsed.token);
359
472
  this.authToken = parsed.token;
360
473
  this.authTokenExpiresAt = claims.exp;
361
- // Pre-flight: the token carries the verdict, so a low balance surfaces
362
- // before the first lifecycle request is ever sent.
363
- this.observeCredits(claims.credits);
364
474
  return parsed.token;
365
475
  }
366
476
  catch (error) {
@@ -506,14 +616,22 @@ function randomId() {
506
616
  const cryptoApi = globalThis.crypto;
507
617
  if (cryptoApi?.randomUUID)
508
618
  return cryptoApi.randomUUID();
509
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
619
+ if (!cryptoApi?.getRandomValues)
620
+ throw new Error("secure UUID generation is unavailable");
621
+ const bytes = cryptoApi.getRandomValues(new Uint8Array(16));
622
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
623
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
624
+ const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
625
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
510
626
  }
511
627
  const LIFECYCLE_REQUEST_MAX_ATTEMPTS = 3;
512
628
  const LIFECYCLE_REQUEST_RETRY_DELAYS_MS = [150, 500];
513
629
  function isRetryableLifecycleStatus(status) {
514
630
  return status === 408 || status === 425 || status === 429 || status >= 500;
515
631
  }
516
- function isRetryableLifecycleError(error) {
632
+ export function isRetryableLifecycleError(error) {
633
+ if (error instanceof LifecycleRequestError)
634
+ return isRetryableLifecycleStatus(error.status);
517
635
  const record = error && typeof error === "object" ? error : null;
518
636
  const code = typeof record?.code === "string" ? record.code : "";
519
637
  const message = error instanceof Error ? error.message : String(error);
@@ -526,10 +644,19 @@ function isRetryableLifecycleError(error) {
526
644
  "econnreset",
527
645
  "econnrefused",
528
646
  "etimedout",
647
+ "fetch failed",
529
648
  "socket",
530
649
  "network"
531
650
  ].some((token) => combined.includes(token));
532
651
  }
652
+ export function isRecoverableRouteStreamError(error) {
653
+ if (isRetryableLifecycleError(error))
654
+ return true;
655
+ if (error instanceof LifecycleRequestError)
656
+ return error.status === 404 || error.status === 409;
657
+ const status = error && typeof error === "object" ? Number(error.status) : NaN;
658
+ return status === 404 || status === 409;
659
+ }
533
660
  async function sleepWithJitter(baseMs) {
534
661
  const jitter = Math.floor(baseMs * (Math.random() * 0.4 - 0.2));
535
662
  await new Promise((resolve) => setTimeout(resolve, Math.max(1, baseMs + jitter)));
@@ -405,8 +405,7 @@ function s3CompatibleMetadata(source, endpoint = s3CompatibleEndpoint(source)) {
405
405
  key: source.key,
406
406
  region: s3CompatibleRegion(source),
407
407
  endpoint_url: endpoint,
408
- account_id: "account_id" in source ? source.account_id : undefined,
409
- capabilities: "capabilities" in source ? source.capabilities : undefined
408
+ account_id: "account_id" in source ? source.account_id : undefined
410
409
  });
411
410
  }
412
411
  function isHippiusProvider(config) {