@prized/sdk 0.1.1 → 0.1.3
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/README.md +74 -29
- package/dist/cjs/commands.d.ts +3 -1
- package/dist/cjs/commands.js +45 -7
- package/dist/cjs/files.d.ts +6 -5
- package/dist/cjs/files.js +8 -5
- package/dist/cjs/http.d.ts +49 -6
- package/dist/cjs/http.js +149 -19
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/outcome.d.ts +9 -0
- package/dist/cjs/outcome.js +21 -1
- package/dist/cjs/output.js +1 -0
- package/dist/cjs/sandbox.d.ts +68 -2
- package/dist/cjs/sandbox.js +112 -7
- package/dist/cjs/shell.d.ts +2 -1
- package/dist/cjs/shell.js +2 -1
- package/dist/cjs/toolbox.d.ts +2 -0
- package/dist/cjs/toolbox.js +1 -0
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/commands.d.ts +3 -1
- package/dist/esm/commands.js +46 -8
- package/dist/esm/files.d.ts +6 -5
- package/dist/esm/files.js +9 -6
- package/dist/esm/http.d.ts +49 -6
- package/dist/esm/http.js +148 -18
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/outcome.d.ts +9 -0
- package/dist/esm/outcome.js +20 -1
- package/dist/esm/output.js +2 -1
- package/dist/esm/sandbox.d.ts +68 -2
- package/dist/esm/sandbox.js +113 -8
- package/dist/esm/shell.d.ts +2 -1
- package/dist/esm/shell.js +2 -1
- package/dist/esm/toolbox.d.ts +2 -0
- package/dist/esm/toolbox.js +1 -0
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/cjs/http.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Polls = exports.LAST_LOOK_MAX_MS = exports.LAST_LOOK_MS = exports.MAX_RETRY_AFTER_MS = exports.MAX_BACKOFF_MS = exports.BACKOFF_FIRST_MS = exports.DEFAULT_POLL_OUTAGE_MS = exports.DEFAULT_EDGE_URL = exports.DEFAULT_BASE_URL = void 0;
|
|
3
|
+
exports.UPLOAD_CHUNK = exports.timing = exports.Polls = exports.LAST_LOOK_MAX_MS = exports.LAST_LOOK_MS = exports.MAX_RETRY_AFTER_MS = exports.MAX_BACKOFF_MS = exports.BACKOFF_FIRST_MS = exports.DEFAULT_POLL_OUTAGE_MS = exports.DEFAULT_EDGE_URL = exports.DEFAULT_BASE_URL = void 0;
|
|
4
4
|
exports.abortableSleep = abortableSleep;
|
|
5
5
|
exports.validOutage = validOutage;
|
|
6
6
|
exports.resolveConfig = resolveConfig;
|
|
@@ -21,6 +21,11 @@ const outcome_js_1 = require("./outcome.js");
|
|
|
21
21
|
const version_js_1 = require("./version.js");
|
|
22
22
|
exports.DEFAULT_BASE_URL = 'https://api.prized.dev';
|
|
23
23
|
exports.DEFAULT_EDGE_URL = 'https://term.prized.dev';
|
|
24
|
+
/** Node's own fetch (not Bun's or Deno's, which also say they are Node), with web streams. */
|
|
25
|
+
function nodeFetch() {
|
|
26
|
+
const versions = globalThis.process?.versions;
|
|
27
|
+
return typeof versions?.node === 'string' && versions.bun === undefined && versions.deno === undefined && typeof ReadableStream === 'function';
|
|
28
|
+
}
|
|
24
29
|
function envApiKey() {
|
|
25
30
|
try {
|
|
26
31
|
const env = globalThis.process?.env;
|
|
@@ -82,8 +87,9 @@ function abortableSleep(ms, signal) {
|
|
|
82
87
|
/**
|
|
83
88
|
* How long a wait (a streamed or long exec, a handle's wait()) keeps polling
|
|
84
89
|
* while its polls fail before it gives up (pollOutageMs), and a streamed or
|
|
85
|
-
* long exec stops its command
|
|
86
|
-
* or an edge that restarts
|
|
90
|
+
* long exec stops its command, and how long a start is sent again: long
|
|
91
|
+
* enough to ride out a dropped connection or an edge that restarts (about
|
|
92
|
+
* 70 s, the last time it crashed). The Python SDK's DEFAULT_POLL_OUTAGE.
|
|
87
93
|
*/
|
|
88
94
|
exports.DEFAULT_POLL_OUTAGE_MS = 120_000;
|
|
89
95
|
/** A pollOutageMs given (a number of ms ≥ 0), or undefined. */
|
|
@@ -125,6 +131,7 @@ function resolveConfig(options = {}) {
|
|
|
125
131
|
sleep: options.sleep ?? abortableSleep,
|
|
126
132
|
now: options.now ?? Date.now,
|
|
127
133
|
random: options.random ?? Math.random,
|
|
134
|
+
streamBodies: options.fetch === undefined && nodeFetch(),
|
|
128
135
|
};
|
|
129
136
|
}
|
|
130
137
|
/** Prized's own domain: its API and its edges are hosts under it. */
|
|
@@ -241,6 +248,8 @@ const NEVER_RETRY = new Set([
|
|
|
241
248
|
'idempotency_conflict',
|
|
242
249
|
'paid_balance_required',
|
|
243
250
|
'insufficient_balance',
|
|
251
|
+
// A 503 that says preview links are off on this deployment: no wait turns them on.
|
|
252
|
+
'preview_unavailable',
|
|
244
253
|
]);
|
|
245
254
|
const EXEC_UNDELIVERED = new Set([
|
|
246
255
|
'rate_limited',
|
|
@@ -372,6 +381,84 @@ function backoffMs(config, attempt) {
|
|
|
372
381
|
const base = Math.min(exports.MAX_BACKOFF_MS, exports.BACKOFF_FIRST_MS * 2 ** attempt);
|
|
373
382
|
return Math.round(base / 2 + (config.random() * base) / 2);
|
|
374
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* How long a file transfer may move no bytes before the attempt is given up
|
|
386
|
+
* (sdk/retry-spec.json transferStallMs; the Python SDK's TRANSFER_STALL).
|
|
387
|
+
* The edge holds a request about 20 s at most before it reads the body (its
|
|
388
|
+
* admission queue, then waking a paused sandbox), and ends a body that brings
|
|
389
|
+
* nothing for 60 s itself: kept above that, a stalled copy is over at the
|
|
390
|
+
* edge before the next one goes to the same staged name, so the two never
|
|
391
|
+
* race. Once an upload's last byte is handed over, its answer is waited for
|
|
392
|
+
* that and a second more for every uploadAnswerBytesPerSecond it sent: what
|
|
393
|
+
* the socket's buffers, or a proxy that takes a whole body before it sends it
|
|
394
|
+
* on, still hold moves unseen, and must not read as a stall (the Python SDK's
|
|
395
|
+
* UPLOAD_ANSWER_RATE). Mutable for tests only.
|
|
396
|
+
*/
|
|
397
|
+
exports.timing = { transferStallMs: 120_000, uploadAnswerBytesPerSecond: 128 * 1024 };
|
|
398
|
+
/** A streamed upload goes out this many bytes at a time. */
|
|
399
|
+
exports.UPLOAD_CHUNK = 64 * 1024;
|
|
400
|
+
/**
|
|
401
|
+
* A body's bytes, to stream; null for one to send as it is. By shape, not
|
|
402
|
+
* instanceof: a Uint8Array or Buffer from another realm (a test runner's vm
|
|
403
|
+
* context, say) is one too.
|
|
404
|
+
*/
|
|
405
|
+
async function bodyBytes(body) {
|
|
406
|
+
if (typeof body === 'string')
|
|
407
|
+
return new TextEncoder().encode(body);
|
|
408
|
+
if (ArrayBuffer.isView(body))
|
|
409
|
+
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
|
410
|
+
if (Object.prototype.toString.call(body) === '[object ArrayBuffer]')
|
|
411
|
+
return new Uint8Array(body);
|
|
412
|
+
if (typeof body.arrayBuffer === 'function')
|
|
413
|
+
return new Uint8Array(await body.arrayBuffer());
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* `bytes` as a stream fed UPLOAD_CHUNK at a time, `moved` told each time
|
|
418
|
+
* fetch asks for more, and at the end given the answer's allowance
|
|
419
|
+
* (timing.uploadAnswerBytesPerSecond).
|
|
420
|
+
*/
|
|
421
|
+
function inPieces(bytes, moved) {
|
|
422
|
+
let at = 0;
|
|
423
|
+
return new ReadableStream({
|
|
424
|
+
pull(controller) {
|
|
425
|
+
if (at >= bytes.byteLength) {
|
|
426
|
+
moved((bytes.byteLength / exports.timing.uploadAnswerBytesPerSecond) * 1000);
|
|
427
|
+
controller.close();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
moved();
|
|
431
|
+
// A copy: a runtime may take the piece's buffer as it sends it.
|
|
432
|
+
controller.enqueue(bytes.slice(at, at + exports.UPLOAD_CHUNK));
|
|
433
|
+
at += exports.UPLOAD_CHUNK;
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
// Nothing read ahead: a pull is the socket taking the last piece.
|
|
437
|
+
{ highWaterMark: 0 });
|
|
438
|
+
}
|
|
439
|
+
/** A body read whole, `moved` told of every piece. */
|
|
440
|
+
async function readAll(response, moved) {
|
|
441
|
+
if (response.body === null)
|
|
442
|
+
return new Uint8Array(0);
|
|
443
|
+
const reader = response.body.getReader();
|
|
444
|
+
const parts = [];
|
|
445
|
+
let size = 0;
|
|
446
|
+
for (;;) {
|
|
447
|
+
const { done, value } = await reader.read();
|
|
448
|
+
if (done)
|
|
449
|
+
break;
|
|
450
|
+
moved();
|
|
451
|
+
parts.push(value);
|
|
452
|
+
size += value.byteLength;
|
|
453
|
+
}
|
|
454
|
+
const whole = new Uint8Array(size);
|
|
455
|
+
let at = 0;
|
|
456
|
+
for (const part of parts) {
|
|
457
|
+
whole.set(part, at);
|
|
458
|
+
at += part.byteLength;
|
|
459
|
+
}
|
|
460
|
+
return whole;
|
|
461
|
+
}
|
|
375
462
|
/**
|
|
376
463
|
* One fetch, body included, under one timeout and the caller's signal. A
|
|
377
464
|
* body that stalls or breaks after the headers fails the attempt like a
|
|
@@ -380,10 +467,24 @@ function backoffMs(config, attempt) {
|
|
|
380
467
|
async function attemptOnce(config, spec, url, timeoutMs) {
|
|
381
468
|
const controller = new AbortController();
|
|
382
469
|
let timedOut = false;
|
|
470
|
+
let stalled = false;
|
|
383
471
|
const timer = setTimeout(() => {
|
|
384
472
|
timedOut = true;
|
|
385
473
|
controller.abort();
|
|
386
474
|
}, timeoutMs);
|
|
475
|
+
// The stall bound starts over whenever bytes move (stallMs), until the attempt is over.
|
|
476
|
+
let stall;
|
|
477
|
+
let over = false;
|
|
478
|
+
const moved = (extraMs = 0) => {
|
|
479
|
+
if (spec.stallMs === undefined || over)
|
|
480
|
+
return;
|
|
481
|
+
clearTimeout(stall);
|
|
482
|
+
stall = setTimeout(() => {
|
|
483
|
+
stalled = true;
|
|
484
|
+
timedOut = true;
|
|
485
|
+
controller.abort();
|
|
486
|
+
}, spec.stallMs + extraMs);
|
|
487
|
+
};
|
|
387
488
|
const onAbort = () => controller.abort();
|
|
388
489
|
spec.signal?.addEventListener('abort', onAbort, { once: true });
|
|
389
490
|
const headers = {
|
|
@@ -393,30 +494,52 @@ async function attemptOnce(config, spec, url, timeoutMs) {
|
|
|
393
494
|
...spec.headers,
|
|
394
495
|
};
|
|
395
496
|
let body;
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
else if (spec.body !== undefined) {
|
|
401
|
-
headers['content-type'] = spec.contentType ?? 'application/octet-stream';
|
|
402
|
-
body = spec.body;
|
|
403
|
-
}
|
|
497
|
+
// Watched from the start unless an upload goes out unwatched (Config.streamBodies).
|
|
498
|
+
let watched = spec.stallMs !== undefined && spec.body === undefined;
|
|
499
|
+
let streamed = false;
|
|
404
500
|
let status = null;
|
|
405
501
|
try {
|
|
502
|
+
if (spec.json !== undefined) {
|
|
503
|
+
headers['content-type'] = 'application/json';
|
|
504
|
+
body = JSON.stringify(spec.json);
|
|
505
|
+
}
|
|
506
|
+
else if (spec.body !== undefined) {
|
|
507
|
+
headers['content-type'] = spec.contentType ?? 'application/octet-stream';
|
|
508
|
+
body = spec.body;
|
|
509
|
+
const bytes = spec.stallMs !== undefined && config.streamBodies ? await bodyBytes(spec.body) : null;
|
|
510
|
+
if (bytes !== null) {
|
|
511
|
+
headers['content-length'] = String(bytes.byteLength);
|
|
512
|
+
body = inPieces(bytes, moved);
|
|
513
|
+
streamed = true;
|
|
514
|
+
watched = true;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (watched)
|
|
518
|
+
moved();
|
|
406
519
|
const response = await config.fetch(url, {
|
|
407
520
|
method: spec.method,
|
|
408
521
|
headers,
|
|
409
522
|
body: body,
|
|
523
|
+
// A streamed body (Node's fetch asks for it): sent before the answer is read.
|
|
524
|
+
...(streamed ? { duplex: 'half' } : {}),
|
|
410
525
|
// Never follow a redirect with the key attached.
|
|
411
526
|
redirect: 'manual',
|
|
412
527
|
signal: controller.signal,
|
|
413
528
|
});
|
|
414
529
|
status = response.status;
|
|
530
|
+
if (spec.stallMs !== undefined)
|
|
531
|
+
moved();
|
|
415
532
|
const ok = response.status >= 200 && response.status < 300;
|
|
416
533
|
if (response.status >= 300 && response.status < 400) {
|
|
417
534
|
await response.body?.cancel().catch(() => undefined);
|
|
418
535
|
return { answer: { status: response.status, headers: response.headers, text: '', bytes: null } };
|
|
419
536
|
}
|
|
537
|
+
if (spec.stallMs !== undefined) {
|
|
538
|
+
const whole = await readAll(response, moved);
|
|
539
|
+
if (ok && spec.read === 'bytes')
|
|
540
|
+
return { answer: { status: response.status, headers: response.headers, text: '', bytes: whole } };
|
|
541
|
+
return { answer: { status: response.status, headers: response.headers, text: new TextDecoder('utf-8').decode(whole), bytes: null } };
|
|
542
|
+
}
|
|
420
543
|
if (ok && spec.read === 'bytes') {
|
|
421
544
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
422
545
|
return { answer: { status: response.status, headers: response.headers, text: '', bytes } };
|
|
@@ -424,10 +547,12 @@ async function attemptOnce(config, spec, url, timeoutMs) {
|
|
|
424
547
|
return { answer: { status: response.status, headers: response.headers, text: await response.text(), bytes: null } };
|
|
425
548
|
}
|
|
426
549
|
catch (error) {
|
|
427
|
-
return { error, timedOut, status };
|
|
550
|
+
return { error, timedOut, stalled, status };
|
|
428
551
|
}
|
|
429
552
|
finally {
|
|
553
|
+
over = true;
|
|
430
554
|
clearTimeout(timer);
|
|
555
|
+
clearTimeout(stall);
|
|
431
556
|
spec.signal?.removeEventListener('abort', onAbort);
|
|
432
557
|
}
|
|
433
558
|
}
|
|
@@ -490,8 +615,8 @@ async function request(config, spec) {
|
|
|
490
615
|
const timeoutMs = spec.timeoutMs ?? config.requestTimeoutMs;
|
|
491
616
|
const sandboxId = spec.sandboxId ?? null;
|
|
492
617
|
const effect = spec.effect ?? (spec.retry === 'exec' ? (0, outcome_js_1.commandEffect)() : undefined);
|
|
493
|
-
// Whether an attempt that was sent again may have reached the sandbox.
|
|
494
|
-
let acted = false;
|
|
618
|
+
// Whether an attempt that was sent again (or an earlier copy) may have reached the sandbox.
|
|
619
|
+
let acted = spec.acted ?? false;
|
|
495
620
|
// Names the last attempt's failure, if any, so a call cut short by its
|
|
496
621
|
// deadline still says what it was waiting out (a 503, a rate limit).
|
|
497
622
|
const pastDeadline = (last) => {
|
|
@@ -500,9 +625,10 @@ async function request(config, spec) {
|
|
|
500
625
|
let error = new errors_js_1.PrizedError('timeout', `The time allowed for this call ran out.${tail}`, { sandboxId, cause: last });
|
|
501
626
|
// A command is sent again only when it provably never arrived, so every
|
|
502
627
|
// attempt of one cut short this way never reached the sandbox. A staged
|
|
503
|
-
// upload's copies may have, unless each provably never arrived
|
|
504
|
-
|
|
505
|
-
|
|
628
|
+
// upload's copies may have, unless each provably never arrived, and so
|
|
629
|
+
// may a keyed start's (the one `safe` request that names an effect).
|
|
630
|
+
if (effect !== undefined)
|
|
631
|
+
error = (0, outcome_js_1.withOutcome)(error, effect, spec.retry !== 'exec' && acted ? 'maybe' : 'never');
|
|
506
632
|
cutShort.add(error);
|
|
507
633
|
return error;
|
|
508
634
|
};
|
|
@@ -540,7 +666,10 @@ async function request(config, spec) {
|
|
|
540
666
|
}
|
|
541
667
|
const unsentAttempt = !outcome.timedOut && neverSent(outcome.error);
|
|
542
668
|
failure = failed(outcome.timedOut
|
|
543
|
-
? new errors_js_1.PrizedError('timeout', `No answer within ${attemptMs} ms`, {
|
|
669
|
+
? new errors_js_1.PrizedError('timeout', outcome.stalled ? `No bytes moved for ${spec.stallMs} ms` : `No answer within ${attemptMs} ms`, {
|
|
670
|
+
sandboxId,
|
|
671
|
+
cause: outcome.error,
|
|
672
|
+
})
|
|
544
673
|
: unsentAttempt
|
|
545
674
|
? new errors_js_1.PrizedError('network_error', null, { sandboxId, cause: outcome.error })
|
|
546
675
|
: new errors_js_1.PrizedError('network_error', lostMessage(spec.method, url, outcome.error), { sandboxId, cause: outcome.error }));
|
|
@@ -565,7 +694,8 @@ async function request(config, spec) {
|
|
|
565
694
|
}
|
|
566
695
|
}
|
|
567
696
|
if (answer.status >= 300 && answer.status < 400) {
|
|
568
|
-
|
|
697
|
+
// A refusal; worded only when an earlier copy of a keyed start may have arrived.
|
|
698
|
+
throw raised(failed(new errors_js_1.PrizedError('unexpected_redirect', null, { status: answer.status, sandboxId })), null, acted, effect);
|
|
569
699
|
}
|
|
570
700
|
const retryAfter = (0, errors_js_1.parseRetryAfter)(answer.headers.get('retry-after'), config.now());
|
|
571
701
|
failure = failed((0, errors_js_1.errorFromResponse)(answer.status, answer.text, retryAfter, sandboxId, urlPath(url)));
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { Sandbox, DEFAULT_TEMPLATE, PREVIEW_KEY_HEADER } from './sandbox.js';
|
|
2
|
-
export type { CreateOptions, ForkOptions, ListOptions, WaitableOptions } from './sandbox.js';
|
|
2
|
+
export type { CreateOptions, ForkOptions, ListOptions, PortPreview, PreviewToken, PreviewTokenOptions, SetPortPublicOptions, WaitableOptions, } from './sandbox.js';
|
|
3
3
|
export { CommandHandle, DEFAULT_EXEC_TIMEOUT_MS, MAX_BLOCKING_EXEC_MS } from './commands.js';
|
|
4
4
|
export type { CommandStatus, ExecOptions, WaitOptions } from './commands.js';
|
|
5
5
|
export { Files } from './files.js';
|
package/dist/cjs/outcome.d.ts
CHANGED
|
@@ -49,6 +49,15 @@ export declare function withOutcome(error: PrizedError, effect: Effect, outcome:
|
|
|
49
49
|
* connection closes. TypeScript only: the Python SDK takes no signal.
|
|
50
50
|
*/
|
|
51
51
|
export declare function withAbort(error: PrizedError, effect: Effect, outcome: 'never' | 'maybe' | 'did'): PrizedError;
|
|
52
|
+
/**
|
|
53
|
+
* What a start that gave up throws when the failure it gives up with
|
|
54
|
+
* (`failure`, Outage's pick) is not the last copy's own: an earlier copy's,
|
|
55
|
+
* worded before a later copy may have reached the sandbox, or what a copy
|
|
56
|
+
* cut short by the window met, not yet worded. Worded for the start: as one
|
|
57
|
+
* that may have started when any copy may have reached the sandbox
|
|
58
|
+
* (`anyActed`), else as one that never did. The Python SDK's start_gave_up.
|
|
59
|
+
*/
|
|
60
|
+
export declare function startGaveUp(failure: PrizedError, anyActed: boolean): PrizedError;
|
|
52
61
|
/**
|
|
53
62
|
* A background start that may have started (`error`, from withOutcome or
|
|
54
63
|
* withAbort) and that the SDK then stopped by its run id (`stopped`: the
|
package/dist/cjs/outcome.js
CHANGED
|
@@ -10,6 +10,7 @@ exports.mayHaveActed = mayHaveActed;
|
|
|
10
10
|
exports.restate = restate;
|
|
11
11
|
exports.withOutcome = withOutcome;
|
|
12
12
|
exports.withAbort = withAbort;
|
|
13
|
+
exports.startGaveUp = startGaveUp;
|
|
13
14
|
exports.startAbandoned = startAbandoned;
|
|
14
15
|
exports.runAborted = runAborted;
|
|
15
16
|
exports.pollFailed = pollFailed;
|
|
@@ -105,12 +106,14 @@ function maybeTail(effect, closed) {
|
|
|
105
106
|
tail += ` ${effect.check}`;
|
|
106
107
|
return tail;
|
|
107
108
|
}
|
|
109
|
+
/** How an error says its request never reached the sandbox (withOutcome). */
|
|
110
|
+
const NEVER_TAIL = ' It never reached the sandbox, so it had no effect.';
|
|
108
111
|
/** `error` (same code, status and body), its message saying what it means for `effect`. */
|
|
109
112
|
function withOutcome(error, effect, outcome) {
|
|
110
113
|
if (outcome === null)
|
|
111
114
|
return error;
|
|
112
115
|
if (outcome === 'never')
|
|
113
|
-
return restate(error, `${trimmed(error.message)}
|
|
116
|
+
return restate(error, `${trimmed(error.message)}.${NEVER_TAIL}`);
|
|
114
117
|
if (outcome === 'did')
|
|
115
118
|
return acting(error, `${effect.did}, but the answer could not be read (${detail(error)}).`);
|
|
116
119
|
return acting(error, `No clear answer came back (${detail(error)}), so ${maybeTail(effect, false)}`);
|
|
@@ -134,6 +137,23 @@ function stopTail(runId, stopped) {
|
|
|
134
137
|
? ` The SDK stopped ${runId}, or it had already ended.`
|
|
135
138
|
: ` The SDK could not confirm it stopped: it may still be running as ${runId} (exec('ps -ef | grep ${runId}') finds it).`;
|
|
136
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* What a start that gave up throws when the failure it gives up with
|
|
142
|
+
* (`failure`, Outage's pick) is not the last copy's own: an earlier copy's,
|
|
143
|
+
* worded before a later copy may have reached the sandbox, or what a copy
|
|
144
|
+
* cut short by the window met, not yet worded. Worded for the start: as one
|
|
145
|
+
* that may have started when any copy may have reached the sandbox
|
|
146
|
+
* (`anyActed`), else as one that never did. The Python SDK's start_gave_up.
|
|
147
|
+
*/
|
|
148
|
+
function startGaveUp(failure, anyActed) {
|
|
149
|
+
if (acted.has(failure))
|
|
150
|
+
return failure;
|
|
151
|
+
const never = failure.message.endsWith(NEVER_TAIL);
|
|
152
|
+
if (never && !anyActed)
|
|
153
|
+
return failure;
|
|
154
|
+
const plain = never ? restate(failure, failure.message.slice(0, -NEVER_TAIL.length)) : failure;
|
|
155
|
+
return withOutcome(plain, startEffect(), anyActed ? 'maybe' : 'never');
|
|
156
|
+
}
|
|
137
157
|
/**
|
|
138
158
|
* A background start that may have started (`error`, from withOutcome or
|
|
139
159
|
* withAbort) and that the SDK then stopped by its run id (`stopped`: the
|
package/dist/cjs/output.js
CHANGED
package/dist/cjs/sandbox.d.ts
CHANGED
|
@@ -4,6 +4,42 @@ import { type ClientOptions } from './http.js';
|
|
|
4
4
|
import { type ExecResult, type Resources, type ResourcesInput, type SandboxInfo, type SandboxState, type Template } from './types.js';
|
|
5
5
|
/** The header a request to a sandbox port's URL carries the API key in. */
|
|
6
6
|
export declare const PREVIEW_KEY_HEADER = "Prized-Key";
|
|
7
|
+
/** A sandbox port's preview link (`setPortPublic`): its URL, and whether
|
|
8
|
+
* it is public, until when. */
|
|
9
|
+
export interface PortPreview {
|
|
10
|
+
sandboxId: string;
|
|
11
|
+
port: number;
|
|
12
|
+
/** The port's URL for a program with the API key (`previewUrl`). */
|
|
13
|
+
url: string;
|
|
14
|
+
/** The same port on prized.link, which opens in a browser tab once the
|
|
15
|
+
* port is public; null where the deployment has no browser domain. */
|
|
16
|
+
browserUrl: string | null;
|
|
17
|
+
/** Anyone with the URL reaches the port, no credential needed. */
|
|
18
|
+
public: boolean;
|
|
19
|
+
/** When the port goes private again on its own; null while private. */
|
|
20
|
+
publicExpiresAt: Date | null;
|
|
21
|
+
}
|
|
22
|
+
/** A short-lived URL token for a sandbox port (`previewToken`). */
|
|
23
|
+
export interface PreviewToken {
|
|
24
|
+
/** The preview URL with the token in its `prized_token` query parameter. */
|
|
25
|
+
url: string;
|
|
26
|
+
/** The same on prized.link, to open in a browser tab or a frame; null
|
|
27
|
+
* where the deployment has no browser domain. */
|
|
28
|
+
browserUrl: string | null;
|
|
29
|
+
token: string;
|
|
30
|
+
expiresAt: Date;
|
|
31
|
+
}
|
|
32
|
+
export interface PreviewTokenOptions {
|
|
33
|
+
/** How long the token opens the port, in seconds: 60 to 86 400. Default 3600 (an hour). */
|
|
34
|
+
expiresIn?: number;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}
|
|
37
|
+
export interface SetPortPublicOptions {
|
|
38
|
+
/** With `true`: how long the port stays public, in seconds: 300 to
|
|
39
|
+
* 2 592 000 (30 days). Default 7 days. */
|
|
40
|
+
expiresIn?: number;
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
}
|
|
7
43
|
/** The template a create uses when it names none. */
|
|
8
44
|
export declare const DEFAULT_TEMPLATE = "prized-base-v1";
|
|
9
45
|
/** How long create, resume and fork wait for `running` by default. */
|
|
@@ -128,8 +164,9 @@ export declare class Sandbox {
|
|
|
128
164
|
/**
|
|
129
165
|
* Delete every sandbox carrying all of `labels` (at least one) and resolve
|
|
130
166
|
* with their ids (the API takes up to 250 a request; this repeats it until
|
|
131
|
-
* none are left).
|
|
132
|
-
*
|
|
167
|
+
* none are left). Each request selects the current matches, so a repeat
|
|
168
|
+
* can delete sandboxes created since the first request. An uncertain
|
|
169
|
+
* answer is not retried; list the matches before deleting again. Each
|
|
133
170
|
* counts toward the workspace's quotas until it is gone;
|
|
134
171
|
* `Sandbox.list({ labels })` coming back empty means they all are.
|
|
135
172
|
*/
|
|
@@ -193,6 +230,35 @@ export declare class Sandbox {
|
|
|
193
230
|
* `redirect: 'manual'`: fetch resends custom headers to whatever host a
|
|
194
231
|
* redirect names, so only ever send them to the sandbox's preview URL. */
|
|
195
232
|
previewHeaders(): Record<string, string>;
|
|
233
|
+
/**
|
|
234
|
+
* A short-lived token for `port`, for a client that cannot send
|
|
235
|
+
* `Prized-Key` in a header: a person in a browser tab, a WebSocket opened
|
|
236
|
+
* from a browser page, or a program you hand this one port to and not
|
|
237
|
+
* your API key. `browserUrl` is the port on prized.link with the token in
|
|
238
|
+
* its `prized_token` query parameter: it opens in a browser tab or a
|
|
239
|
+
* frame (the token moves into a cookie for that host; a tab's first visit
|
|
240
|
+
* shows a notice). `url` is the same on the preview URL, for programs. The
|
|
241
|
+
* app never sees the token. It opens the port until `expiresAt`
|
|
242
|
+
* (`expiresIn` seconds, 60 to 86 400, an hour by default), or until this
|
|
243
|
+
* client's key is revoked; making the port public or private again ends
|
|
244
|
+
* it too. It never wakes a paused sandbox (`503 sandbox_not_running`).
|
|
245
|
+
* Needs a paid workspace (`share_links_need_payment` otherwise).
|
|
246
|
+
*/
|
|
247
|
+
previewToken(port: number, options?: PreviewTokenOptions): Promise<PreviewToken>;
|
|
248
|
+
/**
|
|
249
|
+
* Make `port` public (anyone with its URL reaches it, with no
|
|
250
|
+
* credential) for `expiresIn` seconds (300 to 2 592 000, 7 days by
|
|
251
|
+
* default), or private again with `false`. Public needs the workspace
|
|
252
|
+
* owner's key and a paid workspace, and counts toward the workspace's 10
|
|
253
|
+
* public ports and links. The answer's `browserUrl` (prized.link) opens in
|
|
254
|
+
* a browser tab (a tab's first visit shows a notice); `url` answers
|
|
255
|
+
* programs, `fetch` and WebSockets. A public port never wakes a paused
|
|
256
|
+
* sandbox. A port already public keeps its expiry: make it private, then
|
|
257
|
+
* public again, to change it.
|
|
258
|
+
*/
|
|
259
|
+
setPortPublic(port: number, isPublic: boolean, options?: SetPortPublicOptions): Promise<PortPreview>;
|
|
260
|
+
/** A port a preview link may name: 1 to 65 535, never 22 or 81 (the sandbox's own). */
|
|
261
|
+
private previewPort;
|
|
196
262
|
/** POST the verb, the call and its retries within `deadline`; with `hold`,
|
|
197
263
|
* held by the API until the sandbox is that (each attempt asks only for
|
|
198
264
|
* what is left). */
|
package/dist/cjs/sandbox.js
CHANGED
|
@@ -76,6 +76,13 @@ function wholeMinutes(name, value, allowZero) {
|
|
|
76
76
|
}
|
|
77
77
|
return value;
|
|
78
78
|
}
|
|
79
|
+
function wholeSeconds(name, value) {
|
|
80
|
+
if (value === undefined)
|
|
81
|
+
return undefined;
|
|
82
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
83
|
+
throw new errors_js_1.PrizedError('invalid_argument', `${name} must be a whole number of seconds > 0`);
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
79
86
|
function apiUrl(config, path) {
|
|
80
87
|
return `${config.baseUrl}/api/v1${path}`;
|
|
81
88
|
}
|
|
@@ -440,8 +447,9 @@ class Sandbox {
|
|
|
440
447
|
/**
|
|
441
448
|
* Delete every sandbox carrying all of `labels` (at least one) and resolve
|
|
442
449
|
* with their ids (the API takes up to 250 a request; this repeats it until
|
|
443
|
-
* none are left).
|
|
444
|
-
*
|
|
450
|
+
* none are left). Each request selects the current matches, so a repeat
|
|
451
|
+
* can delete sandboxes created since the first request. An uncertain
|
|
452
|
+
* answer is not retried; list the matches before deleting again. Each
|
|
445
453
|
* counts toward the workspace's quotas until it is gone;
|
|
446
454
|
* `Sandbox.list({ labels })` coming back empty means they all are.
|
|
447
455
|
*/
|
|
@@ -455,7 +463,15 @@ class Sandbox {
|
|
|
455
463
|
const deleted = new Set();
|
|
456
464
|
const stuck = (calls) => new errors_js_1.PrizedError('delete_incomplete', `Deleted ${deleted.size} sandboxes in ${calls} requests and some are left; call again to delete the rest.`);
|
|
457
465
|
for (let call = 1; call <= MAX_BULK_DELETE_CALLS; call += 1) {
|
|
458
|
-
|
|
466
|
+
// A label is a live selector, not a stable resource or idempotency key.
|
|
467
|
+
const result = await (0, http_js_1.request)(config, {
|
|
468
|
+
method: 'DELETE', url: apiUrl(config, `/sandboxes${query}`), retry: 'exec', deadline,
|
|
469
|
+
effect: {
|
|
470
|
+
maybe: 'some matching sandboxes may already be deleting',
|
|
471
|
+
did: 'The matching sandboxes were marked for deletion',
|
|
472
|
+
check: 'It was not sent again: list the matching sandboxes before deleting again, because the matches may have changed.',
|
|
473
|
+
},
|
|
474
|
+
});
|
|
459
475
|
const answer = result.json;
|
|
460
476
|
const ids = answer?.ids;
|
|
461
477
|
if (!Array.isArray(ids) || !ids.every((id) => typeof id === 'string')) {
|
|
@@ -552,9 +568,13 @@ class Sandbox {
|
|
|
552
568
|
body.maxRuntimeMin = maxRuntimeMin;
|
|
553
569
|
const idempotencyKey = options.idempotencyKey ?? randomKey();
|
|
554
570
|
const wire = snapshot(body); // as in create
|
|
571
|
+
const path = `${sandboxPathFor(this.id)}/fork`;
|
|
572
|
+
// Held by the API until every copy runs (or its bound), as create is,
|
|
573
|
+
// instead of polled; the cleanup's replay only wants the ids.
|
|
574
|
+
const held = options.wait !== false;
|
|
555
575
|
const send = (deadline, attempts) => (0, http_js_1.request)(this.config, {
|
|
556
576
|
method: 'POST',
|
|
557
|
-
url: apiUrl(this.config, `${
|
|
577
|
+
url: (leftMs) => apiUrl(this.config, held && attempts !== undefined ? `${path}${holdQuery(this.config, 'running', leftMs)}` : path),
|
|
558
578
|
json: wire,
|
|
559
579
|
headers: { 'idempotency-key': idempotencyKey },
|
|
560
580
|
retry: 'safe',
|
|
@@ -677,9 +697,7 @@ class Sandbox {
|
|
|
677
697
|
* `preview_unavailable` when the deployment has sandbox ingress off.
|
|
678
698
|
*/
|
|
679
699
|
previewUrl(port) {
|
|
680
|
-
|
|
681
|
-
throw new errors_js_1.PrizedError('bad_request', `port must be a whole number from 1 to 65535 other than 22 and 81, not ${String(port)}`, { sandboxId: this.id });
|
|
682
|
-
}
|
|
700
|
+
this.previewPort(port);
|
|
683
701
|
const template = this.current.raw.previewUrlTemplate;
|
|
684
702
|
if (typeof template !== 'string' || !template.includes('{port}')) {
|
|
685
703
|
throw new errors_js_1.PrizedError('preview_unavailable', 'sandbox ports at a URL are not turned on for this deployment', { sandboxId: this.id });
|
|
@@ -693,6 +711,93 @@ class Sandbox {
|
|
|
693
711
|
previewHeaders() {
|
|
694
712
|
return { [exports.PREVIEW_KEY_HEADER]: this.config.apiKey };
|
|
695
713
|
}
|
|
714
|
+
/**
|
|
715
|
+
* A short-lived token for `port`, for a client that cannot send
|
|
716
|
+
* `Prized-Key` in a header: a person in a browser tab, a WebSocket opened
|
|
717
|
+
* from a browser page, or a program you hand this one port to and not
|
|
718
|
+
* your API key. `browserUrl` is the port on prized.link with the token in
|
|
719
|
+
* its `prized_token` query parameter: it opens in a browser tab or a
|
|
720
|
+
* frame (the token moves into a cookie for that host; a tab's first visit
|
|
721
|
+
* shows a notice). `url` is the same on the preview URL, for programs. The
|
|
722
|
+
* app never sees the token. It opens the port until `expiresAt`
|
|
723
|
+
* (`expiresIn` seconds, 60 to 86 400, an hour by default), or until this
|
|
724
|
+
* client's key is revoked; making the port public or private again ends
|
|
725
|
+
* it too. It never wakes a paused sandbox (`503 sandbox_not_running`).
|
|
726
|
+
* Needs a paid workspace (`share_links_need_payment` otherwise).
|
|
727
|
+
*/
|
|
728
|
+
async previewToken(port, options = {}) {
|
|
729
|
+
this.previewPort(port);
|
|
730
|
+
const expiresIn = wholeSeconds('expiresIn', options.expiresIn);
|
|
731
|
+
const result = await (0, http_js_1.request)(this.config, {
|
|
732
|
+
method: 'POST',
|
|
733
|
+
url: apiUrl(this.config, `${sandboxPathFor(this.id)}/ports/${port}/token`),
|
|
734
|
+
json: expiresIn === undefined ? {} : { expiresIn },
|
|
735
|
+
// A second copy mints a second token nobody holds, which expires on its own.
|
|
736
|
+
retry: 'safe',
|
|
737
|
+
signal: options.signal,
|
|
738
|
+
deadline: (0, http_js_1.callDeadline)(this.config),
|
|
739
|
+
sandboxId: this.id,
|
|
740
|
+
});
|
|
741
|
+
const body = result.json;
|
|
742
|
+
const expiresAt = (0, types_js_1.parseTime)(body?.expiresAt);
|
|
743
|
+
if (typeof body?.url !== 'string' || typeof body.token !== 'string' || expiresAt === null) {
|
|
744
|
+
throw new errors_js_1.PrizedError('bad_response', 'the API answered a preview token without its url, token or expiry', { sandboxId: this.id });
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
url: body.url,
|
|
748
|
+
browserUrl: typeof body.browserUrl === 'string' ? body.browserUrl : null,
|
|
749
|
+
token: body.token,
|
|
750
|
+
expiresAt,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Make `port` public (anyone with its URL reaches it, with no
|
|
755
|
+
* credential) for `expiresIn` seconds (300 to 2 592 000, 7 days by
|
|
756
|
+
* default), or private again with `false`. Public needs the workspace
|
|
757
|
+
* owner's key and a paid workspace, and counts toward the workspace's 10
|
|
758
|
+
* public ports and links. The answer's `browserUrl` (prized.link) opens in
|
|
759
|
+
* a browser tab (a tab's first visit shows a notice); `url` answers
|
|
760
|
+
* programs, `fetch` and WebSockets. A public port never wakes a paused
|
|
761
|
+
* sandbox. A port already public keeps its expiry: make it private, then
|
|
762
|
+
* public again, to change it.
|
|
763
|
+
*/
|
|
764
|
+
async setPortPublic(port, isPublic, options = {}) {
|
|
765
|
+
this.previewPort(port);
|
|
766
|
+
if (typeof isPublic !== 'boolean')
|
|
767
|
+
throw new errors_js_1.PrizedError('invalid_argument', 'isPublic must be true or false', { sandboxId: this.id });
|
|
768
|
+
const expiresIn = wholeSeconds('expiresIn', options.expiresIn);
|
|
769
|
+
if (expiresIn !== undefined && !isPublic) {
|
|
770
|
+
throw new errors_js_1.PrizedError('invalid_argument', 'expiresIn applies only when making a port public', { sandboxId: this.id });
|
|
771
|
+
}
|
|
772
|
+
const result = await (0, http_js_1.request)(this.config, {
|
|
773
|
+
method: 'PUT',
|
|
774
|
+
url: apiUrl(this.config, `${sandboxPathFor(this.id)}/ports/${port}`),
|
|
775
|
+
json: isPublic ? { public: true, ...(expiresIn === undefined ? {} : { expiresIn }) } : { public: false },
|
|
776
|
+
// Setting the same state again changes nothing.
|
|
777
|
+
retry: 'safe',
|
|
778
|
+
signal: options.signal,
|
|
779
|
+
deadline: (0, http_js_1.callDeadline)(this.config),
|
|
780
|
+
sandboxId: this.id,
|
|
781
|
+
});
|
|
782
|
+
const doc = result.json?.preview;
|
|
783
|
+
if (doc === undefined || typeof doc.url !== 'string' || typeof doc.public !== 'boolean') {
|
|
784
|
+
throw new errors_js_1.PrizedError('bad_response', 'the API answered a port without its url or public setting', { sandboxId: this.id });
|
|
785
|
+
}
|
|
786
|
+
return {
|
|
787
|
+
sandboxId: typeof doc.sandboxId === 'string' ? doc.sandboxId : this.id,
|
|
788
|
+
port: typeof doc.port === 'number' ? doc.port : port,
|
|
789
|
+
url: doc.url,
|
|
790
|
+
browserUrl: typeof doc.browserUrl === 'string' ? doc.browserUrl : null,
|
|
791
|
+
public: doc.public,
|
|
792
|
+
publicExpiresAt: (0, types_js_1.parseTime)(doc.publicExpiresAt),
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
/** A port a preview link may name: 1 to 65 535, never 22 or 81 (the sandbox's own). */
|
|
796
|
+
previewPort(port) {
|
|
797
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 22 || port === 81) {
|
|
798
|
+
throw new errors_js_1.PrizedError('bad_request', `port must be a whole number from 1 to 65535 other than 22 and 81, not ${String(port)}`, { sandboxId: this.id });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
696
801
|
// --- internals ------------------------------------------------------------------
|
|
697
802
|
/** POST the verb, the call and its retries within `deadline`; with `hold`,
|
|
698
803
|
* held by the API until the sandbox is that (each attempt asks only for
|
package/dist/cjs/shell.d.ts
CHANGED
|
@@ -11,7 +11,8 @@ export declare const RUN_ID_RE: RegExp;
|
|
|
11
11
|
/**
|
|
12
12
|
* Minutes (by the sandbox's clock) a cleaned or abandoned run's directory
|
|
13
13
|
* stays: what keeps a resend of its start from running the command again.
|
|
14
|
-
* The SDK stops resending a start
|
|
14
|
+
* The SDK stops resending a start once pollOutageMs (2 minutes by default)
|
|
15
|
+
* has run out after its first copy's retries, so an hour is ample; each
|
|
15
16
|
* cleanup then removes the ones older than that, so they never pile up.
|
|
16
17
|
*/
|
|
17
18
|
export declare const CLEANED_KEEP_MIN = 60;
|
package/dist/cjs/shell.js
CHANGED
|
@@ -64,7 +64,8 @@ exports.RUN_ID_RE = /^run_[0-9a-f]{16}$/;
|
|
|
64
64
|
/**
|
|
65
65
|
* Minutes (by the sandbox's clock) a cleaned or abandoned run's directory
|
|
66
66
|
* stays: what keeps a resend of its start from running the command again.
|
|
67
|
-
* The SDK stops resending a start
|
|
67
|
+
* The SDK stops resending a start once pollOutageMs (2 minutes by default)
|
|
68
|
+
* has run out after its first copy's retries, so an hour is ample; each
|
|
68
69
|
* cleanup then removes the ones older than that, so they never pile up.
|
|
69
70
|
*/
|
|
70
71
|
exports.CLEANED_KEEP_MIN = 60;
|
package/dist/cjs/toolbox.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export declare function rawExec(toolbox: Toolbox, req: RawExecRequest, options:
|
|
|
30
30
|
deadline?: number | undefined;
|
|
31
31
|
/** What the command does, for an error that says whether it happened (`exec`: a command unless given). */
|
|
32
32
|
effect?: Effect | undefined;
|
|
33
|
+
/** An earlier copy of a keyed start may have reached the sandbox. */
|
|
34
|
+
acted?: boolean | undefined;
|
|
33
35
|
}): Promise<ExecResult>;
|
|
34
36
|
/** A helper command the SDK sends itself; a non-zero exit is the caller's to judge. */
|
|
35
37
|
export declare function helperExec(toolbox: Toolbox, command: string, options?: {
|
package/dist/cjs/toolbox.js
CHANGED
|
@@ -51,6 +51,7 @@ async function rawExec(toolbox, req, options) {
|
|
|
51
51
|
deadline: options.deadline,
|
|
52
52
|
sandboxId: toolbox.sandboxId,
|
|
53
53
|
...(effect === undefined ? {} : { effect }),
|
|
54
|
+
...(options.acted ? { acted: true } : {}),
|
|
54
55
|
});
|
|
55
56
|
const doc = result.json;
|
|
56
57
|
if (doc === null || typeof doc !== 'object' || typeof doc.exitCode !== 'number') {
|