@aerol-ai/aerolvm-sdk 0.6.4 → 0.7.0
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 +62 -3
- package/dist/MicroVM.js +40 -2
- package/dist/index.d.ts +2 -2
- package/dist/internal/client.d.ts +71 -9
- package/dist/internal/client.js +243 -32
- package/dist/types.d.ts +84 -6
- package/package.json +2 -2
package/dist/MicroVM.d.ts
CHANGED
|
@@ -1,11 +1,40 @@
|
|
|
1
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, CloneGeneration, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, PushWasmModuleOptions, PushWasmModuleResult, ExecStreamHandle, ExecStreamOptions, HealthStatus, EgressProfile, EgressProfileList, EgressProfileOptions, ListEgressProfilesOptions, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, IngressTarget, Lifecycle, GetOptions, ListOptions, MountSpecRedacted, RegisterSnapshotOptions, ResizeOptions, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template, WasmModule } from "./types.js";
|
|
4
|
+
import type { APITokenList, BuildImageOptions, BuildImageResult, CloneGeneration, CreateAPITokenOptions, CreatedAPIToken, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, PushWasmModuleOptions, PushWasmModuleResult, ExecStreamHandle, ExecStreamOptions, HealthStatus, EgressProfile, EgressProfileList, EgressProfileOptions, ListEgressProfilesOptions, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, IngressTarget, Lifecycle, GetOptions, ListOptions, MountSpecRedacted, RegisterSnapshotOptions, ListSnapshotsOptions, SnapshotList, ResizeOptions, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template, WasmModule, WhoAmI } from "./types.js";
|
|
5
5
|
type FetchLike = typeof fetch;
|
|
6
|
+
/** Scoped API token calls, reached as `microvm.tokens`. */
|
|
7
|
+
export interface Tokens {
|
|
8
|
+
/**
|
|
9
|
+
* Mint a token (admin only). The result's `token` is the secret, returned
|
|
10
|
+
* this once and never again. A 409 means the name is taken, possibly by an
|
|
11
|
+
* earlier attempt whose reply was lost; that secret cannot be recovered,
|
|
12
|
+
* so revoke the token and create it again.
|
|
13
|
+
*/
|
|
14
|
+
create(options: CreateAPITokenOptions): Promise<CreatedAPIToken>;
|
|
15
|
+
/** List every token (auditor or admin); expired ones stay listed for 30 days. */
|
|
16
|
+
list(): Promise<APITokenList>;
|
|
17
|
+
/** Revoke a token by id (admin only). Revoking one already gone succeeds. */
|
|
18
|
+
revoke(id: string): Promise<void>;
|
|
19
|
+
}
|
|
6
20
|
export interface MicroVMConfig {
|
|
21
|
+
/**
|
|
22
|
+
* The bearer every request carries: the operator PAT, an `avm_` API token
|
|
23
|
+
* or an OIDC ID token. Defaults to `SB_PAT_TOKEN`, then its alias
|
|
24
|
+
* `SB_API_TOKEN`; when both are set, `SB_PAT_TOKEN` wins.
|
|
25
|
+
*/
|
|
7
26
|
patToken?: string;
|
|
8
27
|
apiUrl?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The team requests act for, sent as the `X-AerolVM-Team` header on every
|
|
30
|
+
* HTTP request. Defaults to `SB_TEAM`; with neither, no header is sent and
|
|
31
|
+
* the server uses the credential's first matching binding. The header only
|
|
32
|
+
* chooses among teams the credential is already bound to (a team it does
|
|
33
|
+
* not hold is refused with 403), and the operator PAT ignores it.
|
|
34
|
+
* WebSocket streams (`execStream`, `attachSession`) cannot carry custom
|
|
35
|
+
* headers from a browser or Node, so they go without it.
|
|
36
|
+
*/
|
|
37
|
+
team?: string;
|
|
9
38
|
fetch?: FetchLike;
|
|
10
39
|
/**
|
|
11
40
|
* Wire version of the sandbox daemon API to call. Defaults to the SDK's
|
|
@@ -17,14 +46,27 @@ export interface MicroVMConfig {
|
|
|
17
46
|
/**
|
|
18
47
|
* Retry policy for transient transport errors (socket closed, connection
|
|
19
48
|
* reset) and retryable HTTP status codes (421, 429, 502, 503, 504). The SDK
|
|
20
|
-
* retries up to 3 times with exponential backoff by default.
|
|
21
|
-
*
|
|
49
|
+
* retries up to 3 times with exponential backoff by default. `exec` retries
|
|
50
|
+
* only failures that prove the command never reached the server
|
|
51
|
+
* (connection refused, connect timeout, DNS) and HTTP 429/503, so a command
|
|
52
|
+
* never runs twice. Pass `{ maxRetries: 0 }` to disable. A 429 or 503
|
|
53
|
+
* waits as long as the server's `Retry-After` asks, up to
|
|
54
|
+
* `retry.maxRetryAfterMs` (30 s by default).
|
|
22
55
|
*/
|
|
23
56
|
retry?: RetryConfig;
|
|
57
|
+
/**
|
|
58
|
+
* Cancels the client's HTTP requests: aborting it ends the request in
|
|
59
|
+
* flight and any retry wait, so a call never sits out a long
|
|
60
|
+
* `Retry-After` once aborted. Pass `AbortSignal.timeout(ms)` to bound
|
|
61
|
+
* how long the client may run. WebSocket streams are not affected.
|
|
62
|
+
*/
|
|
63
|
+
signal?: AbortSignal;
|
|
24
64
|
}
|
|
25
65
|
export declare class MicroVM {
|
|
26
66
|
readonly apiUrl: string;
|
|
27
67
|
readonly patToken: string;
|
|
68
|
+
/** The team sent as `X-AerolVM-Team`, or `""` when none is. */
|
|
69
|
+
readonly team: string;
|
|
28
70
|
private readonly client;
|
|
29
71
|
constructor(config?: MicroVMConfig);
|
|
30
72
|
create(options: CreateOptions): Promise<Sandbox>;
|
|
@@ -46,6 +88,12 @@ export declare class MicroVM {
|
|
|
46
88
|
createSnapshot(id: string, name: string): Promise<SandboxSnapshot>;
|
|
47
89
|
registerSnapshot(options: RegisterSnapshotOptions): Promise<SandboxSnapshot>;
|
|
48
90
|
registerSnapshotFromImage(name: string, image: Image, options?: Omit<RegisterSnapshotOptions, "name" | "image" | "dockerfileContent">): Promise<SandboxSnapshot>;
|
|
91
|
+
/** Look a snapshot up by name. In a cluster the request is routed to the node holding the image. */
|
|
92
|
+
getSnapshot(name: string): Promise<SandboxSnapshot>;
|
|
93
|
+
/** List the caller's snapshots, one name-ordered page at a time. */
|
|
94
|
+
listSnapshots(options?: ListSnapshotsOptions): Promise<SnapshotList>;
|
|
95
|
+
/** Delete a snapshot by name (idempotent 404 if it does not exist). */
|
|
96
|
+
deleteSnapshot(name: string): Promise<void>;
|
|
49
97
|
destroy(id: string): Promise<void>;
|
|
50
98
|
resize(id: string, options: ResizeOptions): Promise<Sandbox>;
|
|
51
99
|
updateLifecycle(id: string, lifecycle: Lifecycle): Promise<Sandbox>;
|
|
@@ -143,6 +191,17 @@ export declare class MicroVM {
|
|
|
143
191
|
get dns(): {
|
|
144
192
|
target(): Promise<IngressTarget>;
|
|
145
193
|
};
|
|
194
|
+
/**
|
|
195
|
+
* Who the server says the caller is: principal, role, team and when the
|
|
196
|
+
* credential expires. Every valid credential may ask.
|
|
197
|
+
*/
|
|
198
|
+
whoami(): Promise<WhoAmI>;
|
|
199
|
+
/**
|
|
200
|
+
* Scoped API tokens (`avm_…`) for services and CI, as
|
|
201
|
+
* `microvm.tokens.create/list/revoke`. Same namespaced shape as
|
|
202
|
+
* {@link MicroVM.dns}.
|
|
203
|
+
*/
|
|
204
|
+
get tokens(): Tokens;
|
|
146
205
|
private wrap;
|
|
147
206
|
}
|
|
148
207
|
export {};
|
package/dist/MicroVM.js
CHANGED
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
import { APIClient, stripTrailingSlashes } from "./internal/client.js";
|
|
2
2
|
import { Sandbox } from "./Sandbox.js";
|
|
3
3
|
const defaultAPIURL = "http://127.0.0.1:21212";
|
|
4
|
-
const authRequiredErrorMessage = "PAT token is required. Set patToken or
|
|
4
|
+
const authRequiredErrorMessage = "PAT token is required. Set patToken, SB_PAT_TOKEN or SB_API_TOKEN.";
|
|
5
5
|
export class MicroVM {
|
|
6
6
|
apiUrl;
|
|
7
7
|
patToken;
|
|
8
|
+
/** The team sent as `X-AerolVM-Team`, or `""` when none is. */
|
|
9
|
+
team;
|
|
8
10
|
client;
|
|
9
11
|
constructor(config = {}) {
|
|
10
|
-
const patToken = config.patToken ?? readEnv("SB_PAT_TOKEN") ?? "";
|
|
12
|
+
const patToken = config.patToken ?? readEnv("SB_PAT_TOKEN") ?? readEnv("SB_API_TOKEN") ?? "";
|
|
11
13
|
const apiUrl = normalizeURL(config.apiUrl ?? readEnv("SB_API_URL") ?? defaultAPIURL);
|
|
14
|
+
const team = (config.team ?? readEnv("SB_TEAM") ?? "").trim();
|
|
12
15
|
if (patToken === "") {
|
|
13
16
|
throw new Error(authRequiredErrorMessage);
|
|
14
17
|
}
|
|
15
18
|
this.apiUrl = apiUrl;
|
|
16
19
|
this.patToken = patToken;
|
|
20
|
+
this.team = team;
|
|
17
21
|
this.client = new APIClient({
|
|
18
22
|
baseURL: apiUrl,
|
|
19
23
|
patToken,
|
|
24
|
+
team,
|
|
20
25
|
fetch: config.fetch,
|
|
21
26
|
apiVersion: config.apiVersion,
|
|
22
27
|
retry: config.retry,
|
|
28
|
+
signal: config.signal,
|
|
23
29
|
});
|
|
24
30
|
}
|
|
25
31
|
async create(options) {
|
|
@@ -69,6 +75,18 @@ export class MicroVM {
|
|
|
69
75
|
async registerSnapshotFromImage(name, image, options = {}) {
|
|
70
76
|
return this.client.registerSnapshotFromImage(name, image, options);
|
|
71
77
|
}
|
|
78
|
+
/** Look a snapshot up by name. In a cluster the request is routed to the node holding the image. */
|
|
79
|
+
async getSnapshot(name) {
|
|
80
|
+
return this.client.getSnapshot(name);
|
|
81
|
+
}
|
|
82
|
+
/** List the caller's snapshots, one name-ordered page at a time. */
|
|
83
|
+
async listSnapshots(options = {}) {
|
|
84
|
+
return this.client.listSnapshots(options);
|
|
85
|
+
}
|
|
86
|
+
/** Delete a snapshot by name (idempotent 404 if it does not exist). */
|
|
87
|
+
async deleteSnapshot(name) {
|
|
88
|
+
return this.client.deleteSnapshot(name);
|
|
89
|
+
}
|
|
72
90
|
async destroy(id) {
|
|
73
91
|
await this.client.destroy(id);
|
|
74
92
|
}
|
|
@@ -237,6 +255,26 @@ export class MicroVM {
|
|
|
237
255
|
target: () => client.ingressDNS(),
|
|
238
256
|
};
|
|
239
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Who the server says the caller is: principal, role, team and when the
|
|
260
|
+
* credential expires. Every valid credential may ask.
|
|
261
|
+
*/
|
|
262
|
+
async whoami() {
|
|
263
|
+
return this.client.whoami();
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Scoped API tokens (`avm_…`) for services and CI, as
|
|
267
|
+
* `microvm.tokens.create/list/revoke`. Same namespaced shape as
|
|
268
|
+
* {@link MicroVM.dns}.
|
|
269
|
+
*/
|
|
270
|
+
get tokens() {
|
|
271
|
+
const client = this.client;
|
|
272
|
+
return {
|
|
273
|
+
create: (options) => client.createAPIToken(options),
|
|
274
|
+
list: () => client.listAPITokens(),
|
|
275
|
+
revoke: (id) => client.revokeAPIToken(id),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
240
278
|
wrap(sandbox) {
|
|
241
279
|
return new Sandbox(this.client, sandbox);
|
|
242
280
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { MicroVM } from "./MicroVM.js";
|
|
2
2
|
export { Sandbox } from "./Sandbox.js";
|
|
3
3
|
export { Image } from "./Image.js";
|
|
4
|
-
export type { MicroVMConfig } from "./MicroVM.js";
|
|
4
|
+
export type { MicroVMConfig, Tokens } from "./MicroVM.js";
|
|
5
5
|
export type { RetryConfig } from "./internal/client.js";
|
|
6
|
-
export type { AddCustomDomainOptions, AuditEvent, AuditOptions, AuditPage, EgressProfile, EgressProfileList, EgressProfileOptions, EgressProfileRef, EgressInject, EgressRule, ListEgressProfilesOptions, NetworkLearned, NetworkLearnedEntry, NetworkPolicy, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, NetworkPolicyOptions, BinaryLike, BuildImageOptions, BuildImagePushOptions, BuildImageResult, CloneGeneration, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, CustomDomain, CustomDomainDNSRecords, CustomDomainStatus, DNSRecord, DNSRecordType, ExecRequest, ExecResult, ExecExitInfo, ExposedPort, ExposePortOptions, ExposeProtocol, ExposeResult, Failover, FailoverPolicy, GetOptions, HealthStatus, IngressTarget, IngressTargetSource, Lifecycle, ListOptions, MountSpec, MountSpecRedacted, MountType, PlatformVolumeMount, RegisterSnapshotOptions, RegistryAuth, ResizeOptions, Sandbox as SandboxData, SandboxSnapshot, SandboxStatus, Session, SessionAttachHandle, SessionAttachOptions, SessionStatus, Template, TemplatePushState, TemplateStatus, WasmModule, WasmModuleStatus, PushWasmModuleOptions, PushWasmModuleResult, UpdateLifecycleOptions, } from "./types.js";
|
|
6
|
+
export type { AddCustomDomainOptions, APIToken, APITokenList, CreateAPITokenOptions, CreatedAPIToken, WhoAmI, WhoAmIPrincipal, AuditEvent, AuditOptions, AuditPage, EgressProfile, EgressProfileList, EgressProfileOptions, EgressProfileRef, EgressInject, EgressRule, ListEgressProfilesOptions, NetworkLearned, NetworkLearnedEntry, NetworkPolicy, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, NetworkPolicyOptions, BinaryLike, BuildImageOptions, BuildImagePushOptions, BuildImageResult, CloneGeneration, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, CustomDomain, CustomDomainDNSRecords, CustomDomainStatus, DNSRecord, DNSRecordType, ExecRequest, ExecResult, ExecExitInfo, ExposedPort, ExposePortOptions, ExposeProtocol, ExposeResult, Failover, FailoverPolicy, GetOptions, HealthStatus, IngressTarget, IngressTargetSource, Lifecycle, ListOptions, MountSpec, MountSpecRedacted, MountType, PlatformVolumeMount, RegisterSnapshotOptions, ListSnapshotsOptions, SnapshotList, RegistryAuth, ResizeOptions, Sandbox as SandboxData, SandboxSnapshot, SandboxStatus, Session, SessionAttachHandle, SessionAttachOptions, SessionStatus, Template, TemplatePushState, TemplateStatus, WasmModule, WasmModuleStatus, PushWasmModuleOptions, PushWasmModuleResult, UpdateLifecycleOptions, } from "./types.js";
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { Image } from "../Image.js";
|
|
2
|
-
import type { AddCustomDomainOptions, BinaryLike, BuildImageOptions, BuildImageResult, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, PushWasmModuleOptions, PushWasmModuleResult, CustomDomain, CloneGeneration, CustomDomainDNSRecords, ExecRequest, ExecResult, ExecStreamHandle, ExecStreamOptions, ExposedPort, ExposePortOptions, ExposeResult, Failover, HealthStatus, IngressTarget, Lifecycle, GetOptions, ListOptions, MountSpecRedacted, NetworkUsage, AuditOptions, AuditPage, EgressProfile, EgressProfileList, EgressProfileOptions, EgressProfileRef, EgressRule, ListEgressProfilesOptions, NetworkLearned, NetworkPolicy, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, NetworkPolicyOptions, RegisterSnapshotOptions, SetNetworkLimitsOptions, ResizeOptions, Sandbox, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template, WasmModule } from "../types.js";
|
|
2
|
+
import type { AddCustomDomainOptions, APITokenList, BinaryLike, BuildImageOptions, BuildImageResult, CreateAPITokenOptions, CreatedAPIToken, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, PushWasmModuleOptions, PushWasmModuleResult, CustomDomain, CloneGeneration, CustomDomainDNSRecords, ExecRequest, ExecResult, ExecStreamHandle, ExecStreamOptions, ExposedPort, ExposePortOptions, ExposeResult, Failover, HealthStatus, IngressTarget, Lifecycle, GetOptions, ListOptions, MountSpecRedacted, NetworkUsage, AuditOptions, AuditPage, EgressProfile, EgressProfileList, EgressProfileOptions, EgressProfileRef, EgressRule, ListEgressProfilesOptions, NetworkLearned, NetworkPolicy, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, NetworkPolicyOptions, ListSnapshotsOptions, RegisterSnapshotOptions, SnapshotList, SetNetworkLimitsOptions, ResizeOptions, Sandbox, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template, WasmModule, WhoAmI } from "../types.js";
|
|
3
3
|
type FetchLike = typeof fetch;
|
|
4
|
+
/**
|
|
5
|
+
* The request header that selects which of a credential's teams a request
|
|
6
|
+
* acts for. Keep in sync with pkg/api.HeaderTeam on the server.
|
|
7
|
+
*/
|
|
8
|
+
export declare const TEAM_HEADER = "X-AerolVM-Team";
|
|
4
9
|
/**
|
|
5
10
|
* Wire version of the v1-style sandbox endpoints (`/v1/sandboxes`, etc.) this
|
|
6
11
|
* client speaks. The SDK and the server version independently — bumping the
|
|
@@ -36,10 +41,24 @@ export interface RetryConfig {
|
|
|
36
41
|
* waits when `maxRetries` is high. Defaults to `5000`.
|
|
37
42
|
*/
|
|
38
43
|
maxDelayMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Cap in milliseconds on the wait before re-sending a 429 or 503. The SDK
|
|
46
|
+
* waits as long as the server's `Retry-After` header asks (seconds or an
|
|
47
|
+
* HTTP date), never less than its own backoff, plus up to a fifth more so
|
|
48
|
+
* clients told the same value spread out; a longer ask is cut to this. A
|
|
49
|
+
* missing or malformed header uses the backoff above. Defaults to
|
|
50
|
+
* `30000`; `0` ignores `Retry-After`.
|
|
51
|
+
*/
|
|
52
|
+
maxRetryAfterMs?: number;
|
|
39
53
|
}
|
|
40
54
|
export interface APIClientConfig {
|
|
41
55
|
baseURL: string;
|
|
42
56
|
patToken?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Sent as the {@link TEAM_HEADER} header on every HTTP request. Empty or
|
|
59
|
+
* absent sends no header.
|
|
60
|
+
*/
|
|
61
|
+
team?: string;
|
|
43
62
|
fetch?: FetchLike;
|
|
44
63
|
/**
|
|
45
64
|
* Wire version to call. Defaults to "v1". Pin this if you need a specific
|
|
@@ -49,11 +68,37 @@ export interface APIClientConfig {
|
|
|
49
68
|
apiVersion?: APIVersion;
|
|
50
69
|
/**
|
|
51
70
|
* Retry policy for transient transport errors (socket closed, connection
|
|
52
|
-
* reset) and retryable HTTP status codes (421, 429, 502, 503, 504).
|
|
53
|
-
*
|
|
71
|
+
* reset) and retryable HTTP status codes (421, 429, 502, 503, 504). `exec`
|
|
72
|
+
* is narrower: it retries only failures that prove the command never
|
|
73
|
+
* reached the server (connection refused, connect timeout, DNS) and HTTP
|
|
74
|
+
* 429/503. Pass `{ maxRetries: 0 }` to disable retry entirely.
|
|
54
75
|
*/
|
|
55
76
|
retry?: RetryConfig;
|
|
77
|
+
/**
|
|
78
|
+
* Cancels the client's HTTP requests: aborting it ends the request in
|
|
79
|
+
* flight and any retry wait, so a call never sits out a long
|
|
80
|
+
* `Retry-After` once aborted. WebSocket streams are not affected.
|
|
81
|
+
*/
|
|
82
|
+
signal?: AbortSignal;
|
|
56
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Reads a `Retry-After` value in either form RFC 9110 allows: delta-seconds
|
|
86
|
+
* (`"120"`) or an HTTP date. Returns the wait in milliseconds (0 for a date
|
|
87
|
+
* already past), or `undefined` for an absent or malformed value.
|
|
88
|
+
*/
|
|
89
|
+
export declare function parseRetryAfter(value: string | null | undefined, nowMs: number): number | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* How long {@link APIClient.request} waits before re-sending. `attempt`
|
|
92
|
+
* counts from 0, `retryAfter` is a 429/503's `Retry-After` header (null
|
|
93
|
+
* otherwise) and `random` is uniform in [0, 1).
|
|
94
|
+
*
|
|
95
|
+
* Without a usable header it is the exponential backoff ±25%. With one, it
|
|
96
|
+
* never retries sooner than the server asked (nor sooner than the backoff)
|
|
97
|
+
* and adds up to a fifth so clients told the same value spread out.
|
|
98
|
+
* `maxRetryAfterMs` is a hard ceiling: when the server asks for at least
|
|
99
|
+
* that long the client is early anyway, so the spread goes below it instead.
|
|
100
|
+
*/
|
|
101
|
+
export declare function retryDelayMs(config: Required<RetryConfig>, attempt: number, retryAfter: string | null, nowMs: number, random: number): number;
|
|
57
102
|
/**
|
|
58
103
|
* Trim trailing slashes from a URL without a backtracking regex. The natural
|
|
59
104
|
* `value.replace(/\/+$/, "")` is an anchored `+` quantifier — a polynomial
|
|
@@ -64,9 +109,11 @@ export declare class APIClient {
|
|
|
64
109
|
readonly baseURL: string;
|
|
65
110
|
readonly apiVersion: APIVersion;
|
|
66
111
|
private readonly patToken;
|
|
112
|
+
private readonly team;
|
|
67
113
|
private readonly fetchFn;
|
|
68
114
|
private readonly versionPrefix;
|
|
69
115
|
private readonly retryConfig;
|
|
116
|
+
private readonly signal;
|
|
70
117
|
constructor(config: APIClientConfig);
|
|
71
118
|
/**
|
|
72
119
|
* Build a versioned API path. Pass the suffix beginning with "/" (e.g.
|
|
@@ -106,6 +153,9 @@ export declare class APIClient {
|
|
|
106
153
|
createSnapshot(id: string, name: string): Promise<SandboxSnapshot>;
|
|
107
154
|
registerSnapshot(options: RegisterSnapshotOptions): Promise<SandboxSnapshot>;
|
|
108
155
|
registerSnapshotFromImage(name: string, image: Image, options?: Omit<RegisterSnapshotOptions, "name" | "image" | "dockerfileContent">): Promise<SandboxSnapshot>;
|
|
156
|
+
getSnapshot(name: string): Promise<SandboxSnapshot>;
|
|
157
|
+
listSnapshots(options?: ListSnapshotsOptions): Promise<SnapshotList>;
|
|
158
|
+
deleteSnapshot(name: string): Promise<void>;
|
|
109
159
|
destroy(id: string): Promise<void>;
|
|
110
160
|
resize(id: string, options: ResizeOptions): Promise<SandboxResource>;
|
|
111
161
|
updateLifecycle(id: string, lifecycle: Lifecycle): Promise<SandboxResource>;
|
|
@@ -181,19 +231,31 @@ export declare class APIClient {
|
|
|
181
231
|
deleteWasmModule(id: string): Promise<void>;
|
|
182
232
|
pushWasmModule(options: PushWasmModuleOptions): Promise<PushWasmModuleResult>;
|
|
183
233
|
rebuildTemplate(id: string): Promise<Template>;
|
|
234
|
+
whoami(): Promise<WhoAmI>;
|
|
235
|
+
createAPIToken(options: CreateAPITokenOptions): Promise<CreatedAPIToken>;
|
|
236
|
+
listAPITokens(): Promise<APITokenList>;
|
|
237
|
+
revokeAPIToken(id: string): Promise<void>;
|
|
184
238
|
private wrap;
|
|
185
239
|
private doJSON;
|
|
186
240
|
private doBytes;
|
|
187
241
|
/**
|
|
188
242
|
* Low-level HTTP request with automatic retry for transient failures.
|
|
189
243
|
*
|
|
190
|
-
* Transport-level errors (socket closed, connection reset, DNS
|
|
191
|
-
*
|
|
192
|
-
*
|
|
244
|
+
* Transport-level errors (socket closed, connection reset, timeouts, DNS
|
|
245
|
+
* blip) and HTTP 421/429/502/503/504 are retried regardless of HTTP method.
|
|
246
|
+
* Several of those failures can happen after the server received the
|
|
247
|
+
* request, so a retry may deliver it twice; that is safe only because every
|
|
248
|
+
* mutating endpoint in the daemon is designed for idempotent retry (e.g.
|
|
249
|
+
* INSERT OR IGNORE + disambiguation).
|
|
250
|
+
*
|
|
251
|
+
* A request passed `{ idempotent: false }` (exec) retries only transport
|
|
252
|
+
* failures that prove the request never left the client and HTTP 429/503;
|
|
253
|
+
* everything else is surfaced to the caller rather than risk running the
|
|
254
|
+
* command twice.
|
|
193
255
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
256
|
+
* A 429 or 503 waits as long as its `Retry-After` asks (see
|
|
257
|
+
* {@link retryDelayMs}). The client's `signal` ends the request and any
|
|
258
|
+
* wait.
|
|
197
259
|
*/
|
|
198
260
|
private request;
|
|
199
261
|
}
|
package/dist/internal/client.js
CHANGED
|
@@ -1,10 +1,40 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
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
|
+
/**
|
|
5
|
+
* The request header that selects which of a credential's teams a request
|
|
6
|
+
* acts for. Keep in sync with pkg/api.HeaderTeam on the server.
|
|
7
|
+
*/
|
|
8
|
+
export const TEAM_HEADER = "X-AerolVM-Team";
|
|
4
9
|
const DEFAULT_API_VERSION = "v1";
|
|
5
10
|
const PATH_PREFIXES = {
|
|
6
11
|
v1: V1_PATH_PREFIX,
|
|
7
12
|
};
|
|
13
|
+
function fromApiWhoAmI(w) {
|
|
14
|
+
const out = {
|
|
15
|
+
principal: { kind: w.principal.kind, id: w.principal.id },
|
|
16
|
+
role: w.role,
|
|
17
|
+
};
|
|
18
|
+
if (w.principal.name)
|
|
19
|
+
out.principal.name = w.principal.name;
|
|
20
|
+
if (w.team)
|
|
21
|
+
out.team = w.team;
|
|
22
|
+
if (w.expires_at)
|
|
23
|
+
out.expiresAt = w.expires_at;
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function fromApiAPIToken(t) {
|
|
27
|
+
return {
|
|
28
|
+
id: t.id,
|
|
29
|
+
name: t.name,
|
|
30
|
+
role: t.role,
|
|
31
|
+
team: t.team,
|
|
32
|
+
createdBy: t.created_by,
|
|
33
|
+
createdAt: t.created_at,
|
|
34
|
+
expiresAt: t.expires_at,
|
|
35
|
+
expired: t.expired === true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
8
38
|
function fromApiEgressProfile(p) {
|
|
9
39
|
const out = {
|
|
10
40
|
name: p.name,
|
|
@@ -29,17 +59,76 @@ function fromApiEgressRule({ inject, ...rule }) {
|
|
|
29
59
|
// The server closes the connection, so the retry reconnects and the ingress
|
|
30
60
|
// re-routes it (plans/ingress-proxy-routing.md, review 2A).
|
|
31
61
|
const RETRYABLE_STATUS_CODES = new Set([421, 429, 502, 503, 504]);
|
|
62
|
+
// The subset a non-idempotent request (exec) may retry: 429 and 503 are the
|
|
63
|
+
// server turning the request away before doing any work. 421, 502 and 504 come
|
|
64
|
+
// from forwarding/proxy hops, and the status alone can't prove that no hop
|
|
65
|
+
// already handed the command to the sandbox, so exec surfaces them. They are
|
|
66
|
+
// also the statuses whose Retry-After paces clients: 429 when this caller is
|
|
67
|
+
// over its share, 503 when the node sheds load. A Retry-After on a 421, 502 or
|
|
68
|
+
// 504 is a hop's, not the server's, so those use the normal backoff.
|
|
69
|
+
const REFUSED_STATUS_CODES = new Set([429, 503]);
|
|
32
70
|
/** Default retry settings when the caller doesn't supply a RetryConfig. */
|
|
33
71
|
const DEFAULT_RETRY = {
|
|
34
72
|
maxRetries: 3,
|
|
35
73
|
baseDelayMs: 200,
|
|
36
74
|
maxDelayMs: 5_000,
|
|
75
|
+
// An overloaded node asks for about a second, so the cap only bites on a
|
|
76
|
+
// long ask (maintenance, a misconfigured proxy), which should not hold a
|
|
77
|
+
// call for minutes per retry.
|
|
78
|
+
maxRetryAfterMs: 30_000,
|
|
37
79
|
};
|
|
80
|
+
// IMF-fixdate, the HTTP-date form servers send ("Sun, 06 Nov 1994 08:49:37
|
|
81
|
+
// GMT"). Date.parse alone would also take "1" or a date with no zone, read in
|
|
82
|
+
// local time.
|
|
83
|
+
const IMF_FIXDATE = /^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
|
|
84
|
+
/**
|
|
85
|
+
* Reads a `Retry-After` value in either form RFC 9110 allows: delta-seconds
|
|
86
|
+
* (`"120"`) or an HTTP date. Returns the wait in milliseconds (0 for a date
|
|
87
|
+
* already past), or `undefined` for an absent or malformed value.
|
|
88
|
+
*/
|
|
89
|
+
export function parseRetryAfter(value, nowMs) {
|
|
90
|
+
const trimmed = (value ?? "").trim();
|
|
91
|
+
if (/^[0-9]+$/.test(trimmed)) {
|
|
92
|
+
return Number(trimmed) * 1000;
|
|
93
|
+
}
|
|
94
|
+
if (!IMF_FIXDATE.test(trimmed)) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const when = Date.parse(trimmed);
|
|
98
|
+
return Number.isNaN(when) ? undefined : Math.max(when - nowMs, 0);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* How long {@link APIClient.request} waits before re-sending. `attempt`
|
|
102
|
+
* counts from 0, `retryAfter` is a 429/503's `Retry-After` header (null
|
|
103
|
+
* otherwise) and `random` is uniform in [0, 1).
|
|
104
|
+
*
|
|
105
|
+
* Without a usable header it is the exponential backoff ±25%. With one, it
|
|
106
|
+
* never retries sooner than the server asked (nor sooner than the backoff)
|
|
107
|
+
* and adds up to a fifth so clients told the same value spread out.
|
|
108
|
+
* `maxRetryAfterMs` is a hard ceiling: when the server asks for at least
|
|
109
|
+
* that long the client is early anyway, so the spread goes below it instead.
|
|
110
|
+
*/
|
|
111
|
+
export function retryDelayMs(config, attempt, retryAfter, nowMs, random) {
|
|
112
|
+
const backoff = Math.min(config.baseDelayMs * 2 ** attempt, config.maxDelayMs);
|
|
113
|
+
const limit = config.maxRetryAfterMs;
|
|
114
|
+
const hint = limit > 0 ? parseRetryAfter(retryAfter, nowMs) : undefined;
|
|
115
|
+
if (hint === undefined) {
|
|
116
|
+
return backoff * (0.75 + 0.5 * random);
|
|
117
|
+
}
|
|
118
|
+
const target = Math.max(hint, backoff);
|
|
119
|
+
if (target >= limit) {
|
|
120
|
+
return limit - limit * 0.2 * random;
|
|
121
|
+
}
|
|
122
|
+
const upper = Math.min(target * 1.2, limit);
|
|
123
|
+
return target + (upper - target) * random;
|
|
124
|
+
}
|
|
38
125
|
/**
|
|
39
126
|
* Returns `true` when the thrown `error` looks like a transport-level failure
|
|
40
127
|
* that vanishes on retry (connection reset, socket closed by peer, DNS
|
|
41
128
|
* blip). `undici` and Node core use several `cause.code` values; we check
|
|
42
|
-
* the full cause chain.
|
|
129
|
+
* the full cause chain. A reset, EPIPE, read/headers/body timeout or "other
|
|
130
|
+
* side closed" can fire after the server already received the request, so
|
|
131
|
+
* this classification is only safe for idempotent requests.
|
|
43
132
|
*/
|
|
44
133
|
function isTransientTransportError(error) {
|
|
45
134
|
const codes = new Set([
|
|
@@ -69,6 +158,45 @@ function isTransientTransportError(error) {
|
|
|
69
158
|
}
|
|
70
159
|
return false;
|
|
71
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Returns `true` only for transport failures that prove the request never
|
|
163
|
+
* left the client: the connection was refused, did not open before the
|
|
164
|
+
* connect timeout, or the host name did not resolve (transiently; ENOTFOUND
|
|
165
|
+
* is not retried for any request). Any other code in the cause chain means
|
|
166
|
+
* bytes may have reached the server, so the answer is `false`.
|
|
167
|
+
*/
|
|
168
|
+
function isUnsentTransportError(error) {
|
|
169
|
+
let current = error;
|
|
170
|
+
for (let depth = 0; depth < 5 && current instanceof Error; depth++) {
|
|
171
|
+
const err = current;
|
|
172
|
+
switch (err.code) {
|
|
173
|
+
case undefined:
|
|
174
|
+
break;
|
|
175
|
+
case "ECONNREFUSED":
|
|
176
|
+
case "EAI_AGAIN":
|
|
177
|
+
case "UND_ERR_CONNECT_TIMEOUT":
|
|
178
|
+
return true;
|
|
179
|
+
case "ETIMEDOUT":
|
|
180
|
+
// Node uses ETIMEDOUT both for a connect that never completed and for
|
|
181
|
+
// an open socket that stopped answering; only the first is unsent.
|
|
182
|
+
return failedWhileConnecting(err);
|
|
183
|
+
default:
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
current = err.cause;
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
function failedWhileConnecting(err) {
|
|
191
|
+
if (err.syscall === "connect")
|
|
192
|
+
return true;
|
|
193
|
+
// With autoSelectFamily (Node's default), a host with several addresses
|
|
194
|
+
// fails with an AggregateError holding one error per connect attempt and no
|
|
195
|
+
// syscall of its own.
|
|
196
|
+
return (err instanceof AggregateError &&
|
|
197
|
+
err.errors.length > 0 &&
|
|
198
|
+
err.errors.every((inner) => inner?.syscall === "connect"));
|
|
199
|
+
}
|
|
72
200
|
/**
|
|
73
201
|
* Trim trailing slashes from a URL without a backtracking regex. The natural
|
|
74
202
|
* `value.replace(/\/+$/, "")` is an anchored `+` quantifier — a polynomial
|
|
@@ -81,21 +209,37 @@ export function stripTrailingSlashes(value) {
|
|
|
81
209
|
}
|
|
82
210
|
return value.slice(0, end);
|
|
83
211
|
}
|
|
84
|
-
/**
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
|
|
212
|
+
/** Waits `ms`, or rejects with the signal's reason as soon as it aborts. */
|
|
213
|
+
function sleep(ms, signal) {
|
|
214
|
+
return new Promise((resolve, reject) => {
|
|
215
|
+
if (signal?.aborted) {
|
|
216
|
+
reject(signal.reason);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const onAbort = () => {
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
reject(signal?.reason);
|
|
222
|
+
};
|
|
223
|
+
const timer = setTimeout(() => {
|
|
224
|
+
signal?.removeEventListener("abort", onAbort);
|
|
225
|
+
resolve();
|
|
226
|
+
}, ms);
|
|
227
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
228
|
+
});
|
|
88
229
|
}
|
|
89
230
|
export class APIClient {
|
|
90
231
|
baseURL;
|
|
91
232
|
apiVersion;
|
|
92
233
|
patToken;
|
|
234
|
+
team;
|
|
93
235
|
fetchFn;
|
|
94
236
|
versionPrefix;
|
|
95
237
|
retryConfig;
|
|
238
|
+
signal;
|
|
96
239
|
constructor(config) {
|
|
97
240
|
this.baseURL = stripTrailingSlashes(config.baseURL);
|
|
98
241
|
this.patToken = config.patToken ?? "";
|
|
242
|
+
this.team = (config.team ?? "").trim();
|
|
99
243
|
this.fetchFn = config.fetch ?? fetch;
|
|
100
244
|
this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION;
|
|
101
245
|
this.versionPrefix = PATH_PREFIXES[this.apiVersion];
|
|
@@ -103,7 +247,9 @@ export class APIClient {
|
|
|
103
247
|
maxRetries: config.retry?.maxRetries ?? DEFAULT_RETRY.maxRetries,
|
|
104
248
|
baseDelayMs: config.retry?.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
|
|
105
249
|
maxDelayMs: config.retry?.maxDelayMs ?? DEFAULT_RETRY.maxDelayMs,
|
|
250
|
+
maxRetryAfterMs: config.retry?.maxRetryAfterMs ?? DEFAULT_RETRY.maxRetryAfterMs,
|
|
106
251
|
};
|
|
252
|
+
this.signal = config.signal;
|
|
107
253
|
}
|
|
108
254
|
/**
|
|
109
255
|
* Build a versioned API path. Pass the suffix beginning with "/" (e.g.
|
|
@@ -272,6 +418,25 @@ export class APIClient {
|
|
|
272
418
|
dockerfileContent: image.dockerfile,
|
|
273
419
|
});
|
|
274
420
|
}
|
|
421
|
+
async getSnapshot(name) {
|
|
422
|
+
return fromApiSandboxSnapshot(await this.doJSON("GET", this.versioned(`/snapshots/${encodeURIComponent(name)}`)));
|
|
423
|
+
}
|
|
424
|
+
async listSnapshots(options = {}) {
|
|
425
|
+
const params = new URLSearchParams();
|
|
426
|
+
if (options.limit)
|
|
427
|
+
params.set("limit", String(options.limit));
|
|
428
|
+
if (options.startingAfter)
|
|
429
|
+
params.set("starting_after", options.startingAfter);
|
|
430
|
+
const query = params.toString();
|
|
431
|
+
const response = await this.doJSON("GET", this.versioned(`/snapshots${query ? `?${query}` : ""}`));
|
|
432
|
+
return {
|
|
433
|
+
snapshots: (response.snapshots ?? []).map(fromApiSandboxSnapshot),
|
|
434
|
+
hasMore: response.has_more ?? false,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
async deleteSnapshot(name) {
|
|
438
|
+
await this.doJSON("DELETE", this.versioned(`/snapshots/${encodeURIComponent(name)}`));
|
|
439
|
+
}
|
|
275
440
|
async destroy(id) {
|
|
276
441
|
await this.doJSON("DELETE", `${this.versionPrefix}/sandboxes/${id}`);
|
|
277
442
|
}
|
|
@@ -284,7 +449,8 @@ export class APIClient {
|
|
|
284
449
|
return this.wrap(response);
|
|
285
450
|
}
|
|
286
451
|
async exec(id, request) {
|
|
287
|
-
|
|
452
|
+
// Not idempotent: a re-sent exec runs the caller's command a second time.
|
|
453
|
+
const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/toolbox/process/execute`, toApiExecRequest(request), { idempotent: false });
|
|
288
454
|
return fromApiExecResult(response);
|
|
289
455
|
}
|
|
290
456
|
execStream(id, options) {
|
|
@@ -571,10 +737,33 @@ export class APIClient {
|
|
|
571
737
|
const response = await this.doJSON("POST", `${this.versionPrefix}/templates/${id}/rebuild`);
|
|
572
738
|
return fromApiTemplate(response);
|
|
573
739
|
}
|
|
740
|
+
async whoami() {
|
|
741
|
+
return fromApiWhoAmI(await this.doJSON("GET", this.versioned("/whoami")));
|
|
742
|
+
}
|
|
743
|
+
async createAPIToken(options) {
|
|
744
|
+
const response = await this.doJSON("POST", this.versioned("/tokens"), {
|
|
745
|
+
name: options.name,
|
|
746
|
+
role: options.role,
|
|
747
|
+
team: options.team,
|
|
748
|
+
expires_in: options.expiresIn,
|
|
749
|
+
});
|
|
750
|
+
return { ...fromApiAPIToken(response), token: response.token };
|
|
751
|
+
}
|
|
752
|
+
async listAPITokens() {
|
|
753
|
+
const response = await this.doJSON("GET", this.versioned("/tokens"));
|
|
754
|
+
return { tokens: (response.tokens ?? []).map(fromApiAPIToken) };
|
|
755
|
+
}
|
|
756
|
+
async revokeAPIToken(id) {
|
|
757
|
+
const trimmed = id.trim();
|
|
758
|
+
if (trimmed === "") {
|
|
759
|
+
throw new Error("token id is required");
|
|
760
|
+
}
|
|
761
|
+
await this.doJSON("DELETE", this.versioned(`/tokens/${encodeURIComponent(trimmed)}`));
|
|
762
|
+
}
|
|
574
763
|
wrap(sandbox) {
|
|
575
764
|
return new SandboxResource(this, fromApiSandbox(sandbox));
|
|
576
765
|
}
|
|
577
|
-
async doJSON(method, path, body) {
|
|
766
|
+
async doJSON(method, path, body, options) {
|
|
578
767
|
const init = {};
|
|
579
768
|
if (body !== undefined) {
|
|
580
769
|
init.body = JSON.stringify(body);
|
|
@@ -582,7 +771,7 @@ export class APIClient {
|
|
|
582
771
|
"Content-Type": "application/json",
|
|
583
772
|
};
|
|
584
773
|
}
|
|
585
|
-
const response = await this.request(method, path, init);
|
|
774
|
+
const response = await this.request(method, path, init, options);
|
|
586
775
|
if (!response.ok) {
|
|
587
776
|
throw await decodeError(response);
|
|
588
777
|
}
|
|
@@ -601,46 +790,59 @@ export class APIClient {
|
|
|
601
790
|
/**
|
|
602
791
|
* Low-level HTTP request with automatic retry for transient failures.
|
|
603
792
|
*
|
|
604
|
-
* Transport-level errors (socket closed, connection reset, DNS
|
|
605
|
-
*
|
|
606
|
-
*
|
|
793
|
+
* Transport-level errors (socket closed, connection reset, timeouts, DNS
|
|
794
|
+
* blip) and HTTP 421/429/502/503/504 are retried regardless of HTTP method.
|
|
795
|
+
* Several of those failures can happen after the server received the
|
|
796
|
+
* request, so a retry may deliver it twice; that is safe only because every
|
|
797
|
+
* mutating endpoint in the daemon is designed for idempotent retry (e.g.
|
|
798
|
+
* INSERT OR IGNORE + disambiguation).
|
|
607
799
|
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
800
|
+
* A request passed `{ idempotent: false }` (exec) retries only transport
|
|
801
|
+
* failures that prove the request never left the client and HTTP 429/503;
|
|
802
|
+
* everything else is surfaced to the caller rather than risk running the
|
|
803
|
+
* command twice.
|
|
804
|
+
*
|
|
805
|
+
* A 429 or 503 waits as long as its `Retry-After` asks (see
|
|
806
|
+
* {@link retryDelayMs}). The client's `signal` ends the request and any
|
|
807
|
+
* wait.
|
|
611
808
|
*/
|
|
612
|
-
async request(method, path, init = {}) {
|
|
809
|
+
async request(method, path, init = {}, options = {}) {
|
|
613
810
|
const headers = new Headers(init.headers);
|
|
614
811
|
if (this.patToken !== "") {
|
|
615
812
|
headers.set("Authorization", `Bearer ${this.patToken}`);
|
|
616
813
|
}
|
|
814
|
+
if (this.team !== "") {
|
|
815
|
+
headers.set(TEAM_HEADER, this.team);
|
|
816
|
+
}
|
|
617
817
|
const url = `${this.baseURL}${path}`;
|
|
618
|
-
const
|
|
619
|
-
const {
|
|
620
|
-
|
|
621
|
-
|
|
818
|
+
const signal = init.signal ?? this.signal;
|
|
819
|
+
const requestInit = { ...init, method, headers, ...(signal ? { signal } : {}) };
|
|
820
|
+
const { maxRetries } = this.retryConfig;
|
|
821
|
+
const idempotent = options.idempotent ?? true;
|
|
822
|
+
const retryableStatusCodes = idempotent ? RETRYABLE_STATUS_CODES : REFUSED_STATUS_CODES;
|
|
823
|
+
const isRetryableError = idempotent ? isTransientTransportError : isUnsentTransportError;
|
|
824
|
+
for (let attempt = 0;; attempt++) {
|
|
825
|
+
let retryAfter = null;
|
|
622
826
|
try {
|
|
623
827
|
const response = await this.fetchFn(url, requestInit);
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
828
|
+
if (!retryableStatusCodes.has(response.status) || attempt >= maxRetries) {
|
|
829
|
+
return response;
|
|
830
|
+
}
|
|
831
|
+
if (REFUSED_STATUS_CODES.has(response.status)) {
|
|
832
|
+
retryAfter = response.headers.get("retry-after");
|
|
629
833
|
}
|
|
630
|
-
|
|
834
|
+
// Free the connection instead of holding it through the wait.
|
|
835
|
+
response.body?.cancel().catch(() => undefined);
|
|
631
836
|
}
|
|
632
837
|
catch (error) {
|
|
633
|
-
|
|
634
|
-
//
|
|
635
|
-
if (!
|
|
838
|
+
// Only retry transport-level failures; anything else propagates. An
|
|
839
|
+
// aborted signal surfaces whatever fetch threw for it.
|
|
840
|
+
if (signal?.aborted || !isRetryableError(error) || attempt >= maxRetries) {
|
|
636
841
|
throw error;
|
|
637
842
|
}
|
|
638
|
-
const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
639
|
-
await jitteredDelay(delay);
|
|
640
843
|
}
|
|
844
|
+
await sleep(retryDelayMs(this.retryConfig, attempt, retryAfter, Date.now(), Math.random()), signal);
|
|
641
845
|
}
|
|
642
|
-
// Should be unreachable — the loop always throws or returns.
|
|
643
|
-
throw lastError;
|
|
644
846
|
}
|
|
645
847
|
}
|
|
646
848
|
export class SandboxResource {
|
|
@@ -826,6 +1028,15 @@ function toApiCreateOptions(options) {
|
|
|
826
1028
|
module_ref: options.moduleRef,
|
|
827
1029
|
tenant_id: options.tenantId,
|
|
828
1030
|
custom_domains: options.customDomains,
|
|
1031
|
+
gpus: options.gpus ? toApiGPUOptions(options.gpus) : undefined,
|
|
1032
|
+
template_id: options.templateId,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
function toApiGPUOptions(gpus) {
|
|
1036
|
+
return {
|
|
1037
|
+
vendor: gpus.vendor,
|
|
1038
|
+
count: gpus.count,
|
|
1039
|
+
device_ids: gpus.deviceIDs,
|
|
829
1040
|
};
|
|
830
1041
|
}
|
|
831
1042
|
function toApiRegisterSnapshotOptions(options) {
|
package/dist/types.d.ts
CHANGED
|
@@ -194,9 +194,10 @@ export interface CreateOptions {
|
|
|
194
194
|
*/
|
|
195
195
|
networkEgressRules?: EgressRule[];
|
|
196
196
|
/**
|
|
197
|
-
* Whether the sandbox
|
|
198
|
-
*
|
|
199
|
-
*
|
|
197
|
+
* Whether the sandbox is public from boot. Omitted or `false` creates it
|
|
198
|
+
* private: no public URL and no ingress route. `true` makes it public from
|
|
199
|
+
* boot. Either way the first `exposePort` call makes the sandbox public, so
|
|
200
|
+
* `false` does not stop a later `exposePort`.
|
|
200
201
|
*/
|
|
201
202
|
allowPublicTraffic?: boolean;
|
|
202
203
|
/**
|
|
@@ -269,6 +270,13 @@ export interface CreateOptions {
|
|
|
269
270
|
* `sandbox.customDomains.add(host)` for each entry after create.
|
|
270
271
|
*/
|
|
271
272
|
customDomains?: string[];
|
|
273
|
+
/**
|
|
274
|
+
* Firecracker template id (from `createTemplate`); the template must be
|
|
275
|
+
* ready. Requires `runtime: "firecracker"` and `image`: pass the image the
|
|
276
|
+
* template was built from. The sandbox boots from the template's prepared
|
|
277
|
+
* rootfs instead of building one from `image` on every create.
|
|
278
|
+
*/
|
|
279
|
+
templateId?: string;
|
|
272
280
|
}
|
|
273
281
|
export interface ResizeOptions {
|
|
274
282
|
/** Number of CPU cores. Fractional values supported (e.g. 0.5). */
|
|
@@ -445,6 +453,19 @@ export interface SandboxSnapshot {
|
|
|
445
453
|
memoryMB?: number;
|
|
446
454
|
diskGB?: number;
|
|
447
455
|
}
|
|
456
|
+
/** Options for {@link MicroVM.listSnapshots}: one name-ordered page. */
|
|
457
|
+
export interface ListSnapshotsOptions {
|
|
458
|
+
/** Maximum snapshots to return in this page. */
|
|
459
|
+
limit?: number;
|
|
460
|
+
/** Exclusive name cursor — pass the last name of the previous page. */
|
|
461
|
+
startingAfter?: string;
|
|
462
|
+
}
|
|
463
|
+
/** One page of snapshots from {@link MicroVM.listSnapshots}. */
|
|
464
|
+
export interface SnapshotList {
|
|
465
|
+
snapshots: SandboxSnapshot[];
|
|
466
|
+
/** True when more snapshots remain after this page. */
|
|
467
|
+
hasMore: boolean;
|
|
468
|
+
}
|
|
448
469
|
/**
|
|
449
470
|
* Discriminated result from `exposePort`. Branch on `protocol` to access the
|
|
450
471
|
* fields that are meaningful for the chosen wire protocol — only the raw-TCP
|
|
@@ -482,8 +503,9 @@ export interface Sandbox {
|
|
|
482
503
|
networkBlockAll: boolean;
|
|
483
504
|
/**
|
|
484
505
|
* Hostname-egress state on `get` for a sandbox whose allow list names
|
|
485
|
-
* hosts (container runtimes): "active", "held" or "unavailable"
|
|
486
|
-
*
|
|
506
|
+
* hosts (container runtimes): "active", "held" or "unavailable";
|
|
507
|
+
* "unenforced" when the server's firewall is off and nothing filters the
|
|
508
|
+
* sandbox. Absent otherwise, and on list results.
|
|
487
509
|
*/
|
|
488
510
|
egressStatus?: string;
|
|
489
511
|
/** Egress profiles this sandbox references. */
|
|
@@ -629,7 +651,10 @@ export interface NetworkPolicy {
|
|
|
629
651
|
networkEgressRules: EgressRule[];
|
|
630
652
|
/** Hostname entries in force: inline plus every profile's (at most 1024). */
|
|
631
653
|
effectiveHostnameCount: number;
|
|
632
|
-
/**
|
|
654
|
+
/**
|
|
655
|
+
* "active", "held" or "unavailable" for hostname rules on a container;
|
|
656
|
+
* "unenforced" when the server's firewall is off.
|
|
657
|
+
*/
|
|
633
658
|
egressStatus?: string;
|
|
634
659
|
}
|
|
635
660
|
/**
|
|
@@ -722,6 +747,59 @@ export interface EgressProfileList {
|
|
|
722
747
|
/** Pass back as `cursor` for the next page; absent on the last one. */
|
|
723
748
|
nextCursor?: string;
|
|
724
749
|
}
|
|
750
|
+
/** Who the caller is, as `microvm.whoami()` reports it. */
|
|
751
|
+
export interface WhoAmIPrincipal {
|
|
752
|
+
/** `"operator"`, `"token"`, `"oidc"`, `"controlplane"`, ... */
|
|
753
|
+
kind: string;
|
|
754
|
+
/** Stable id: `"operator"`, `"tok_…"`, `"oidc:<issuer>:<sub>"`, ... */
|
|
755
|
+
id: string;
|
|
756
|
+
/** Display name; absent when the server has none. */
|
|
757
|
+
name?: string;
|
|
758
|
+
}
|
|
759
|
+
/** The body of `microvm.whoami()`: the caller's own resolved identity. */
|
|
760
|
+
export interface WhoAmI {
|
|
761
|
+
principal: WhoAmIPrincipal;
|
|
762
|
+
/** `"viewer"`, `"runner"`, `"auditor"` or `"admin"`. */
|
|
763
|
+
role: string;
|
|
764
|
+
/** `"*"` for every team, a team name, or absent for a managed account. */
|
|
765
|
+
team?: string;
|
|
766
|
+
/** When the credential stops working (RFC 3339); absent if it never does. */
|
|
767
|
+
expiresAt?: string;
|
|
768
|
+
}
|
|
769
|
+
/** One scoped API token as `microvm.tokens.list()` returns it. Never the secret. */
|
|
770
|
+
export interface APIToken {
|
|
771
|
+
/** Public id (`tok_…`): what `tokens.revoke` takes and audit records name. */
|
|
772
|
+
id: string;
|
|
773
|
+
name: string;
|
|
774
|
+
role: string;
|
|
775
|
+
/** A team name, or `"*"` for every team. */
|
|
776
|
+
team: string;
|
|
777
|
+
createdBy: string;
|
|
778
|
+
createdAt: string;
|
|
779
|
+
expiresAt: string;
|
|
780
|
+
/** Expired tokens no longer work; they stay listed for 30 days. */
|
|
781
|
+
expired: boolean;
|
|
782
|
+
}
|
|
783
|
+
/** `microvm.tokens.create()`'s result: the token plus its secret. */
|
|
784
|
+
export interface CreatedAPIToken extends APIToken {
|
|
785
|
+
/** The secret (`avm_…`), returned this once and never stored by the server. */
|
|
786
|
+
token: string;
|
|
787
|
+
}
|
|
788
|
+
/** The body of `microvm.tokens.create()`. */
|
|
789
|
+
export interface CreateAPITokenOptions {
|
|
790
|
+
/** 1 to 64 of `A-Z a-z 0-9 . _ -`, unique among the team's tokens. */
|
|
791
|
+
name: string;
|
|
792
|
+
/** `"viewer"`, `"runner"`, `"auditor"` or `"admin"`. */
|
|
793
|
+
role: string;
|
|
794
|
+
/** A team name, or `"*"` for every team. `auditor` and `admin` require `"*"`. */
|
|
795
|
+
team: string;
|
|
796
|
+
/** Required lifetime: `"90d"`, `"720h"` or any Go duration. */
|
|
797
|
+
expiresIn: string;
|
|
798
|
+
}
|
|
799
|
+
/** `microvm.tokens.list()`'s result. */
|
|
800
|
+
export interface APITokenList {
|
|
801
|
+
tokens: APIToken[];
|
|
802
|
+
}
|
|
725
803
|
/** One destination a learn-mode sandbox reached. */
|
|
726
804
|
export interface NetworkLearnedEntry {
|
|
727
805
|
host: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aerol-ai/aerolvm-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"test": "tsc -p tsconfig.test.json --outDir dist-test && node --test dist-test/*.test.js dist-test/internal/*.test.js"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@types/node": "^26.6.
|
|
29
|
+
"@types/node": "^26.6.6",
|
|
30
30
|
"typescript": "^7.0.2",
|
|
31
31
|
"tsx": "^4.23.15"
|
|
32
32
|
}
|