xapi-to 0.1.18 → 0.1.20

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.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * xAPI Sandbox Gateway client.
3
+ *
4
+ * State-changing calls are never retried blindly. A lost POST response may have
5
+ * created, executed, or terminated a real billable instance. Read-only calls and
6
+ * quotes opt into the shared client's conservative transient retry policy.
7
+ */
8
+
9
+ import { assertAllowedHost, isLoopbackHost, scheme } from './config.ts';
10
+ import { request } from './client.ts';
11
+
12
+ const READ_RETRIES = 2;
13
+ const READ_TIMEOUT_MS = 30_000;
14
+ const MUTATION_TIMEOUT_MS = 180_000;
15
+
16
+ export interface SandboxClientOptions {
17
+ sandboxHost: string;
18
+ apiKey: string;
19
+ provider?: string;
20
+ }
21
+
22
+ export interface SandboxRequirements {
23
+ cpu?: { min?: number; max?: number };
24
+ memoryGiB?: { min?: number; max?: number };
25
+ volumeGiB?: { min?: number; max?: number };
26
+ gpu?: { count?: number; model?: string };
27
+ regions?: string[];
28
+ capabilities?: string[];
29
+ [key: string]: unknown;
30
+ }
31
+
32
+ export interface SandboxDetail {
33
+ id: string;
34
+ observedState?: string;
35
+ desiredState?: string | null;
36
+ totalCost?: string | number;
37
+ offeringId?: string;
38
+ providerInstanceId?: string;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ export interface SandboxCommandResult {
43
+ exitCode?: number;
44
+ stdout?: string;
45
+ stderr?: string;
46
+ background?: {
47
+ sessionId: string;
48
+ commandId: string;
49
+ };
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ const PRODUCTION_PROVIDER_HOSTS: Readonly<Record<string, string>> = {
54
+ daytona: 'daytona-sandbox',
55
+ e2b: 'e2b-sandbox',
56
+ };
57
+
58
+ function parsedHost(raw: string): URL {
59
+ const value = raw.trim();
60
+ if (!value) throw new Error('sandbox host is empty');
61
+ const url = new URL(value.includes('://') ? value : `${scheme(value)}://${value}`);
62
+ if (url.username || url.password) throw new Error('sandbox host must not contain credentials');
63
+ if (url.pathname !== '/' || url.search || url.hash) {
64
+ throw new Error('sandbox host must not contain a path, query, or fragment');
65
+ }
66
+ const loopback = isLoopbackHost(url.toString());
67
+ if (loopback && url.protocol !== 'http:' && url.protocol !== 'https:') {
68
+ throw new Error('localhost sandbox hosts must use HTTP or HTTPS');
69
+ }
70
+ if (!loopback && url.protocol !== 'https:') {
71
+ throw new Error('public sandbox hosts must use HTTPS');
72
+ }
73
+ return url;
74
+ }
75
+
76
+ /** Resolve auto-routing or a provider-pinned Sandbox Gateway URL. */
77
+ export function sandboxBaseUrl(host: string, provider?: string): string {
78
+ const url = parsedHost(host);
79
+ const pin = provider && provider !== 'auto' ? provider : undefined;
80
+ if (pin) {
81
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(pin)) {
82
+ throw new Error(`invalid sandbox provider: ${pin}`);
83
+ }
84
+ if (isLoopbackHost(url.toString())) {
85
+ throw new Error('provider pinning is unavailable for a localhost sandbox gateway');
86
+ }
87
+ const labels = url.hostname.split('.');
88
+ const sandboxIndex = labels.indexOf('sandbox');
89
+ const isProduction =
90
+ sandboxIndex >= 0 &&
91
+ labels.slice(sandboxIndex).join('.') === 'sandbox.xapi.to';
92
+ const gatewayLabel = isProduction
93
+ ? PRODUCTION_PROVIDER_HOSTS[pin] || pin
94
+ : pin;
95
+ if (sandboxIndex === 0) labels.unshift(gatewayLabel);
96
+ else if (sandboxIndex === 1) labels[0] = gatewayLabel;
97
+ else throw new Error('provider pinning requires a sandbox.<xapi-domain> host');
98
+ url.hostname = labels.join('.');
99
+ }
100
+
101
+ // Sandbox credentials follow the stricter public contract: only *.xapi.to
102
+ // (plus loopback for local development), even though legacy action commands
103
+ // also recognize xapi.xyz.
104
+ assertAllowedHost(url.toString());
105
+ const hostname = url.hostname.toLowerCase();
106
+ if (!isLoopbackHost(url.toString()) && hostname !== 'xapi.to' && !hostname.endsWith('.xapi.to')) {
107
+ throw new Error('sandbox API keys may only be sent to *.xapi.to or localhost');
108
+ }
109
+ return url.toString().replace(/\/$/, '');
110
+ }
111
+
112
+ function headers(apiKey: string, body: boolean): Record<string, string> {
113
+ return {
114
+ Accept: 'application/json',
115
+ 'XAPI-Key': apiKey,
116
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
117
+ };
118
+ }
119
+
120
+ async function sandboxRequest<T>(
121
+ opts: SandboxClientOptions,
122
+ path: string,
123
+ init: { method?: string; body?: unknown; timeoutMs?: number; readOnly?: boolean; signal?: AbortSignal } = {},
124
+ ): Promise<T> {
125
+ const method = init.method || 'GET';
126
+ const hasBody = init.body !== undefined;
127
+ return request<T>(
128
+ `${sandboxBaseUrl(opts.sandboxHost, opts.provider)}${path}`,
129
+ {
130
+ method,
131
+ headers: headers(opts.apiKey, hasBody),
132
+ ...(hasBody ? { body: JSON.stringify(init.body) } : {}),
133
+ ...(init.signal ? { signal: init.signal } : {}),
134
+ },
135
+ init.timeoutMs || (init.readOnly || method === 'GET' ? READ_TIMEOUT_MS : MUTATION_TIMEOUT_MS),
136
+ init.readOnly || method === 'GET' ? READ_RETRIES : 0,
137
+ );
138
+ }
139
+
140
+ export const sandboxOfferings = (opts: SandboxClientOptions) =>
141
+ sandboxRequest<unknown[]>(opts, '/v1/offerings');
142
+
143
+ export const sandboxQuote = (
144
+ opts: SandboxClientOptions,
145
+ body: Record<string, unknown>,
146
+ signal?: AbortSignal,
147
+ ) => sandboxRequest<any>(opts, '/v1/quotes', { method: 'POST', body, readOnly: true, signal });
148
+
149
+ export const sandboxList = (opts: SandboxClientOptions) =>
150
+ sandboxRequest<SandboxDetail[] | { items?: SandboxDetail[]; data?: SandboxDetail[] }>(opts, '/v1/sandboxes');
151
+
152
+ export const sandboxHistory = (
153
+ opts: SandboxClientOptions,
154
+ filters: {
155
+ state?: string;
156
+ search?: string;
157
+ from?: string;
158
+ to?: string;
159
+ page?: number;
160
+ pageSize?: number;
161
+ } = {},
162
+ ) => {
163
+ const query = new URLSearchParams();
164
+ for (const [key, value] of Object.entries(filters)) {
165
+ if (value !== undefined && value !== '') query.set(key, String(value));
166
+ }
167
+ return sandboxRequest<any>(opts, `/v1/sandbox-history${query.size ? `?${query}` : ''}`);
168
+ };
169
+
170
+ export const sandboxGet = (opts: SandboxClientOptions, id: string, signal?: AbortSignal) =>
171
+ sandboxRequest<SandboxDetail>(opts, `/v1/sandboxes/${encodeURIComponent(id)}`, { signal });
172
+
173
+ export const sandboxCreate = (opts: SandboxClientOptions, body: Record<string, unknown>) =>
174
+ sandboxRequest<SandboxDetail>(opts, '/v1/sandboxes', { method: 'POST', body });
175
+
176
+ export const sandboxExec = (
177
+ opts: SandboxClientOptions,
178
+ id: string,
179
+ body: {
180
+ command: string;
181
+ timeoutSeconds?: number;
182
+ cwd?: string;
183
+ background?: boolean;
184
+ },
185
+ signal?: AbortSignal,
186
+ ) => sandboxRequest<SandboxCommandResult>(
187
+ opts,
188
+ `/v1/sandboxes/${encodeURIComponent(id)}/commands`,
189
+ {
190
+ method: 'POST', body, signal,
191
+ timeoutMs: Math.max(MUTATION_TIMEOUT_MS, (body.timeoutSeconds || 60) * 1_000 + 30_000),
192
+ },
193
+ );
194
+
195
+ export const sandboxFileWrite = (
196
+ opts: SandboxClientOptions,
197
+ id: string,
198
+ body: { path: string; content: string; encoding: 'utf8' | 'base64' },
199
+ ) => sandboxRequest<any>(opts, `/v1/sandboxes/${encodeURIComponent(id)}/files`, { method: 'POST', body });
200
+
201
+ export const sandboxFileRead = (
202
+ opts: SandboxClientOptions,
203
+ id: string,
204
+ path: string,
205
+ encoding: 'utf8' | 'base64' = 'utf8',
206
+ ) => sandboxRequest<any>(
207
+ opts,
208
+ `/v1/sandboxes/${encodeURIComponent(id)}/files?path=${encodeURIComponent(path)}&encoding=${encoding}`,
209
+ );
210
+
211
+ export const sandboxFileList = (
212
+ opts: SandboxClientOptions,
213
+ id: string,
214
+ path = '.',
215
+ depth = 2,
216
+ ) => sandboxRequest<any>(
217
+ opts,
218
+ `/v1/sandboxes/${encodeURIComponent(id)}/files/list?path=${encodeURIComponent(path)}&depth=${depth}`,
219
+ );
220
+
221
+ export const sandboxPort = (opts: SandboxClientOptions, id: string, port: number) =>
222
+ sandboxRequest<any>(opts, `/v1/sandboxes/${encodeURIComponent(id)}/ports/${port}`);
223
+
224
+ export const sandboxExtension = (
225
+ opts: SandboxClientOptions,
226
+ id: string,
227
+ extensionId: string,
228
+ body: { input: Record<string, unknown>; idempotencyKey?: string },
229
+ ) => sandboxRequest<any>(
230
+ opts,
231
+ `/v1/sandboxes/${encodeURIComponent(id)}/extensions/${encodeURIComponent(extensionId)}`,
232
+ { method: 'POST', body },
233
+ );
234
+
235
+ export const sandboxStateAction = (
236
+ opts: SandboxClientOptions,
237
+ id: string,
238
+ action: 'suspend' | 'resume' | 'terminate',
239
+ body: Record<string, unknown> = {},
240
+ ) => sandboxRequest<any>(
241
+ opts,
242
+ `/v1/sandboxes/${encodeURIComponent(id)}/${action}`,
243
+ { method: 'POST', body },
244
+ );
245
+
246
+ export const sandboxAudit = (
247
+ opts: SandboxClientOptions,
248
+ id: string,
249
+ kind: string,
250
+ page = 1,
251
+ pageSize = 100,
252
+ ) => sandboxRequest<any>(
253
+ opts,
254
+ `/v1/sandboxes/${encodeURIComponent(id)}/audit?kind=${encodeURIComponent(kind)}&page=${page}&pageSize=${pageSize}`,
255
+ );
256
+
257
+ export async function sandboxWait(
258
+ opts: SandboxClientOptions,
259
+ id: string,
260
+ wanted: string[],
261
+ timeoutMs = 300_000,
262
+ intervalMs = 2_000,
263
+ signal?: AbortSignal,
264
+ ): Promise<SandboxDetail> {
265
+ const deadline = Date.now() + timeoutMs;
266
+ let last: SandboxDetail | undefined;
267
+ while (Date.now() < deadline) {
268
+ if (signal?.aborted) throw new Error(`sandbox wait interrupted while waiting for ${wanted.join(' or ')}`);
269
+ last = await sandboxGet(opts, id, signal);
270
+ const state = String(last.observedState || '');
271
+ if (wanted.includes(state)) return last;
272
+ if (['FAILED', 'TERMINATED'].includes(state) && !wanted.includes(state)) {
273
+ throw new Error(`sandbox ${id} entered ${state} while waiting for ${wanted.join(' or ')}`);
274
+ }
275
+ await new Promise<void>((resolve) => {
276
+ const done = () => {
277
+ clearTimeout(timer);
278
+ signal?.removeEventListener('abort', done);
279
+ resolve();
280
+ };
281
+ const timer = setTimeout(done, Math.min(intervalMs, Math.max(0, deadline - Date.now())));
282
+ signal?.addEventListener('abort', done, { once: true });
283
+ });
284
+ }
285
+ throw new Error(
286
+ `sandbox ${id} did not enter ${wanted.join(' or ')} within ${timeoutMs}ms` +
287
+ ` (last state: ${last?.observedState || 'unknown'})`,
288
+ );
289
+ }