@prized/sdk 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +625 -2
- package/dist/cjs/commands.d.ts +168 -0
- package/dist/cjs/commands.js +567 -0
- package/dist/cjs/errors.d.ts +73 -0
- package/dist/cjs/errors.js +258 -0
- package/dist/cjs/files.d.ts +86 -0
- package/dist/cjs/files.js +366 -0
- package/dist/cjs/http.d.ts +210 -0
- package/dist/cjs/http.js +570 -0
- package/dist/cjs/index.d.ts +12 -0
- package/dist/cjs/index.js +22 -0
- package/dist/cjs/outcome.d.ts +75 -0
- package/dist/cjs/outcome.js +167 -0
- package/dist/cjs/output.d.ts +59 -0
- package/dist/cjs/output.js +288 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/sandbox.d.ts +218 -0
- package/dist/cjs/sandbox.js +810 -0
- package/dist/cjs/shell.d.ts +152 -0
- package/dist/cjs/shell.js +393 -0
- package/dist/cjs/toolbox.d.ts +48 -0
- package/dist/cjs/toolbox.js +86 -0
- package/dist/cjs/types.d.ts +99 -0
- package/dist/cjs/types.js +102 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.js +5 -0
- package/dist/esm/commands.d.ts +168 -0
- package/dist/esm/commands.js +560 -0
- package/dist/esm/errors.d.ts +73 -0
- package/dist/esm/errors.js +250 -0
- package/dist/esm/files.d.ts +86 -0
- package/dist/esm/files.js +360 -0
- package/dist/esm/http.d.ts +210 -0
- package/dist/esm/http.js +553 -0
- package/dist/esm/index.d.ts +12 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/outcome.d.ts +75 -0
- package/dist/esm/outcome.js +152 -0
- package/dist/esm/output.d.ts +59 -0
- package/dist/esm/output.js +280 -0
- package/dist/esm/sandbox.d.ts +218 -0
- package/dist/esm/sandbox.js +806 -0
- package/dist/esm/shell.d.ts +152 -0
- package/dist/esm/shell.js +379 -0
- package/dist/esm/toolbox.d.ts +48 -0
- package/dist/esm/toolbox.js +81 -0
- package/dist/esm/types.d.ts +99 -0
- package/dist/esm/types.js +95 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +2 -0
- package/package.json +53 -4
package/dist/esm/http.js
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// Configuration and the one HTTP path every call takes: auth header,
|
|
2
|
+
// per-request timeout, retries with backoff, and the error envelope.
|
|
3
|
+
import { errorFromResponse, parseRetryAfter, PrizedError } from './errors.js';
|
|
4
|
+
import { commandEffect, restate, withAbort, withOutcome } from './outcome.js';
|
|
5
|
+
import { VERSION } from './version.js';
|
|
6
|
+
export const DEFAULT_BASE_URL = 'https://api.prized.dev';
|
|
7
|
+
export const DEFAULT_EDGE_URL = 'https://term.prized.dev';
|
|
8
|
+
function envApiKey() {
|
|
9
|
+
try {
|
|
10
|
+
const env = globalThis.process?.env;
|
|
11
|
+
return env?.PRIZED_API_KEY;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function isLoopback(hostname) {
|
|
18
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
|
|
19
|
+
}
|
|
20
|
+
/** An http(s) origin, trailing slashes and a trailing /api/v1 dropped. */
|
|
21
|
+
function normalizeOrigin(name, raw) {
|
|
22
|
+
let url;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new PrizedError('invalid_argument', `${name} is not a URL: ${raw}`);
|
|
28
|
+
}
|
|
29
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
30
|
+
throw new PrizedError('invalid_argument', `${name} must be an http(s) URL`);
|
|
31
|
+
}
|
|
32
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
33
|
+
throw new PrizedError('invalid_argument', `${name} must not carry credentials, a query or a fragment`);
|
|
34
|
+
}
|
|
35
|
+
const path = url.pathname.replace(/\/+$/, '').replace(/\/api\/v1$/, '');
|
|
36
|
+
return `${url.origin}${path}`;
|
|
37
|
+
}
|
|
38
|
+
export function abortableSleep(ms, signal) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
if (signal?.aborted) {
|
|
41
|
+
reject(new PrizedError('aborted'));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const onAbort = () => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
reject(new PrizedError('aborted'));
|
|
47
|
+
};
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
signal?.removeEventListener('abort', onAbort);
|
|
50
|
+
resolve();
|
|
51
|
+
}, ms);
|
|
52
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* How long a streamed or long exec keeps polling while its polls fail
|
|
57
|
+
* before it stops the command (pollOutageMs): long enough to ride out a
|
|
58
|
+
* dropped connection or an edge that restarts. The Python SDK's
|
|
59
|
+
* DEFAULT_POLL_OUTAGE.
|
|
60
|
+
*/
|
|
61
|
+
export const DEFAULT_POLL_OUTAGE_MS = 120_000;
|
|
62
|
+
/** A pollOutageMs given (a number of ms ≥ 0), or undefined. */
|
|
63
|
+
export function validOutage(value) {
|
|
64
|
+
if (value !== undefined && !(Number.isFinite(value) && value >= 0)) {
|
|
65
|
+
throw new PrizedError('invalid_argument', 'pollOutageMs must be a number of ms ≥ 0');
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
export function resolveConfig(options = {}) {
|
|
70
|
+
const apiKey = (options.apiKey ?? envApiKey() ?? '').trim();
|
|
71
|
+
if (apiKey === '')
|
|
72
|
+
throw new PrizedError('missing_api_key');
|
|
73
|
+
// A key is printable ASCII. Anything else (the docs' `dcp_…` placeholder,
|
|
74
|
+
// a smart quote) makes fetch throw on the header, which would read as
|
|
75
|
+
// "could not reach the API"; the key itself is never quoted back.
|
|
76
|
+
if (![...apiKey].every((c) => c.charCodeAt(0) >= 33 && c.charCodeAt(0) <= 126))
|
|
77
|
+
throw new PrizedError('invalid_api_key');
|
|
78
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
79
|
+
if (typeof fetchImpl !== 'function') {
|
|
80
|
+
throw new PrizedError('invalid_argument', 'No global fetch: use Node 18 or newer, or pass options.fetch');
|
|
81
|
+
}
|
|
82
|
+
const maxRetries = options.maxRetries ?? 3;
|
|
83
|
+
if (!Number.isInteger(maxRetries) || maxRetries < 0) {
|
|
84
|
+
throw new PrizedError('invalid_argument', 'maxRetries must be a whole number ≥ 0');
|
|
85
|
+
}
|
|
86
|
+
const pollOutageMs = validOutage(options.pollOutageMs) ?? DEFAULT_POLL_OUTAGE_MS;
|
|
87
|
+
return {
|
|
88
|
+
apiKey,
|
|
89
|
+
baseUrl: normalizeOrigin('baseUrl', options.baseUrl ?? DEFAULT_BASE_URL),
|
|
90
|
+
edgeUrl: options.edgeUrl === undefined ? null : normalizeOrigin('edgeUrl', options.edgeUrl),
|
|
91
|
+
// Bound to globalThis: some runtimes throw "Illegal invocation" on an
|
|
92
|
+
// unbound fetch.
|
|
93
|
+
fetch: options.fetch ?? fetchImpl.bind(globalThis),
|
|
94
|
+
maxRetries,
|
|
95
|
+
requestTimeoutMs: options.requestTimeoutMs ?? 60_000,
|
|
96
|
+
pollIntervalMs: options.pollIntervalMs ?? 500,
|
|
97
|
+
pollOutageMs,
|
|
98
|
+
sleep: options.sleep ?? abortableSleep,
|
|
99
|
+
now: options.now ?? Date.now,
|
|
100
|
+
random: options.random ?? Math.random,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Where exec and files go for one sandbox. An explicit edgeUrl wins; else the
|
|
105
|
+
* API's own `toolboxBase` when it has the expected shape (https, or plain
|
|
106
|
+
* http on loopback for local development); else the default edge.
|
|
107
|
+
*/
|
|
108
|
+
export function toolboxBaseFor(config, sandboxId, toolboxBase) {
|
|
109
|
+
const path = `/v1/sandbox/${encodeURIComponent(sandboxId)}`;
|
|
110
|
+
if (config.edgeUrl !== null)
|
|
111
|
+
return `${config.edgeUrl}${path}`;
|
|
112
|
+
if (toolboxBase) {
|
|
113
|
+
try {
|
|
114
|
+
const url = new URL(toolboxBase);
|
|
115
|
+
const secure = url.protocol === 'https:' || (url.protocol === 'http:' && isLoopback(url.hostname));
|
|
116
|
+
if (secure &&
|
|
117
|
+
!url.username &&
|
|
118
|
+
!url.password &&
|
|
119
|
+
!url.search &&
|
|
120
|
+
!url.hash &&
|
|
121
|
+
url.pathname.replace(/\/+$/, '') === path) {
|
|
122
|
+
return `${url.origin}${path}`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// fall through to the default
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return `${DEFAULT_EDGE_URL}${path}`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The edge this config's sandboxes answer on, before the API has named one:
|
|
133
|
+
* an explicit edgeUrl, else the default edge for the default API. null for
|
|
134
|
+
* any other API, whose edge is known only from its answer.
|
|
135
|
+
*/
|
|
136
|
+
export function expectedEdge(config) {
|
|
137
|
+
if (config.edgeUrl !== null)
|
|
138
|
+
return config.edgeUrl;
|
|
139
|
+
return config.baseUrl === DEFAULT_BASE_URL ? DEFAULT_EDGE_URL : null;
|
|
140
|
+
}
|
|
141
|
+
/** The bound on one warm-up request. */
|
|
142
|
+
const WARM_EDGE_MS = 10_000;
|
|
143
|
+
/**
|
|
144
|
+
* Open a connection to the edge before the first command needs it: one
|
|
145
|
+
* `GET /healthz` without the API key (the route needs none), its body read
|
|
146
|
+
* so the connection goes back to the pool. Sent beside a held create, it
|
|
147
|
+
* takes DNS, TCP and TLS off the first command after the sandbox runs. Best
|
|
148
|
+
* effort: never throws, and an edge it cannot reach costs only itself.
|
|
149
|
+
*/
|
|
150
|
+
export async function warmEdge(config) {
|
|
151
|
+
const edge = expectedEdge(config);
|
|
152
|
+
if (edge === null)
|
|
153
|
+
return;
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
const timer = setTimeout(() => controller.abort(), WARM_EDGE_MS);
|
|
156
|
+
// Never what keeps a process alive.
|
|
157
|
+
timer.unref?.();
|
|
158
|
+
try {
|
|
159
|
+
const response = await config.fetch(`${edge}/healthz`, {
|
|
160
|
+
method: 'GET',
|
|
161
|
+
headers: { 'user-agent': `prized-sdk-typescript/${VERSION}` },
|
|
162
|
+
signal: controller.signal,
|
|
163
|
+
});
|
|
164
|
+
await response.arrayBuffer();
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// The first command dials its own connection.
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Refusals that will not change by waiting a few seconds. */
|
|
174
|
+
const NEVER_RETRY = new Set([
|
|
175
|
+
'sandbox_quota_exceeded',
|
|
176
|
+
'sandboxes_disabled',
|
|
177
|
+
'sandbox_create_disabled',
|
|
178
|
+
'sandbox_fork_disabled',
|
|
179
|
+
'idempotency_conflict',
|
|
180
|
+
'paid_balance_required',
|
|
181
|
+
'insufficient_balance',
|
|
182
|
+
]);
|
|
183
|
+
const EXEC_UNDELIVERED = new Set([
|
|
184
|
+
'rate_limited',
|
|
185
|
+
'resolve_failed',
|
|
186
|
+
'sandbox_credentials_unavailable',
|
|
187
|
+
'control_plane_unavailable',
|
|
188
|
+
// A paused or starting sandbox the edge woke did not run within its wait.
|
|
189
|
+
'sandbox_not_ready',
|
|
190
|
+
]);
|
|
191
|
+
/** Failures to open a connection at all (Node's fetch: the cause's code or syscall): nothing was sent. */
|
|
192
|
+
const NOT_CONNECTED_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT']);
|
|
193
|
+
const NOT_CONNECTED_SYSCALLS = new Set(['connect', 'getaddrinfo']);
|
|
194
|
+
/**
|
|
195
|
+
* Whether a fetch that threw never sent its request: the connection was
|
|
196
|
+
* refused, the name did not resolve, or connecting timed out. Anything else
|
|
197
|
+
* (a reset, a timeout once connected, a runtime that names no cause) may
|
|
198
|
+
* have been delivered.
|
|
199
|
+
*/
|
|
200
|
+
export function neverSent(error, depth = 0) {
|
|
201
|
+
if (depth > 4 || error === null || typeof error !== 'object')
|
|
202
|
+
return false;
|
|
203
|
+
const { code, syscall, errors, cause } = error;
|
|
204
|
+
// Every address Node tried (IPv6 then IPv4, say) failed to connect: an
|
|
205
|
+
// AggregateError, whose own code is only the first member's.
|
|
206
|
+
if (Array.isArray(errors) && errors.length > 0)
|
|
207
|
+
return errors.every((member) => neverSent(member, depth + 1));
|
|
208
|
+
if ((typeof code === 'string' && NOT_CONNECTED_CODES.has(code)) || (typeof syscall === 'string' && NOT_CONNECTED_SYSCALLS.has(syscall))) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
return neverSent(cause, depth + 1);
|
|
212
|
+
}
|
|
213
|
+
export function retryable(error, mode, method) {
|
|
214
|
+
if (NEVER_RETRY.has(error.code))
|
|
215
|
+
return false;
|
|
216
|
+
// Whatever a command may be sent again on, a request that is safe to
|
|
217
|
+
// repeat may be too, whatever its status.
|
|
218
|
+
if (EXEC_UNDELIVERED.has(error.code))
|
|
219
|
+
return true;
|
|
220
|
+
if (mode === 'exec')
|
|
221
|
+
return false;
|
|
222
|
+
// The API failing mid-request (500 internal: a database blip, say) may
|
|
223
|
+
// have done a write's work, so a write is left to its caller (a create
|
|
224
|
+
// that may have made a sandbox finds and deletes it). A GET did nothing:
|
|
225
|
+
// a poll that meets one is sent again rather than failing its create.
|
|
226
|
+
if (error.status === 500 && method === 'GET')
|
|
227
|
+
return true;
|
|
228
|
+
return error.status === 429 || error.status === 502 || error.status === 503 || error.status === 504;
|
|
229
|
+
}
|
|
230
|
+
/** The first retry's backoff; each later one doubles, up to MAX_BACKOFF_MS (jittered down to half). */
|
|
231
|
+
export const BACKOFF_FIRST_MS = 500;
|
|
232
|
+
export const MAX_BACKOFF_MS = 8_000;
|
|
233
|
+
/**
|
|
234
|
+
* The longest Retry-After the SDK waits for, the Python SDK's too: twice the
|
|
235
|
+
* longest the API asks for on a route the SDK calls (30 s, with 503
|
|
236
|
+
* sandbox_capacity; the edge asks for 1 s). A longer one is still on the
|
|
237
|
+
* error (`retryAfter`) if the retries run out.
|
|
238
|
+
*/
|
|
239
|
+
export const MAX_RETRY_AFTER_MS = 60_000;
|
|
240
|
+
/**
|
|
241
|
+
* The bound on a call the caller gives no timeout (connect, list, templates,
|
|
242
|
+
* refresh, keepAlive), retries included: twice requestTimeoutMs, room for one
|
|
243
|
+
* attempt that runs out and the retries after it.
|
|
244
|
+
*/
|
|
245
|
+
export function callDeadline(config) {
|
|
246
|
+
return config.now() + 2 * config.requestTimeoutMs;
|
|
247
|
+
}
|
|
248
|
+
/** The timeouts request() threw because its deadline came first (not an attempt's own). */
|
|
249
|
+
const cutShort = new WeakSet();
|
|
250
|
+
/**
|
|
251
|
+
* Whether request() ended because the call's deadline came first: no time
|
|
252
|
+
* was left, or the retry it was due could not start in time. A wait relabels
|
|
253
|
+
* only these as its own timeout; an attempt that got no answer while time
|
|
254
|
+
* was left is the API not answering, and is thrown as it is.
|
|
255
|
+
*/
|
|
256
|
+
export function cutByDeadline(error) {
|
|
257
|
+
return error instanceof PrizedError && cutShort.has(error);
|
|
258
|
+
}
|
|
259
|
+
/** The least room a wait leaves before its deadline for its last poll to be answered. */
|
|
260
|
+
export const LAST_LOOK_MS = 100;
|
|
261
|
+
/** The most: a slow read never makes a wait give up its last seconds. */
|
|
262
|
+
export const LAST_LOOK_MAX_MS = 2_000;
|
|
263
|
+
/**
|
|
264
|
+
* A wait's polls, paced so the last goes out just before the deadline, with
|
|
265
|
+
* room for its answer: the slowest read of this wait so far plus
|
|
266
|
+
* LAST_LOOK_MS (at most LAST_LOOK_MAX_MS). Without it a wait whose last
|
|
267
|
+
* pause ran to the deadline gave up without looking, and one shorter than a
|
|
268
|
+
* poll interval never looked at all.
|
|
269
|
+
*/
|
|
270
|
+
export class Polls {
|
|
271
|
+
config;
|
|
272
|
+
deadline;
|
|
273
|
+
lastLeft = Infinity;
|
|
274
|
+
sentAt = 0;
|
|
275
|
+
slowest = 0;
|
|
276
|
+
constructor(config, deadline) {
|
|
277
|
+
this.config = config;
|
|
278
|
+
this.deadline = deadline;
|
|
279
|
+
}
|
|
280
|
+
room() {
|
|
281
|
+
return Math.min(LAST_LOOK_MAX_MS, this.slowest + LAST_LOOK_MS);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* How long to pause before the next poll: `delayMs`, cut so the poll goes
|
|
285
|
+
* out no later than the room it needs before the deadline (at once when
|
|
286
|
+
* less is left). null when the wait is over: the time is up, or the last
|
|
287
|
+
* look was taken (the previous poll went out with no more than that room).
|
|
288
|
+
*/
|
|
289
|
+
pause(delayMs) {
|
|
290
|
+
const left = this.deadline - this.config.now();
|
|
291
|
+
// (A millisecond's slack: a pause too short to move the clock must not
|
|
292
|
+
// keep the last look from counting.)
|
|
293
|
+
if (left <= 0 || this.lastLeft <= this.room() + 1)
|
|
294
|
+
return null;
|
|
295
|
+
return Math.max(0, Math.min(delayMs, left - this.room()));
|
|
296
|
+
}
|
|
297
|
+
/** A poll is going out now. */
|
|
298
|
+
sent() {
|
|
299
|
+
this.sentAt = this.config.now();
|
|
300
|
+
this.lastLeft = this.deadline - this.sentAt;
|
|
301
|
+
}
|
|
302
|
+
/** Its answer came back. */
|
|
303
|
+
answered() {
|
|
304
|
+
this.slowest = Math.max(this.slowest, this.config.now() - this.sentAt);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/** Delay before retry number `attempt` (0-based): exponential with jitter. */
|
|
308
|
+
export function backoffMs(config, attempt) {
|
|
309
|
+
const base = Math.min(MAX_BACKOFF_MS, BACKOFF_FIRST_MS * 2 ** attempt);
|
|
310
|
+
return Math.round(base / 2 + (config.random() * base) / 2);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* One fetch, body included, under one timeout and the caller's signal. A
|
|
314
|
+
* body that stalls or breaks after the headers fails the attempt like a
|
|
315
|
+
* dropped connection, so it is timed and retried by the same rules.
|
|
316
|
+
*/
|
|
317
|
+
async function attemptOnce(config, spec, url, timeoutMs) {
|
|
318
|
+
const controller = new AbortController();
|
|
319
|
+
let timedOut = false;
|
|
320
|
+
const timer = setTimeout(() => {
|
|
321
|
+
timedOut = true;
|
|
322
|
+
controller.abort();
|
|
323
|
+
}, timeoutMs);
|
|
324
|
+
const onAbort = () => controller.abort();
|
|
325
|
+
spec.signal?.addEventListener('abort', onAbort, { once: true });
|
|
326
|
+
const headers = {
|
|
327
|
+
accept: 'application/json',
|
|
328
|
+
authorization: `Bearer ${config.apiKey}`,
|
|
329
|
+
'user-agent': `prized-sdk-typescript/${VERSION}`,
|
|
330
|
+
...spec.headers,
|
|
331
|
+
};
|
|
332
|
+
let body;
|
|
333
|
+
if (spec.json !== undefined) {
|
|
334
|
+
headers['content-type'] = 'application/json';
|
|
335
|
+
body = JSON.stringify(spec.json);
|
|
336
|
+
}
|
|
337
|
+
else if (spec.body !== undefined) {
|
|
338
|
+
headers['content-type'] = spec.contentType ?? 'application/octet-stream';
|
|
339
|
+
body = spec.body;
|
|
340
|
+
}
|
|
341
|
+
let status = null;
|
|
342
|
+
try {
|
|
343
|
+
const response = await config.fetch(url, {
|
|
344
|
+
method: spec.method,
|
|
345
|
+
headers,
|
|
346
|
+
body: body,
|
|
347
|
+
// Never follow a redirect with the key attached.
|
|
348
|
+
redirect: 'manual',
|
|
349
|
+
signal: controller.signal,
|
|
350
|
+
});
|
|
351
|
+
status = response.status;
|
|
352
|
+
const ok = response.status >= 200 && response.status < 300;
|
|
353
|
+
if (response.status >= 300 && response.status < 400) {
|
|
354
|
+
await response.body?.cancel().catch(() => undefined);
|
|
355
|
+
return { answer: { status: response.status, headers: response.headers, text: '', bytes: null } };
|
|
356
|
+
}
|
|
357
|
+
if (ok && spec.read === 'bytes') {
|
|
358
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
359
|
+
return { answer: { status: response.status, headers: response.headers, text: '', bytes } };
|
|
360
|
+
}
|
|
361
|
+
return { answer: { status: response.status, headers: response.headers, text: await response.text(), bytes: null } };
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
return { error, timedOut, status };
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
clearTimeout(timer);
|
|
368
|
+
spec.signal?.removeEventListener('abort', onAbort);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* A request whose connection failed after it may have been sent: the method,
|
|
373
|
+
* the path (never the query) and the innermost error code, e.g.
|
|
374
|
+
* `POST /v1/sandbox/sbx_1/process/execute: the connection failed
|
|
375
|
+
* (UND_ERR_SOCKET)`. Not "could not reach": it may have. Never the cause's
|
|
376
|
+
* own text, which can quote a header (the key) or the whole URL; that stays
|
|
377
|
+
* in `cause`.
|
|
378
|
+
*/
|
|
379
|
+
function lostMessage(method, url, error) {
|
|
380
|
+
let code = null;
|
|
381
|
+
let inner = error;
|
|
382
|
+
for (let depth = 0; depth < 5 && inner !== null && typeof inner === 'object'; depth += 1) {
|
|
383
|
+
const own = inner.code;
|
|
384
|
+
if (typeof own === 'string' && /^[A-Z][A-Z0-9_]{1,40}$/.test(own))
|
|
385
|
+
code = own;
|
|
386
|
+
inner = inner.cause;
|
|
387
|
+
}
|
|
388
|
+
let path = '';
|
|
389
|
+
try {
|
|
390
|
+
path = ` ${new URL(url).pathname}`;
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// no path to name
|
|
394
|
+
}
|
|
395
|
+
return `${method}${path}: the connection failed${code === null ? '' : ` (${code})`}.`;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* What an error answer (not a 2xx: request() judges those) says about
|
|
399
|
+
* whether the sandbox acted: `never` for the codes that prove the request
|
|
400
|
+
* never arrived, `maybe` for any other 5xx, null for a refusal (4xx), whose
|
|
401
|
+
* own words say what happened.
|
|
402
|
+
*/
|
|
403
|
+
export function outcomeOf(error) {
|
|
404
|
+
if (error.status === null)
|
|
405
|
+
return null;
|
|
406
|
+
if (EXEC_UNDELIVERED.has(error.code))
|
|
407
|
+
return 'never';
|
|
408
|
+
return error.status >= 500 ? 'maybe' : null;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The error a request throws. With an `effect`, it says whether the sandbox
|
|
412
|
+
* acted on the request: by this attempt, or by an earlier one that was sent
|
|
413
|
+
* again (`acted`: only a keyed request, the background start, is sent again
|
|
414
|
+
* after it may have arrived). `cutStatus`: the 2xx whose body broke off.
|
|
415
|
+
*/
|
|
416
|
+
function raised(failure, outcome, acted, effect, cutStatus = null) {
|
|
417
|
+
if (effect === undefined)
|
|
418
|
+
return failure;
|
|
419
|
+
let error = failure;
|
|
420
|
+
if (outcome === 'did' && cutStatus !== null)
|
|
421
|
+
error = restate(error, error.message, { status: cutStatus, code: 'bad_response' });
|
|
422
|
+
return withOutcome(error, effect, acted && (outcome === null || outcome === 'never') ? 'maybe' : outcome);
|
|
423
|
+
}
|
|
424
|
+
/** One API or edge call, retried per `spec.retry`. Throws PrizedError. */
|
|
425
|
+
export async function request(config, spec) {
|
|
426
|
+
const timeoutMs = spec.timeoutMs ?? config.requestTimeoutMs;
|
|
427
|
+
const sandboxId = spec.sandboxId ?? null;
|
|
428
|
+
const effect = spec.effect ?? (spec.retry === 'exec' ? commandEffect() : undefined);
|
|
429
|
+
const unsent = spec.retry === 'exec' ? effect : undefined;
|
|
430
|
+
// Names the last attempt's failure, if any, so a call cut short by its
|
|
431
|
+
// deadline still says what it was waiting out (a 503, a rate limit).
|
|
432
|
+
const pastDeadline = (last) => {
|
|
433
|
+
const what = last === undefined ? null : last.status === null ? last.code : `${last.status} ${last.code}`;
|
|
434
|
+
const tail = what === null ? '' : ` The last attempt failed with ${what}.`;
|
|
435
|
+
let error = new PrizedError('timeout', `The time allowed for this call ran out.${tail}`, { sandboxId, cause: last });
|
|
436
|
+
// A command is sent again only when it provably never arrived, so every
|
|
437
|
+
// attempt of one cut short this way never reached the sandbox.
|
|
438
|
+
if (unsent !== undefined)
|
|
439
|
+
error = withOutcome(error, unsent, 'never');
|
|
440
|
+
cutShort.add(error);
|
|
441
|
+
return error;
|
|
442
|
+
};
|
|
443
|
+
const failed = (error) => {
|
|
444
|
+
spec.onAttemptFailed?.(error);
|
|
445
|
+
return error;
|
|
446
|
+
};
|
|
447
|
+
let last;
|
|
448
|
+
// Whether an attempt that was sent again may have reached the sandbox.
|
|
449
|
+
let acted = false;
|
|
450
|
+
// An abort with an `effect` says whether the sandbox may have acted:
|
|
451
|
+
// before an attempt, or while waiting to send one again, nothing was sent
|
|
452
|
+
// that might have, unless an earlier attempt of a keyed request (`acted`)
|
|
453
|
+
// did.
|
|
454
|
+
const abortedBetween = (cause) => {
|
|
455
|
+
const error = new PrizedError('aborted', null, cause === undefined ? { sandboxId } : { sandboxId, cause });
|
|
456
|
+
return effect === undefined ? error : withAbort(error, effect, acted ? 'maybe' : 'never');
|
|
457
|
+
};
|
|
458
|
+
for (let attempt = 0;; attempt += 1) {
|
|
459
|
+
if (spec.signal?.aborted)
|
|
460
|
+
throw abortedBetween(last);
|
|
461
|
+
const left = spec.deadline === undefined ? Infinity : spec.deadline - config.now();
|
|
462
|
+
if (left <= 0)
|
|
463
|
+
throw pastDeadline(last);
|
|
464
|
+
const attemptMs = Math.max(1, Math.min(timeoutMs, left));
|
|
465
|
+
const url = typeof spec.url === 'string' ? spec.url : spec.url(left);
|
|
466
|
+
const outcome = await attemptOnce(config, spec, url, attemptMs);
|
|
467
|
+
let failure;
|
|
468
|
+
let waitMs = null;
|
|
469
|
+
if ('error' in outcome) {
|
|
470
|
+
if (spec.signal?.aborted) {
|
|
471
|
+
const error = failed(new PrizedError('aborted', null, { sandboxId, cause: outcome.error }));
|
|
472
|
+
// An attempt cut off may have reached the sandbox; one whose 2xx
|
|
473
|
+
// status came before its body did, and the sandbox acted on it.
|
|
474
|
+
const answered = outcome.status !== null && outcome.status >= 200 && outcome.status < 300;
|
|
475
|
+
throw effect === undefined ? error : withAbort(error, effect, answered ? 'did' : 'maybe');
|
|
476
|
+
}
|
|
477
|
+
const unsentAttempt = !outcome.timedOut && neverSent(outcome.error);
|
|
478
|
+
failure = failed(outcome.timedOut
|
|
479
|
+
? new PrizedError('timeout', `No answer within ${attemptMs} ms`, { sandboxId, cause: outcome.error })
|
|
480
|
+
: unsentAttempt
|
|
481
|
+
? new PrizedError('network_error', null, { sandboxId, cause: outcome.error })
|
|
482
|
+
: new PrizedError('network_error', lostMessage(spec.method, url, outcome.error), { sandboxId, cause: outcome.error }));
|
|
483
|
+
// The status came and the body did not: the sandbox acted on it.
|
|
484
|
+
const cut = outcome.status !== null && outcome.status >= 200 && outcome.status < 300;
|
|
485
|
+
const said = cut ? 'did' : unsentAttempt ? 'never' : 'maybe';
|
|
486
|
+
// A command or upload is sent again only if it never left.
|
|
487
|
+
const resend = spec.retry === 'safe' || (spec.retry === 'exec' && unsentAttempt);
|
|
488
|
+
if (!resend || attempt >= config.maxRetries)
|
|
489
|
+
throw raised(failure, said, acted, effect, cut ? outcome.status : null);
|
|
490
|
+
acted = acted || said !== 'never';
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
const answer = outcome.answer;
|
|
494
|
+
if (answer.status >= 200 && answer.status < 300) {
|
|
495
|
+
try {
|
|
496
|
+
return parseSuccess(answer, spec, sandboxId);
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
throw error instanceof PrizedError ? raised(failed(error), 'did', acted, effect) : error;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (answer.status >= 300 && answer.status < 400) {
|
|
503
|
+
throw failed(new PrizedError('unexpected_redirect', null, { status: answer.status, sandboxId }));
|
|
504
|
+
}
|
|
505
|
+
const retryAfter = parseRetryAfter(answer.headers.get('retry-after'), config.now());
|
|
506
|
+
failure = failed(errorFromResponse(answer.status, answer.text, retryAfter, sandboxId, urlPath(url)));
|
|
507
|
+
const said = outcomeOf(failure);
|
|
508
|
+
if (!retryable(failure, spec.retry, spec.method) || attempt >= config.maxRetries)
|
|
509
|
+
throw raised(failure, said, acted, effect);
|
|
510
|
+
acted = acted || said === 'maybe' || said === 'did';
|
|
511
|
+
if (retryAfter !== null)
|
|
512
|
+
waitMs = Math.min(MAX_RETRY_AFTER_MS, Math.ceil(retryAfter * 1000));
|
|
513
|
+
}
|
|
514
|
+
last = failure;
|
|
515
|
+
const delay = Math.max(backoffMs(config, attempt), waitMs ?? 0);
|
|
516
|
+
// A retry that could not start before the deadline is not attempted.
|
|
517
|
+
if (spec.deadline !== undefined && config.now() + delay >= spec.deadline)
|
|
518
|
+
throw pastDeadline(failure);
|
|
519
|
+
try {
|
|
520
|
+
await config.sleep(delay, spec.signal);
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
throw error instanceof PrizedError && error.code === 'aborted' ? abortedBetween(failure) : error;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/** The path of a request's URL ('' if it does not parse). */
|
|
528
|
+
function urlPath(url) {
|
|
529
|
+
try {
|
|
530
|
+
return new URL(url).pathname;
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return '';
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
function parseSuccess(answer, spec, sandboxId) {
|
|
537
|
+
const read = spec.read ?? 'json';
|
|
538
|
+
if (read === 'bytes')
|
|
539
|
+
return { status: answer.status, json: null, bytes: answer.bytes ?? new Uint8Array(0) };
|
|
540
|
+
if (read === 'none' || answer.status === 204 || answer.text === '') {
|
|
541
|
+
return { status: answer.status, json: null, bytes: null };
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
return { status: answer.status, json: JSON.parse(answer.text), bytes: null };
|
|
545
|
+
}
|
|
546
|
+
catch (cause) {
|
|
547
|
+
throw new PrizedError('bad_response', `HTTP ${answer.status} with a body that is not JSON`, {
|
|
548
|
+
status: answer.status,
|
|
549
|
+
sandboxId,
|
|
550
|
+
cause,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { Sandbox, DEFAULT_TEMPLATE, PREVIEW_KEY_HEADER } from './sandbox.js';
|
|
2
|
+
export type { CreateOptions, ForkOptions, ListOptions, WaitableOptions } from './sandbox.js';
|
|
3
|
+
export { CommandHandle, DEFAULT_EXEC_TIMEOUT_MS, MAX_BLOCKING_EXEC_MS } from './commands.js';
|
|
4
|
+
export type { CommandStatus, ExecOptions, WaitOptions } from './commands.js';
|
|
5
|
+
export { Files } from './files.js';
|
|
6
|
+
export type { FileData, WriteOptions } from './files.js';
|
|
7
|
+
export { CommandTimeoutError, PrizedError } from './errors.js';
|
|
8
|
+
export type { PrizedErrorInit } from './errors.js';
|
|
9
|
+
export { DEFAULT_BASE_URL, DEFAULT_EDGE_URL } from './http.js';
|
|
10
|
+
export type { ClientOptions } from './http.js';
|
|
11
|
+
export type { ExecResult, FileInfo, Resources, ResourcesInput, SandboxInfo, SandboxState, Template, WriteInfo, } from './types.js';
|
|
12
|
+
export { VERSION } from './version.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Prized Sandboxes (beta) from TypeScript: the public surface.
|
|
2
|
+
export { Sandbox, DEFAULT_TEMPLATE, PREVIEW_KEY_HEADER } from './sandbox.js';
|
|
3
|
+
export { CommandHandle, DEFAULT_EXEC_TIMEOUT_MS, MAX_BLOCKING_EXEC_MS } from './commands.js';
|
|
4
|
+
export { Files } from './files.js';
|
|
5
|
+
export { CommandTimeoutError, PrizedError } from './errors.js';
|
|
6
|
+
export { DEFAULT_BASE_URL, DEFAULT_EDGE_URL } from './http.js';
|
|
7
|
+
export { VERSION } from './version.js';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { PrizedError } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* What one request does in the sandbox, in the words its errors use: `maybe`
|
|
4
|
+
* when no clear answer came back (a lost connection, a timeout, a 5xx other
|
|
5
|
+
* than the ones that prove it never arrived), `did` when a 2xx came back that
|
|
6
|
+
* could not be read (the sandbox acted on it), and `check`, how to find out
|
|
7
|
+
* which.
|
|
8
|
+
*/
|
|
9
|
+
export interface Effect {
|
|
10
|
+
readonly maybe: string;
|
|
11
|
+
readonly did: string;
|
|
12
|
+
readonly check: string;
|
|
13
|
+
/** What the sandbox does once the connection closes, said when the caller aborts after it may have been sent (withAbort). */
|
|
14
|
+
readonly onClose?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function commandEffect(): Effect;
|
|
17
|
+
export declare function writeEffect(path: string): Effect;
|
|
18
|
+
export declare function removeEffect(path: string): Effect;
|
|
19
|
+
/** For CommandHandle.kill (`signal` without its SIG prefix). */
|
|
20
|
+
export declare function signalEffect(runId: string, signal: string): Effect;
|
|
21
|
+
/** For a background start (or a streamed or long exec, which runs as one): said before what the SDK did about it (startAbandoned). A 2xx proves only that the start ran. */
|
|
22
|
+
export declare function startEffect(): Effect;
|
|
23
|
+
/** For an SDK helper sent in `exec` mode that names no effect of its own. */
|
|
24
|
+
export declare function helperEffect(): Effect;
|
|
25
|
+
/**
|
|
26
|
+
* How a request that is not sent again ended: `never` reached the sandbox,
|
|
27
|
+
* `maybe` acted (no clear answer), `did` act (a 2xx that could not be read),
|
|
28
|
+
* or null for a refusal whose own words say what happened.
|
|
29
|
+
*/
|
|
30
|
+
export type Outcome = 'never' | 'maybe' | 'did' | null;
|
|
31
|
+
/** Whether withOutcome said the sandbox may have acted on the request. */
|
|
32
|
+
export declare function mayHaveActed(error: unknown): error is PrizedError;
|
|
33
|
+
/** `error` with another message (and, given, another status, code, sandbox or cause), every other field kept. */
|
|
34
|
+
export declare function restate(error: PrizedError, message: string, init?: {
|
|
35
|
+
status?: number;
|
|
36
|
+
code?: string;
|
|
37
|
+
sandboxId?: string;
|
|
38
|
+
cause?: unknown;
|
|
39
|
+
}): PrizedError;
|
|
40
|
+
/** `error` (same code, status and body), its message saying what it means for `effect`. */
|
|
41
|
+
export declare function withOutcome(error: PrizedError, effect: Effect, outcome: Outcome): PrizedError;
|
|
42
|
+
/**
|
|
43
|
+
* `error` (code `aborted`) for a request with an `effect`: `never` when
|
|
44
|
+
* nothing that may have reached the sandbox was sent (the signal was
|
|
45
|
+
* aborted before an attempt, or while the SDK waited to send one again
|
|
46
|
+
* after attempts that never arrived), `did` when a 2xx status came before
|
|
47
|
+
* the abort cut its body, else `maybe`, with what the sandbox does once a
|
|
48
|
+
* connection closes. TypeScript only: the Python SDK takes no signal.
|
|
49
|
+
*/
|
|
50
|
+
export declare function withAbort(error: PrizedError, effect: Effect, outcome: 'never' | 'maybe' | 'did'): PrizedError;
|
|
51
|
+
/**
|
|
52
|
+
* A background start that may have started (`error`, from withOutcome or
|
|
53
|
+
* withAbort) and that the SDK then stopped by its run id (`stopped`: the
|
|
54
|
+
* abandon script answered exit 0, so the run can no longer start, was
|
|
55
|
+
* killed with its group, or had already ended), or could not confirm it did
|
|
56
|
+
* (no answer, or exit 3: no pid came, so the command may yet run). The
|
|
57
|
+
* Python SDK's start_abandoned.
|
|
58
|
+
*/
|
|
59
|
+
export declare function startAbandoned(error: PrizedError, runId: string, stopped: boolean): PrizedError;
|
|
60
|
+
/**
|
|
61
|
+
* A streamed or long exec the caller aborted after its run started
|
|
62
|
+
* (`error`, code `aborted`, from `sandboxId`), saying whether the SDK then
|
|
63
|
+
* stopped the run by its id (`stopped`: its KILL was sent, or the run had
|
|
64
|
+
* already ended), or could not confirm it did.
|
|
65
|
+
*/
|
|
66
|
+
export declare function runAborted(error: PrizedError, runId: string, stopped: boolean, sandboxId: string): PrizedError;
|
|
67
|
+
/**
|
|
68
|
+
* A streamed or long exec whose poll failed after its run started (`error`:
|
|
69
|
+
* a lost connection, a 5xx, an answer that could not be read, a refused
|
|
70
|
+
* key), saying whether the SDK then stopped the run by its id (`stopped`),
|
|
71
|
+
* or could not confirm it did: with the network down, the command may still
|
|
72
|
+
* be running. Same code, status and body (and `sandboxId` when it names
|
|
73
|
+
* none); `error` itself is the cause. The Python SDK's poll_failed.
|
|
74
|
+
*/
|
|
75
|
+
export declare function pollFailed(error: PrizedError, runId: string, stopped: boolean, sandboxId: string): PrizedError;
|