@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/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: long enough to ride out a dropped connection
86
- * or an edge that restarts. The Python SDK's DEFAULT_POLL_OUTAGE.
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
- if (spec.json !== undefined) {
397
- headers['content-type'] = 'application/json';
398
- body = JSON.stringify(spec.json);
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
- if (effect !== undefined && spec.retry !== 'safe')
505
- error = (0, outcome_js_1.withOutcome)(error, effect, spec.retry === 'staged' && acted ? 'maybe' : 'never');
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`, { sandboxId, cause: outcome.error })
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
- throw failed(new errors_js_1.PrizedError('unexpected_redirect', null, { status: answer.status, sandboxId }));
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)));
@@ -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';
@@ -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
@@ -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)}. It never reached the sandbox, so it had no effect.`);
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
@@ -279,6 +279,7 @@ async function download(toolbox, path, signal) {
279
279
  retry: 'safe',
280
280
  read: 'bytes',
281
281
  timeoutMs: READ_MS,
282
+ stallMs: http_js_1.timing.transferStallMs,
282
283
  deadline: toolbox.config.now() + READ_MS,
283
284
  signal,
284
285
  sandboxId: toolbox.sandboxId,
@@ -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). Safe to repeat: sandboxes already being
132
- * deleted are in the answer again, and none matching resolves `[]`. Each
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). */
@@ -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). Safe to repeat: sandboxes already being
444
- * deleted are in the answer again, and none matching resolves `[]`. Each
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
- const result = await (0, http_js_1.request)(config, { method: 'DELETE', url: apiUrl(config, `/sandboxes${query}`), retry: 'safe', deadline });
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, `${sandboxPathFor(this.id)}/fork`),
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
- if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 22 || port === 81) {
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
@@ -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 within minutes, so an hour is ample; each
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 within minutes, so an hour is ample; each
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;
@@ -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?: {
@@ -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') {