@aerol-ai/aerolvm-sdk 0.3.1 → 0.4.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/MicroVM.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { type APIVersion } from "./internal/client.js";
1
+ import { type APIVersion, type RetryConfig } from "./internal/client.js";
2
2
  import { Sandbox } from "./Sandbox.js";
3
3
  import { Image } from "./Image.js";
4
- import type { BuildImageOptions, BuildImageResult, CreateOptions, CreateSessionOptions, ExecStreamHandle, ExecStreamOptions, HealthStatus, Lifecycle, ListOptions, MountSpecRedacted, RegisterSnapshotOptions, ResizeOptions, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions } from "./types.js";
4
+ import type { BuildImageOptions, BuildImageResult, CreateOptions, CreateSessionOptions, CreateTemplateOptions, ExecStreamHandle, ExecStreamOptions, HealthStatus, IngressTarget, Lifecycle, ListOptions, MountSpecRedacted, RegisterSnapshotOptions, ResizeOptions, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template } from "./types.js";
5
5
  type FetchLike = typeof fetch;
6
6
  export interface MicroVMConfig {
7
7
  patToken?: string;
@@ -14,6 +14,13 @@ export interface MicroVMConfig {
14
14
  * other.
15
15
  */
16
16
  apiVersion?: APIVersion;
17
+ /**
18
+ * Retry policy for transient transport errors (socket closed, connection
19
+ * reset) and retryable HTTP status codes (429, 502, 503, 504). The SDK
20
+ * retries up to 3 times with exponential backoff by default. Pass
21
+ * `{ maxRetries: 0 }` to disable.
22
+ */
23
+ retry?: RetryConfig;
17
24
  }
18
25
  export declare class MicroVM {
19
26
  readonly apiUrl: string;
@@ -51,6 +58,42 @@ export declare class MicroVM {
51
58
  sessionLog(sandboxID: string, sessionID: string): Promise<Uint8Array>;
52
59
  sessionRecording(sandboxID: string, sessionID: string): Promise<Uint8Array>;
53
60
  attachSession(sandboxID: string, sessionID: string, options?: SessionAttachOptions): SessionAttachHandle;
61
+ /**
62
+ * Register a Firecracker rootfs template. Returns immediately with a
63
+ * `status: "pending"` row; poll {@link MicroVM.getTemplate} until the
64
+ * status reaches `"ready"` (fast-boot available) or `"ready_no_snapshot"`
65
+ * (cold boot only — see {@link Template.snapshotError}).
66
+ *
67
+ * Idempotent when {@link CreateTemplateOptions.id} is supplied: a
68
+ * duplicate ID returns 409 so a retried CI step does not create two
69
+ * rows for the same logical template.
70
+ */
71
+ createTemplate(options: CreateTemplateOptions): Promise<Template>;
72
+ listTemplates(): Promise<Template[]>;
73
+ getTemplate(id: string): Promise<Template>;
74
+ deleteTemplate(id: string): Promise<void>;
75
+ /**
76
+ * Re-run the snapshot phase against an existing template. Idempotent
77
+ * under concurrent retry: the daemon's CAS collapses N parallel calls
78
+ * for the same ready template into one rebuild kick. Returns the row in
79
+ * its post-transition state (typically `unhealthy`) — poll
80
+ * {@link MicroVM.getTemplate} to observe the transition back to `ready`.
81
+ *
82
+ * Returns 412 (raised as an error) when the template is in a state
83
+ * where rebuild is not safe (build in flight) or not supported
84
+ * (`ready_no_snapshot` / `failed` — those need delete+recreate today).
85
+ */
86
+ rebuildTemplate(id: string): Promise<Template>;
87
+ /**
88
+ * Cluster-level DNS helpers. Exposed as a namespaced accessor (rather
89
+ * than a flat `ingressDNS()` method) so call sites read like
90
+ * `microvm.dns.target()`, mirroring the per-sandbox `sandbox.customDomains`
91
+ * pattern. A fresh object is returned per access so the closures always
92
+ * see the current client even if MicroVM is later extended.
93
+ */
94
+ get dns(): {
95
+ target(): Promise<IngressTarget>;
96
+ };
54
97
  private wrap;
55
98
  }
56
99
  export {};
package/dist/MicroVM.js CHANGED
@@ -19,6 +19,7 @@ export class MicroVM {
19
19
  patToken,
20
20
  fetch: config.fetch,
21
21
  apiVersion: config.apiVersion,
22
+ retry: config.retry,
22
23
  });
23
24
  }
