@prized/sdk 0.0.0-stage → 0.1.1

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