24
25
  async create(options) {
@@ -109,6 +110,55 @@ export class MicroVM {
109
110
  attachSession(sandboxID, sessionID, options = {}) {
110
111
  return this.client.attachSession(sandboxID, sessionID, options);
111
112
  }
113
+ /**
114
+ * Register a Firecracker rootfs template. Returns immediately with a
115
+ * `status: "pending"` row; poll {@link MicroVM.getTemplate} until the
116
+ * status reaches `"ready"` (fast-boot available) or `"ready_no_snapshot"`
117
+ * (cold boot only — see {@link Template.snapshotError}).
118
+ *
119
+ * Idempotent when {@link CreateTemplateOptions.id} is supplied: a
120
+ * duplicate ID returns 409 so a retried CI step does not create two
121
+ * rows for the same logical template.
122
+ */
123
+ async createTemplate(options) {
124
+ return this.client.createTemplate(options);
125
+ }
126
+ async listTemplates() {
127
+ return this.client.listTemplates();
128
+ }
129
+ async getTemplate(id) {
130
+ return this.client.getTemplate(id);
131
+ }
132
+ async deleteTemplate(id) {
133
+ await this.client.deleteTemplate(id);
134
+ }
135
+ /**
136
+ * Re-run the snapshot phase against an existing template. Idempotent
137
+ * under concurrent retry: the daemon's CAS collapses N parallel calls
138
+ * for the same ready template into one rebuild kick. Returns the row in
139
+ * its post-transition state (typically `unhealthy`) — poll
140
+ * {@link MicroVM.getTemplate} to observe the transition back to `ready`.
141
+ *
142
+ * Returns 412 (raised as an error) when the template is in a state
143
+ * where rebuild is not safe (build in flight) or not supported
144
+ * (`ready_no_snapshot` / `failed` — those need delete+recreate today).
145
+ */
146
+ async rebuildTemplate(id) {
147
+ return this.client.rebuildTemplate(id);
148
+ }
149
+ /**
150
+ * Cluster-level DNS helpers. Exposed as a namespaced accessor (rather
151
+ * than a flat `ingressDNS()` method) so call sites read like
152
+ * `microvm.dns.target()`, mirroring the per-sandbox `sandbox.customDomains`
153
+ * pattern. A fresh object is returned per access so the closures always
154
+ * see the current client even if MicroVM is later extended.
155
+ */
156
+ get dns() {
157
+ const client = this.client;
158
+ return {
159
+ target: () => client.ingressDNS(),
160
+ };
161
+ }
112
162
  wrap(sandbox) {
113
163
  return new Sandbox(this.client, sandbox);
114
164
  }
package/dist/index.d.ts CHANGED
@@ -2,4 +2,5 @@ export { MicroVM } from "./MicroVM.js";
2
2
  export { Sandbox } from "./Sandbox.js";
3
3
  export { Image } from "./Image.js";
4
4
  export type { MicroVMConfig } from "./MicroVM.js";
5
- export type { BinaryLike, BuildImageOptions, BuildImagePushOptions, BuildImageResult, CreateOptions, CreateSessionOptions, CustomDomain, CustomDomainStatus, ExecRequest, ExecResult, ExecExitInfo, ExposedPort, ExposePortOptions, ExposeProtocol, ExposeResult, Failover, FailoverPolicy, HealthStatus, Lifecycle, MountSpec, MountSpecRedacted, MountType, RegisterSnapshotOptions, RegistryAuth, ResizeOptions, Sandbox as SandboxData, SandboxSnapshot, SandboxStatus, Session, SessionAttachHandle, SessionAttachOptions, SessionStatus, UpdateLifecycleOptions, } from "./types.js";
5
+ export type { RetryConfig } from "./internal/client.js";
6
+ export type { AddCustomDomainOptions, BinaryLike, BuildImageOptions, BuildImagePushOptions, BuildImageResult, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CustomDomain, CustomDomainDNSRecords, CustomDomainStatus, DNSRecord, DNSRecordType, ExecRequest, ExecResult, ExecExitInfo, ExposedPort, ExposePortOptions, ExposeProtocol, ExposeResult, Failover, FailoverPolicy, HealthStatus, IngressTarget, IngressTargetSource, Lifecycle, MountSpec, MountSpecRedacted, MountType, RegisterSnapshotOptions, RegistryAuth, ResizeOptions, Sandbox as SandboxData, SandboxSnapshot, SandboxStatus, Session, SessionAttachHandle, SessionAttachOptions, SessionStatus, Template, TemplatePushState, TemplateStatus, UpdateLifecycleOptions, } from "./types.js";
@@ -14,3 +14,15 @@ export declare function sandboxCustomDomainsPath(prefix: string, id: string): st
14
14
  * `DELETE /v1/sandboxes/{id}/custom-domains/{hostname}`.
15
15
  */
16
16
  export declare function sandboxCustomDomainPath(prefix: string, id: string, hostname: string): string;
17
+ /**
18
+ * URL for the cluster's published ingress address(es), used by
19
+ * `microvm.dns.target()` to render the CNAME/A target a user must point
20
+ * custom-domain DNS at.
21
+ */
22
+ export declare function ingressDNSPath(prefix: string): string;
23
+ /**
24
+ * URL for the ready-to-paste DNS records of one sandbox's custom-domain
25
+ * bindings. Sibling of {@link sandboxCustomDomainsPath} — same `id` rules
26
+ * apply.
27
+ */
28
+ export declare function sandboxCustomDomainDNSPath(prefix: string, id: string): string;
@@ -24,3 +24,19 @@ export function sandboxCustomDomainsPath(prefix, id) {
24
24
  export function sandboxCustomDomainPath(prefix, id, hostname) {
25
25
  return `${prefix}/sandboxes/${id}/custom-domains/${encodeURIComponent(hostname)}`;
26
26
  }
27
+ /**
28
+ * URL for the cluster's published ingress address(es), used by
29
+ * `microvm.dns.target()` to render the CNAME/A target a user must point
30
+ * custom-domain DNS at.
31
+ */
32
+ export function ingressDNSPath(prefix) {
33
+ return `${prefix}/ingress/dns`;
34
+ }
35
+ /**
36
+ * URL for the ready-to-paste DNS records of one sandbox's custom-domain
37
+ * bindings. Sibling of {@link sandboxCustomDomainsPath} — same `id` rules
38
+ * apply.
39
+ */
40
+ export function sandboxCustomDomainDNSPath(prefix, id) {
41
+ return `${prefix}/sandboxes/${id}/custom-domains/dns`;
42
+ }
@@ -1,5 +1,5 @@
1
1
  import { Image } from "../Image.js";
2
- import type { BinaryLike, BuildImageOptions, BuildImageResult, CreateOptions, CreateSessionOptions, CustomDomain, ExecRequest, ExecResult, ExecStreamHandle, ExecStreamOptions, ExposedPort, ExposePortOptions, ExposeResult, Failover, HealthStatus, Lifecycle, ListOptions, MountSpecRedacted, NetworkUsage, RegisterSnapshotOptions, SetNetworkLimitsOptions, ResizeOptions, Sandbox, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions } from "../types.js";
2
+ import type { AddCustomDomainOptions, BinaryLike, BuildImageOptions, BuildImageResult, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CustomDomain, CustomDomainDNSRecords, ExecRequest, ExecResult, ExecStreamHandle, ExecStreamOptions, ExposedPort, ExposePortOptions, ExposeResult, Failover, HealthStatus, IngressTarget, Lifecycle, ListOptions, MountSpecRedacted, NetworkUsage, RegisterSnapshotOptions, SetNetworkLimitsOptions, ResizeOptions, Sandbox, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template } from "../types.js";
3
3
  type FetchLike = typeof fetch;
4
4
  /**
5
5
  * Wire version of the v1-style sandbox endpoints (`/v1/sandboxes`, etc.) this
@@ -16,6 +16,27 @@ type FetchLike = typeof fetch;
16
16
  * string image.
17
17
  */
18
18
  export type APIVersion = "v1";
19
+ /**
20
+ * Retry configuration for transient transport and server errors.
21
+ * All fields are optional — the SDK ships sensible defaults.
22
+ */
23
+ export interface RetryConfig {
24
+ /**
25
+ * Maximum number of retry attempts after the initial request fails.
26
+ * Set to `0` to disable retry entirely. Defaults to `3`.
27
+ */
28
+ maxRetries?: number;
29
+ /**
30
+ * Initial backoff delay in milliseconds. Subsequent retries double this
31
+ * value (exponential backoff). Defaults to `200`.
32
+ */
33
+ baseDelayMs?: number;
34
+ /**
35
+ * Hard ceiling on the computed delay in milliseconds. Prevents unbounded
36
+ * waits when `maxRetries` is high. Defaults to `5000`.
37
+ */
38
+ maxDelayMs?: number;
39
+ }
19
40
  export interface APIClientConfig {
20
41
  baseURL: string;
21
42
  patToken?: string;
@@ -26,6 +47,12 @@ export interface APIClientConfig {
26
47
  * a future major SDK release.
27
48
  */
28
49
  apiVersion?: APIVersion;
50
+ /**
51
+ * Retry policy for transient transport errors (socket closed, connection
52
+ * reset) and retryable HTTP status codes (429, 502, 503, 504). Pass
53
+ * `{ maxRetries: 0 }` to disable retry entirely.
54
+ */
55
+ retry?: RetryConfig;
29
56
  }
30
57
  export declare class APIClient {
31
58
  readonly baseURL: string;
@@ -33,6 +60,7 @@ export declare class APIClient {
33
60
  private readonly patToken;
34
61
  private readonly fetchFn;
35
62
  private readonly versionPrefix;
63
+ private readonly retryConfig;
36
64
  constructor(config: APIClientConfig);
37
65
  /**
38
66
  * Build a versioned API path. Pass the suffix beginning with "/" (e.g.
@@ -99,17 +127,44 @@ export declare class APIClient {
99
127
  * with an already-registered hostname is idempotent and returns the
100
128
  * existing list.
101
129
  */
102
- addCustomDomain(id: string, hostname: string): Promise<CustomDomain[]>;
130
+ addCustomDomain(id: string, hostname: string, options?: AddCustomDomainOptions): Promise<CustomDomain[]>;
103
131
  listCustomDomains(id: string): Promise<CustomDomain[]>;
104
132
  removeCustomDomain(id: string, hostname: string): Promise<void>;
133
+ /**
134
+ * Fetch the cluster's published ingress address(es) — the CNAME/A target
135
+ * a user must point custom-domain DNS at. Field names already match the
136
+ * SDK's camelCase shape on the wire, so no translation is needed.
137
+ */
138
+ ingressDNS(): Promise<IngressTarget>;
139
+ /**
140
+ * Fetch the ready-to-paste DNS records for one sandbox's custom-domain
141
+ * bindings, along with the {@link IngressTarget} they were composed from.
142
+ */
143
+ customDomainDNS(id: string): Promise<CustomDomainDNSRecords>;
105
144
  reconcile(): Promise<void>;
106
145
  health(): Promise<HealthStatus>;
107
146
  mounts(id: string): Promise<MountSpecRedacted[]>;
108
147
  getNetworkUsage(id: string): Promise<NetworkUsage>;
109
148
  setNetworkLimits(id: string, options: SetNetworkLimitsOptions): Promise<NetworkUsage>;
149
+ createTemplate(options: CreateTemplateOptions): Promise<Template>;
150
+ listTemplates(): Promise<Template[]>;
151
+ getTemplate(id: string): Promise<Template>;
152
+ deleteTemplate(id: string): Promise<void>;
153
+ rebuildTemplate(id: string): Promise<Template>;
110
154
  private wrap;
111
155
  private doJSON;
112
156
  private doBytes;
157
+ /**
158
+ * Low-level HTTP request with automatic retry for transient failures.
159
+ *
160
+ * Transport-level errors (socket closed, connection reset, DNS blip) are
161
+ * always retried regardless of HTTP method — the request never reached the
162
+ * server so there is no idempotency concern.
163
+ *
164
+ * HTTP-level retryable codes (429, 502, 503, 504) are retried for ALL
165
+ * methods because every mutating endpoint in the daemon is already
166
+ * designed for idempotent retry (e.g. INSERT OR IGNORE + disambiguation).
167
+ */
113
168
  private request;
114
169
  }
115
170
  export declare class SandboxResource implements Sandbox {
@@ -164,9 +219,10 @@ export declare class SandboxResource implements Sandbox {
164
219
  * so the closures always see the current `id` even after `refresh()`.
165
220
  */
166
221
  get customDomains(): {
167
- add(hostname: string): Promise<CustomDomain[]>;
222
+ add(hostname: string, options?: AddCustomDomainOptions): Promise<CustomDomain[]>;
168
223
  remove(hostname: string): Promise<void>;
169
224
  list(): Promise<CustomDomain[]>;
225
+ dns(): Promise<CustomDomainDNSRecords>;
170
226
  };
171
227
  createSnapshot(name: string): Promise<SandboxSnapshot>;
172
228
  start(): Promise<this>;
@@ -1,22 +1,75 @@
1
1
  import { basename } from "node:path";
2
- import { PATH_PREFIX as V1_PATH_PREFIX, sandboxCustomDomainPath as v1SandboxCustomDomainPath, sandboxCustomDomainsPath as v1SandboxCustomDomainsPath, } from "./api/v1/paths.js";
2
+ import { PATH_PREFIX as V1_PATH_PREFIX, ingressDNSPath as v1IngressDNSPath, sandboxCustomDomainDNSPath as v1SandboxCustomDomainDNSPath, sandboxCustomDomainPath as v1SandboxCustomDomainPath, sandboxCustomDomainsPath as v1SandboxCustomDomainsPath, } from "./api/v1/paths.js";
3
3
  import { Image } from "../Image.js";
4
4
  const DEFAULT_API_VERSION = "v1";
5
5
  const PATH_PREFIXES = {
6
6
  v1: V1_PATH_PREFIX,
7
7
  };
8
+ /** HTTP status codes the retry loop considers transient. */
9
+ const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]);
10
+ /** Default retry settings when the caller doesn't supply a RetryConfig. */
11
+ const DEFAULT_RETRY = {
12
+ maxRetries: 3,
13
+ baseDelayMs: 200,
14
+ maxDelayMs: 5_000,
15
+ };
16
+ /**
17
+ * Returns `true` when the thrown `error` looks like a transport-level failure
18
+ * that vanishes on retry (connection reset, socket closed by peer, DNS
19
+ * blip). `undici` and Node core use several `cause.code` values; we check
20
+ * the full cause chain.
21
+ */
22
+ function isTransientTransportError(error) {
23
+ const codes = new Set([
24
+ "UND_ERR_SOCKET",
25
+ "ECONNRESET",
26
+ "ECONNREFUSED",
27
+ "ETIMEDOUT",
28
+ "EPIPE",
29
+ "EAI_AGAIN",
30
+ "UND_ERR_CONNECT_TIMEOUT",
31
+ "UND_ERR_BODY_TIMEOUT",
32
+ "UND_ERR_HEADERS_TIMEOUT",
33
+ ]);
34
+ let current = error;
35
+ for (let depth = 0; depth < 5 && current != null; depth++) {
36
+ if (current instanceof Error) {
37
+ const code = current.code;
38
+ if (code && codes.has(code))
39
+ return true;
40
+ if (current.message && /socket hang up|other side closed/i.test(current.message))
41
+ return true;
42
+ current = current.cause;
43
+ }
44
+ else {
45
+ break;
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+ /** Sleep with ±25% jitter so concurrent callers don't thundering-herd. */
51
+ function jitteredDelay(baseMs) {
52
+ const jitter = 1 + (Math.random() - 0.5) * 0.5; // 0.75 – 1.25
53
+ return new Promise((resolve) => setTimeout(resolve, Math.round(baseMs * jitter)));
54
+ }
8
55
  export class APIClient {
9
56
  baseURL;
10
57
  apiVersion;
11
58
  patToken;
12
59
  fetchFn;
13
60
  versionPrefix;
61
+ retryConfig;
14
62
  constructor(config) {
15
63
  this.baseURL = config.baseURL.replace(/\/+$/, "");
16
64
  this.patToken = config.patToken ?? "";
17
65
  this.fetchFn = config.fetch ?? fetch;
18
66
  this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION;
19
67
  this.versionPrefix = PATH_PREFIXES[this.apiVersion];
68
+ this.retryConfig = {
69
+ maxRetries: config.retry?.maxRetries ?? DEFAULT_RETRY.maxRetries,
70
+ baseDelayMs: config.retry?.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
71
+ maxDelayMs: config.retry?.maxDelayMs ?? DEFAULT_RETRY.maxDelayMs,
72
+ };
20
73
  }
21
74
  /**
22
75
  * Build a versioned API path. Pass the suffix beginning with "/" (e.g.
@@ -226,8 +279,12 @@ export class APIClient {
226
279
  * with an already-registered hostname is idempotent and returns the
227
280
  * existing list.
228
281
  */
229
- async addCustomDomain(id, hostname) {
230
- const response = await this.doJSON("POST", v1SandboxCustomDomainsPath(this.versionPrefix, id), { hostname });
282
+ async addCustomDomain(id, hostname, options) {
283
+ const body = { hostname };
284
+ if (options?.port !== undefined && options.port !== 0) {
285
+ body.target_port = options.port;
286
+ }
287
+ const response = await this.doJSON("POST", v1SandboxCustomDomainsPath(this.versionPrefix, id), body);
231
288
  return response.custom_domains.map(fromApiCustomDomain);
232
289
  }
233
290
  async listCustomDomains(id) {
@@ -237,6 +294,21 @@ export class APIClient {
237
294
  async removeCustomDomain(id, hostname) {
238
295
  await this.doJSON("DELETE", v1SandboxCustomDomainPath(this.versionPrefix, id, hostname));
239
296
  }
297
+ /**
298
+ * Fetch the cluster's published ingress address(es) — the CNAME/A target
299
+ * a user must point custom-domain DNS at. Field names already match the
300
+ * SDK's camelCase shape on the wire, so no translation is needed.
301
+ */
302
+ async ingressDNS() {
303
+ return this.doJSON("GET", v1IngressDNSPath(this.versionPrefix));
304
+ }
305
+ /**
306
+ * Fetch the ready-to-paste DNS records for one sandbox's custom-domain
307
+ * bindings, along with the {@link IngressTarget} they were composed from.
308
+ */
309
+ async customDomainDNS(id) {
310
+ return this.doJSON("GET", v1SandboxCustomDomainDNSPath(this.versionPrefix, id));
311
+ }
240
312
  async reconcile() {
241
313
  await this.doJSON("POST", this.versioned("/admin/reconcile"));
242
314
  }
@@ -256,6 +328,31 @@ export class APIClient {
256
328
  const response = await this.doJSON("PATCH", `${this.versionPrefix}/sandboxes/${id}/network/limits`, toApiSetNetworkLimitsOptions(options));
257
329
  return fromApiNetworkUsage(response);
258
330
  }
331
+ async createTemplate(options) {
332
+ const response = await this.doJSON("POST", this.versioned("/templates"), {
333
+ id: options.id,
334
+ image: options.image,
335
+ min_size_mib: options.minSizeMiB,
336
+ });
337
+ return fromApiTemplate(response);
338
+ }
339
+ async listTemplates() {
340
+ const response = await this.doJSON("GET", this.versioned("/templates"));
341
+ // The daemon returns `null` rather than `[]` when the table is empty;
342
+ // normalize so callers always get an array.
343
+ return (response ?? []).map(fromApiTemplate);
344
+ }
345
+ async getTemplate(id) {
346
+ const response = await this.doJSON("GET", `${this.versionPrefix}/templates/${id}`);
347
+ return fromApiTemplate(response);
348
+ }
349
+ async deleteTemplate(id) {
350
+ await this.doJSON("DELETE", `${this.versionPrefix}/templates/${id}`);
351
+ }
352
+ async rebuildTemplate(id) {
353
+ const response = await this.doJSON("POST", `${this.versionPrefix}/templates/${id}/rebuild`);
354
+ return fromApiTemplate(response);
355
+ }
259
356
  wrap(sandbox) {
260
357
  return new SandboxResource(this, fromApiSandbox(sandbox));
261
358
  }
@@ -283,16 +380,49 @@ export class APIClient {
283
380
  }
284
381
  return new Uint8Array(await response.arrayBuffer());
285
382
  }
286
- request(method, path, init = {}) {
383
+ /**
384
+ * Low-level HTTP request with automatic retry for transient failures.
385
+ *
386
+ * Transport-level errors (socket closed, connection reset, DNS blip) are
387
+ * always retried regardless of HTTP method — the request never reached the
388
+ * server so there is no idempotency concern.
389
+ *
390
+ * HTTP-level retryable codes (429, 502, 503, 504) are retried for ALL
391
+ * methods because every mutating endpoint in the daemon is already
392
+ * designed for idempotent retry (e.g. INSERT OR IGNORE + disambiguation).
393
+ */
394
+ async request(method, path, init = {}) {
287
395
  const headers = new Headers(init.headers);
288
396
  if (this.patToken !== "") {
289
397
  headers.set("Authorization", `Bearer ${this.patToken}`);
290
398
  }
291
- return this.fetchFn(`${this.baseURL}${path}`, {
292
- ...init,
293
- method,
294
- headers,
295
- });
399
+ const url = `${this.baseURL}${path}`;
400
+ const requestInit = { ...init, method, headers };
401
+ const { maxRetries, baseDelayMs, maxDelayMs } = this.retryConfig;
402
+ let lastError;
403
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
404
+ try {
405
+ const response = await this.fetchFn(url, requestInit);
406
+ // Retry on transient HTTP status codes.
407
+ if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < maxRetries) {
408
+ const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
409
+ await jitteredDelay(delay);
410
+ continue;
411
+ }
412
+ return response;
413
+ }
414
+ catch (error) {
415
+ lastError = error;
416
+ // Only retry transport-level failures; anything else propagates.
417
+ if (!isTransientTransportError(error) || attempt >= maxRetries) {
418
+ throw error;
419
+ }
420
+ const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
421
+ await jitteredDelay(delay);
422
+ }
423
+ }
424
+ // Should be unreachable — the loop always throws or returns.
425
+ throw lastError;
296
426
  }
297
427
  }
298
428
  export class SandboxResource {
@@ -363,9 +493,10 @@ export class SandboxResource {
363
493
  const client = this.client;
364
494
  const id = this.id;
365
495
  return {
366
- add: (hostname) => client.addCustomDomain(id, hostname),
496
+ add: (hostname, options) => client.addCustomDomain(id, hostname, options),
367
497
  remove: (hostname) => client.removeCustomDomain(id, hostname),
368
498
  list: () => client.listCustomDomains(id),
499
+ dns: () => client.customDomainDNS(id),
369
500
  };
370
501
  }
371
502
  async createSnapshot(name) {
@@ -524,6 +655,25 @@ function fromApiCreateSandboxResponse(response) {
524
655
  sshPrivateKey: response.ssh_private_key,
525
656
  };
526
657
  }
658
+ function fromApiTemplate(template) {
659
+ return {
660
+ id: template.id,
661
+ image: template.image,
662
+ status: template.status,
663
+ rootfsSizeBytes: template.rootfs_size_bytes,
664
+ minSizeMiB: template.min_size_mib,
665
+ lastError: template.last_error,
666
+ createdAt: template.created_at,
667
+ updatedAt: template.updated_at,
668
+ readyAt: template.ready_at,
669
+ snapshotSizeBytes: template.snapshot_size_bytes,
670
+ snapshotError: template.snapshot_error,
671
+ hasSnapshot: template.has_snapshot,
672
+ hasOverlay: template.has_overlay,
673
+ pushState: template.push_state,
674
+ pushError: template.push_error,
675
+ };
676
+ }
527
677
  function fromApiSandboxSnapshot(snapshot) {
528
678
  return {
529
679
  name: snapshot.name,
@@ -564,6 +714,7 @@ function fromApiCustomDomain(domain) {
564
714
  lastError: domain.last_error,
565
715
  createdAt: domain.created_at,
566
716
  updatedAt: domain.updated_at,
717
+ targetPort: domain.target_port,
567
718
  };
568
719
  }
569
720
  function fromApiExposedPort(port) {
package/dist/types.d.ts CHANGED
@@ -164,7 +164,7 @@ export interface CreateOptions {
164
164
  *
165
165
  * GPU access is not supported with `"gvisor"`.
166
166
  */
167
- runtime?: "docker" | "gvisor" | "kata";
167
+ runtime?: "docker" | "gvisor" | "kata" | "firecracker";
168
168
  /**
169
169
  * Attach GPU resources to the sandbox. Omit for CPU-only workloads.
170
170
  * Not compatible with runtime="gvisor".
@@ -240,6 +240,70 @@ export interface CustomDomain {
240
240
  lastError?: string;
241
241
  createdAt: string;
242
242
  updatedAt: string;
243
+ /**
244
+ * Target container port traffic to this hostname dials. `0` (or omitted)
245
+ * means the sandbox's toolbox port — the default that preserves the
246
+ * pre-target-port behavior. Set once at attach time; to change it, remove
247
+ * the binding and re-add it with the new port.
248
+ */
249
+ targetPort?: number;
250
+ }
251
+ /**
252
+ * Options for `sandbox.customDomains.add()`. `port` pins the container port
253
+ * traffic to this hostname dials; omit it to route to the sandbox's toolbox
254
+ * port (the default). Re-adding the same hostname with a different `port`
255
+ * returns 409 — detach first.
256
+ */
257
+ export interface AddCustomDomainOptions {
258
+ port?: number;
259
+ }
260
+ /**
261
+ * Where the SDK derived the ingress address(es) from. Reflects how the
262
+ * operator deployed the cluster:
263
+ * - "hostname": the cluster gossips a stable hostname (CNAME target).
264
+ * - "ips": one or more raw ingress IPs (one A/AAAA record per IP).
265
+ * - "mixed": some nodes gossip a hostname, others raw IPs — prefer
266
+ * the hostname but render both so apex domains still work.
267
+ * - "unknown": no ingress node has gossiped a public address yet; the
268
+ * target is unusable and callers should surface that, not
269
+ * guess.
270
+ */
271
+ export type IngressTargetSource = "hostname" | "ips" | "mixed" | "unknown";
272
+ /**
273
+ * The cluster's published ingress address(es) — the value that DNS for a
274
+ * custom domain must ultimately resolve to. Returned by `microvm.dns.target()`
275
+ * and embedded in {@link CustomDomainDNSRecords}.
276
+ */
277
+ export interface IngressTarget {
278
+ hostname?: string;
279
+ ips?: string[];
280
+ source: IngressTargetSource;
281
+ }
282
+ /** DNS record types the daemon emits for custom-domain wiring. */
283
+ export type DNSRecordType = "CNAME" | "A" | "AAAA";
284
+ /**
285
+ * One ready-to-paste DNS record a user adds at their DNS provider to make a
286
+ * custom domain reach the cluster. `name` is the leftmost label (or "@" for
287
+ * apex) the way DNS UIs accept it. `notes` carries provider-specific gotchas
288
+ * the daemon pre-renders (e.g. Cloudflare "DNS only, gray cloud") when
289
+ * applicable.
290
+ */
291
+ export interface DNSRecord {
292
+ hostname: string;
293
+ type: DNSRecordType;
294
+ name: string;
295
+ value: string;
296
+ notes?: string;
297
+ }
298
+ /**
299
+ * Response from `sandbox.customDomains.dns()`. `records` is the flat
300
+ * ready-to-paste list (one row per custom domain × per ingress address);
301
+ * `target` is the raw aggregation the records were composed from, included
302
+ * so callers can render their own UI without a second round trip.
303
+ */
304
+ export interface CustomDomainDNSRecords {
305
+ records: DNSRecord[];
306
+ target: IngressTarget;
243
307
  }
244
308
  /**
245
309
  * Wire protocol an exposure publishes through:
@@ -311,7 +375,7 @@ export interface Sandbox {
311
375
  * Container runtime this sandbox is running under. Empty string indicates
312
376
  * a pre-migration row that resolves to the host default at start time.
313
377
  */
314
- runtime: "" | "docker" | "gvisor" | "kata";
378
+ runtime: "" | "docker" | "gvisor" | "kata" | "firecracker";
315
379
  /** GPU configuration this sandbox was created with. Absent means no GPU. */
316
380
  gpus?: GPUOptions;
317
381
  }
@@ -406,3 +470,63 @@ export interface HealthStatus {
406
470
  version: string;
407
471
  }
408
472
  export type BinaryLike = Uint8Array | ArrayBuffer | Blob | string;
473
+ /**
474
+ * Lifecycle states reported by a Firecracker rootfs template. See
475
+ * `plans/snapshot-clone-fast-boot.md` for the state machine.
476
+ *
477
+ * - `pending`, `building_rootfs`, `snapshotting`: the build pipeline is in
478
+ * flight; no sandbox creation against this template will succeed yet.
479
+ * - `ready`: cold boot + fast snapshot clone both available.
480
+ * - `ready_no_snapshot`: cold boot works; snapshot phase failed and
481
+ * sandboxes will not get the fast-boot path until a rebuild succeeds.
482
+ * - `unhealthy`: snapshot was detected as corrupt; an async rebuild is in
483
+ * flight (or pending the next daemon restart).
484
+ * - `failed`: terminal state from a failed initial build; the row must be
485
+ * deleted and recreated.
486
+ */
487
+ export type TemplateStatus = "pending" | "building_rootfs" | "snapshotting" | "ready" | "ready_no_snapshot" | "failed" | "unhealthy";
488
+ /**
489
+ * Background-push state for the template's artifacts (rootfs.ext4 +
490
+ * snapshot.*). "active" means push succeeded or push is disabled; "pending"
491
+ * / "pushing" are in-flight states; "error" carries `pushError` for the
492
+ * last failure.
493
+ */
494
+ export type TemplatePushState = "active" | "pending" | "pushing" | "error";
495
+ /**
496
+ * A Firecracker rootfs template. Created once from an OCI image and
497
+ * boot-shared across many sandboxes. Use {@link MicroVM.createTemplate} to
498
+ * register one, then pass its {@link Template.id} on subsequent sandbox
499
+ * `create` calls (when the sandbox runtime is `firecracker`).
500
+ */
501
+ export interface Template {
502
+ id: string;
503
+ image: string;
504
+ status: TemplateStatus;
505
+ rootfsSizeBytes?: number;
506
+ /** Optional floor for the ext4 image size, in MiB. */
507
+ minSizeMiB?: number;
508
+ /** Last build-pipeline error, populated when `status` is `failed`. */
509
+ lastError?: string;
510
+ createdAt: string;
511
+ updatedAt: string;
512
+ /** First time the template reached `ready` / `ready_no_snapshot`. */
513
+ readyAt?: string;
514
+ snapshotSizeBytes?: number;
515
+ /** Populated when `status` is `ready_no_snapshot` — why the snapshot phase failed. */
516
+ snapshotError?: string;
517
+ /** True when the template has a usable snapshot for fast-boot. */
518
+ hasSnapshot: boolean;
519
+ /** True when the template was built with a per-sandbox writable overlay placeholder. */
520
+ hasOverlay: boolean;
521
+ pushState?: TemplatePushState;
522
+ /** Populated when `pushState` is `error` — the last push failure. */
523
+ pushError?: string;
524
+ }
525
+ export interface CreateTemplateOptions {
526
+ /** Optional explicit ID. Empty means the daemon generates one. Supplying an explicit ID lets retries be idempotent — a duplicate ID returns 409. */
527
+ id?: string;
528
+ /** skopeo-style image reference, e.g. `"docker://python:3.11"`. */
529
+ image: string;
530
+ /** Optional floor for the ext4 image size, in MiB. */
531
+ minSizeMiB?: number;
532
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aerol-ai/aerolvm-sdk",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "test": "tsc -p tsconfig.test.json --outDir dist-test && node --test dist-test/*.test.js dist-test/internal/*.test.js"
22
22
  },
23
23
  "devDependencies": {
24
- "@types/node": "^25.8.0",
24
+ "@types/node": "^25.9.1",
25
25
  "typescript": "^6.0.3",
26
26
  "tsx": "^4.22.3"
27
27
  }