@prized/sdk 0.1.2 → 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 CHANGED
@@ -13,6 +13,10 @@ console.log(stdout); // 42
13
13
  await sandbox.delete();
14
14
  ```
15
15
 
16
+ Save it as `hello.mjs` and run `node hello.mjs` (Node 18 or later) with
17
+ `PRIZED_API_KEY` set ([Authentication](#authentication)). Top-level `await`
18
+ needs an ES module, so a `.js` file needs `"type": "module"` in package.json.
19
+
16
20
  Sandboxes are in beta. A workspace on free credit alone runs them on the
17
21
  free tier: up to 10 at once, each up to 2 vCPU and 4 GB of memory and 60
18
22
  minutes of running. A top-up or a paid plan lifts the limits: a sandbox then
@@ -301,9 +305,9 @@ Python's buffering), so a Python program's output arrives as it prints.
301
305
  Commands run as the user `prized` (home `/home/prized`, no sudo). The
302
306
  templates come with git, curl, Python 3 with pip and venv, Node 22 with npm
303
307
  and pnpm; `prized-agent-v1` adds Claude Code and Codex. Install what else
304
- you need into your home: Python packages into a venv
305
- (`python3 -m venv ~/.venv && ~/.venv/bin/pip install numpy`; plain
306
- `pip install`, `--user` too, is refused), and `npm` with a user prefix.
308
+ you need into your home: `pip install numpy` goes to `~/.local` (or use a
309
+ venv: `python3 -m venv ~/.venv && ~/.venv/bin/pip install numpy`), and
310
+ `npm install -g` to `~/.local` too.
307
311
 
308
312
  ## Background commands
309
313
 
@@ -423,11 +427,6 @@ prized sandbox timeout sbx_0123456789abcdef --in 30m # deleted 30 minutes from
423
427
  On a paid plan an extend stops at 7 days from now; on the free tier neither
424
428
  moves the end later.
425
429
 
426
- <!-- Held while sandbox ports at a URL are switched off: this section stays
427
- out of the rendered README until they are on. In the change that turns them
428
- on, delete these five lines and the closing line after the section, take
429
- "a sandbox's ports at a URL" out of "Not yet" under Limits, and delete the
430
- test "the README holds A port at a URL" (test/preview.test.ts).
431
430
  ## A port at a URL
432
431
 
433
432
  `sandbox.previewUrl(port)` is the https URL a port of the sandbox answers at:
@@ -454,15 +453,47 @@ the preview URL, and one that points anywhere else comes back as
454
453
  `Prized-Location` instead, which no client follows. To follow a redirect,
455
454
  resolve its `Location` against the preview URL and send `previewHeaders()`
456
455
  again only when the result has the preview URL's origin, scheme included.
457
- Call preview URLs from server-side code: a browser hides `Location` from
458
- `'manual'`.
456
+ Call preview URLs with the key from server-side code: a browser hides
457
+ `Location` from `'manual'`.
459
458
 
460
459
  Nothing is created: the URL is the sandbox's own, and every port has one
461
- (but 22 and 81). A request to a paused sandbox resumes it and waits up to 10
462
- seconds for it, as `exec` does; send `Prized-Auto-Resume: false` to get
463
- `503` instead. `previewUrl` throws `preview_unavailable` where this is not
464
- turned on.
465
- -->
460
+ (but 22 and 81). A request with the key to a paused sandbox resumes it and
461
+ waits up to 10 seconds for it, as `exec` does; send
462
+ `Prized-Auto-Resume: false` to get `503` instead. `previewUrl` throws
463
+ `preview_unavailable` where this is not turned on.
464
+
465
+ A person in a browser, or a client that cannot send the key in a header (a
466
+ WebSocket from a browser page, a program you hand one port to and not your
467
+ key), takes a short-lived token in the URL instead:
468
+
469
+ ```ts
470
+ const { browserUrl, url, expiresAt } = await sandbox.previewToken(3000, { expiresIn: 600 });
471
+ // browserUrl: https://3000-sbx-….prized.link/?prized_token=… opens in a tab or an iframe
472
+ const socket = new WebSocket(url.replace('https:', 'wss:'));
473
+ ```
474
+
475
+ `browserUrl` is the port on `prized.link`, a domain of its own for pages your
476
+ sandboxes serve: open it in a tab or embed it in an iframe. The browser swaps
477
+ the token for a cookie for that one host, so the page's own assets, fetches
478
+ and WebSockets work, and a tab's first visit shows a short notice that the
479
+ page is hosted by a Prized user. `url` is the same token on the preview URL,
480
+ for programs. The token opens that one port until `expiresAt` (an hour by
481
+ default, a day at most), or until your key is revoked; the app never sees
482
+ it. To let anyone reach a port with no credential at all, make it public:
483
+
484
+ ```ts
485
+ const { browserUrl } = await sandbox.setPortPublic(3000, true, { expiresIn: 7 * 86400 });
486
+ await sandbox.setPortPublic(3000, false); // private again
487
+ ```
488
+
489
+ Tokens and public ports need a paid workspace (`share_links_need_payment`
490
+ otherwise), and making a port public takes the workspace owner's key. On the
491
+ preview URL they answer programs, `fetch` and WebSockets but not a page
492
+ opened in a browser tab (`403 ingress_browser_page`, which names the
493
+ `browserUrl`); on `browserUrl` they open in a browser too. Neither wakes a
494
+ paused sandbox: a request gets `503` with `error: 'sandbox_not_running'`
495
+ until something with the key resumes it. Making a port public or private
496
+ again ends the tokens made for it.
466
497
 
467
498
  ## Fork
468
499
 
@@ -538,7 +569,7 @@ try {
538
569
  | `sandbox_not_running` | 409 | The sandbox is not running and the call could not wake it: it is resizing (`refresh()` until it leaves `resizing`, then retry), failed, or being deleted. A paused sandbox is resumed by any command or file call. The SDK never turns waking off; a request that carries `Prized-Auto-Resume: false` (added by a custom `fetch`, say) gets this for a paused or starting sandbox too: drop the header to let the call wake it and wait. |
539
570
  | `sandbox_not_ready` | 503 | A paused or starting sandbox did not reach running within the edge's 10-second wait. The wait starts when the edge, having checked the key, finds the sandbox not running, and calls with the same key arriving during it share it, so a later call may wait less. Nothing ran; retry. |
540
571
  | `parent_not_running` | 409 | Only a running or paused sandbox can be forked. |
541
- | `parent_not_forkable` | 409 | This sandbox cannot be forked. `reason` `resized_while_paused`: it has no memory to copy; resume it, then fork it. `reason` `parent_too_big`: its machine cannot hold even one copy beside it; fork a smaller sandbox. |
572
+ | `parent_not_forkable` | 409 | This sandbox cannot be forked. `reason` `resized_while_paused` or `memory_discarded`: it has no memory to copy; resume it, then fork it. `reason` `parent_too_big`: its machine cannot hold even one copy beside it; fork a smaller sandbox. |
542
573
  | `fork_capacity` | 409 | The parent's machine has room for only `fits` more copies (`err.body?.fits`). If `fits` is above 0, fork that many or fewer; or try again later, or start a new sandbox and fork that one, keeping the parent until it runs. The new sandbox and each copy count toward your quota: at the limit, delete sandboxes you no longer need with `delete({ wait: true })` (a deleting sandbox still counts), or top up on the free tier. Copy out what you need first, and never delete a parent while a copy forked from it is still starting. |
543
574
  | `fork_unsupported_host` | 409 | The parent's machine cannot start copies of it. With `reason` `host_retiring` (`err.reason`), the machine is being replaced: it keeps the parent but starts no new sandboxes, so no retry or smaller `count` helps. Create a new sandbox, set it up again, and fork that one. |
544
575
  | `sandbox_capacity` | 503 | No sandbox capacity is free right now. Nothing was created or billed; the SDK retries it after `Retry-After`. |
@@ -666,8 +697,8 @@ runtime) are on the pricing and docs pages at prized.dev; a create or resume
666
697
  past one fails with `sandbox_quota_exceeded`.
667
698
 
668
699
  Not yet: custom templates (from a Dockerfile), egress allow and deny lists,
669
- a sandbox's ports at a URL, more than 32 vCPU or 128 GB, GPUs, and regions
670
- other than US West.
700
+ a port URL that opens without an API key (for a browser or a webhook), more
701
+ than 32 vCPU or 128 GB, GPUs, and regions other than US West.
671
702
 
672
703
  ## Runtimes
673
704
 
@@ -130,7 +130,7 @@ export type RetryMode = 'safe' | 'exec' | 'staged';
130
130
  export declare function neverSent(error: unknown, depth?: number): boolean;
131
131
  export declare function retryable(error: PrizedError, mode: RetryMode, method: RequestSpec['method']): boolean;
132
132
  export interface RequestSpec {
133
- method: 'GET' | 'POST' | 'DELETE';
133
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
134
134
  /** The URL, or one built per attempt from the ms left before `deadline`
135
135
  * (Infinity without one): a held request asks only for what is left. */
136
136
  url: string | ((leftMs: number) => string);
package/dist/cjs/http.js CHANGED
@@ -248,6 +248,8 @@ const NEVER_RETRY = new Set([
248
248
  'idempotency_conflict',
249
249
  'paid_balance_required',
250
250
  'insufficient_balance',
251
+ // A 503 that says preview links are off on this deployment: no wait turns them on.
252
+ 'preview_unavailable',
251
253
  ]);
252
254
  const EXEC_UNDELIVERED = new Set([
253
255
  'rate_limited',
@@ -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';
@@ -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. */
@@ -194,6 +230,35 @@ export declare class Sandbox {
194
230
  * `redirect: 'manual'`: fetch resends custom headers to whatever host a
195
231
  * redirect names, so only ever send them to the sandbox's preview URL. */
196
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;
197
262
  /** POST the verb, the call and its retries within `deadline`; with `hold`,
198
263
  * held by the API until the sandbox is that (each attempt asks only for
199
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
  }
@@ -561,9 +568,13 @@ class Sandbox {
561
568
  body.maxRuntimeMin = maxRuntimeMin;
562
569
  const idempotencyKey = options.idempotencyKey ?? randomKey();
563
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;
564
575
  const send = (deadline, attempts) => (0, http_js_1.request)(this.config, {
565
576
  method: 'POST',
566
- url: apiUrl(this.config, `${sandboxPathFor(this.id)}/fork`),
577
+ url: (leftMs) => apiUrl(this.config, held && attempts !== undefined ? `${path}${holdQuery(this.config, 'running', leftMs)}` : path),
567
578
  json: wire,
568
579
  headers: { 'idempotency-key': idempotencyKey },
569
580
  retry: 'safe',
@@ -686,9 +697,7 @@ class Sandbox {
686
697
  * `preview_unavailable` when the deployment has sandbox ingress off.
687
698
  */
688
699
  previewUrl(port) {
689
- if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 22 || port === 81) {
690
- 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 });
691
- }
700
+ this.previewPort(port);
692
701
  const template = this.current.raw.previewUrlTemplate;
693
702
  if (typeof template !== 'string' || !template.includes('{port}')) {
694
703
  throw new errors_js_1.PrizedError('preview_unavailable', 'sandbox ports at a URL are not turned on for this deployment', { sandboxId: this.id });
@@ -702,6 +711,93 @@ class Sandbox {
702
711
  previewHeaders() {
703
712
  return { [exports.PREVIEW_KEY_HEADER]: this.config.apiKey };
704
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
+ }
705
801
  // --- internals ------------------------------------------------------------------
706
802
  /** POST the verb, the call and its retries within `deadline`; with `hold`,
707
803
  * held by the API until the sandbox is that (each attempt asks only for
@@ -1,2 +1,2 @@
1
1
  /** This package's version; test/version.test.ts holds it equal to package.json. */
2
- export declare const VERSION = "0.1.2";
2
+ export declare const VERSION = "0.1.3";
@@ -2,4 +2,4 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  /** This package's version; test/version.test.ts holds it equal to package.json. */
5
- exports.VERSION = '0.1.2';
5
+ exports.VERSION = '0.1.3';
@@ -130,7 +130,7 @@ export type RetryMode = 'safe' | 'exec' | 'staged';
130
130
  export declare function neverSent(error: unknown, depth?: number): boolean;
131
131
  export declare function retryable(error: PrizedError, mode: RetryMode, method: RequestSpec['method']): boolean;
132
132
  export interface RequestSpec {
133
- method: 'GET' | 'POST' | 'DELETE';
133
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
134
134
  /** The URL, or one built per attempt from the ms left before `deadline`
135
135
  * (Infinity without one): a held request asks only for what is left. */
136
136
  url: string | ((leftMs: number) => string);
package/dist/esm/http.js CHANGED
@@ -232,6 +232,8 @@ const NEVER_RETRY = new Set([
232
232
  'idempotency_conflict',
233
233
  'paid_balance_required',
234
234
  'insufficient_balance',
235
+ // A 503 that says preview links are off on this deployment: no wait turns them on.
236
+ 'preview_unavailable',
235
237
  ]);
236
238
  const EXEC_UNDELIVERED = new Set([
237
239
  'rate_limited',
@@ -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';
@@ -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. */
@@ -194,6 +230,35 @@ export declare class Sandbox {
194
230
  * `redirect: 'manual'`: fetch resends custom headers to whatever host a
195
231
  * redirect names, so only ever send them to the sandbox's preview URL. */
196
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;
197
262
  /** POST the verb, the call and its retries within `deadline`; with `hold`,
198
263
  * held by the API until the sandbox is that (each attempt asks only for
199
264
  * what is left). */
@@ -4,7 +4,7 @@ import { execCommand } from './commands.js';
4
4
  import { PrizedError } from './errors.js';
5
5
  import { Files } from './files.js';
6
6
  import { callDeadline, cutByDeadline, MAX_RETRY_AFTER_MS, Polls, request, resolveConfig, toolboxBaseFor, warmEdge, } from './http.js';
7
- import { parseSandbox, parseTemplate, resourcesToWire, } from './types.js';
7
+ import { parseSandbox, parseTemplate, parseTime, resourcesToWire, } from './types.js';
8
8
  /** The header a request to a sandbox port's URL carries the API key in. */
9
9
  export const PREVIEW_KEY_HEADER = 'Prized-Key';
10
10
  /** The template a create uses when it names none. */
@@ -73,6 +73,13 @@ function wholeMinutes(name, value, allowZero) {
73
73
  }
74
74
  return value;
75
75
  }
76
+ function wholeSeconds(name, value) {
77
+ if (value === undefined)
78
+ return undefined;
79
+ if (!Number.isInteger(value) || value <= 0)
80
+ throw new PrizedError('invalid_argument', `${name} must be a whole number of seconds > 0`);
81
+ return value;
82
+ }
76
83
  function apiUrl(config, path) {
77
84
  return `${config.baseUrl}/api/v1${path}`;
78
85
  }
@@ -558,9 +565,13 @@ export class Sandbox {
558
565
  body.maxRuntimeMin = maxRuntimeMin;
559
566
  const idempotencyKey = options.idempotencyKey ?? randomKey();
560
567
  const wire = snapshot(body); // as in create
568
+ const path = `${sandboxPathFor(this.id)}/fork`;
569
+ // Held by the API until every copy runs (or its bound), as create is,
570
+ // instead of polled; the cleanup's replay only wants the ids.
571
+ const held = options.wait !== false;
561
572
  const send = (deadline, attempts) => request(this.config, {
562
573
  method: 'POST',
563
- url: apiUrl(this.config, `${sandboxPathFor(this.id)}/fork`),
574
+ url: (leftMs) => apiUrl(this.config, held && attempts !== undefined ? `${path}${holdQuery(this.config, 'running', leftMs)}` : path),
564
575
  json: wire,
565
576
  headers: { 'idempotency-key': idempotencyKey },
566
577
  retry: 'safe',
@@ -683,9 +694,7 @@ export class Sandbox {
683
694
  * `preview_unavailable` when the deployment has sandbox ingress off.
684
695
  */
685
696
  previewUrl(port) {
686
- if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 22 || port === 81) {
687
- throw new PrizedError('bad_request', `port must be a whole number from 1 to 65535 other than 22 and 81, not ${String(port)}`, { sandboxId: this.id });
688
- }
697
+ this.previewPort(port);
689
698
  const template = this.current.raw.previewUrlTemplate;
690
699
  if (typeof template !== 'string' || !template.includes('{port}')) {
691
700
  throw new PrizedError('preview_unavailable', 'sandbox ports at a URL are not turned on for this deployment', { sandboxId: this.id });
@@ -699,6 +708,93 @@ export class Sandbox {
699
708
  previewHeaders() {
700
709
  return { [PREVIEW_KEY_HEADER]: this.config.apiKey };
701
710
  }
711
+ /**
712
+ * A short-lived token for `port`, for a client that cannot send
713
+ * `Prized-Key` in a header: a person in a browser tab, a WebSocket opened
714
+ * from a browser page, or a program you hand this one port to and not
715
+ * your API key. `browserUrl` is the port on prized.link with the token in
716
+ * its `prized_token` query parameter: it opens in a browser tab or a
717
+ * frame (the token moves into a cookie for that host; a tab's first visit
718
+ * shows a notice). `url` is the same on the preview URL, for programs. The
719
+ * app never sees the token. It opens the port until `expiresAt`
720
+ * (`expiresIn` seconds, 60 to 86 400, an hour by default), or until this
721
+ * client's key is revoked; making the port public or private again ends
722
+ * it too. It never wakes a paused sandbox (`503 sandbox_not_running`).
723
+ * Needs a paid workspace (`share_links_need_payment` otherwise).
724
+ */
725
+ async previewToken(port, options = {}) {
726
+ this.previewPort(port);
727
+ const expiresIn = wholeSeconds('expiresIn', options.expiresIn);
728
+ const result = await request(this.config, {
729
+ method: 'POST',
730
+ url: apiUrl(this.config, `${sandboxPathFor(this.id)}/ports/${port}/token`),
731
+ json: expiresIn === undefined ? {} : { expiresIn },
732
+ // A second copy mints a second token nobody holds, which expires on its own.
733
+ retry: 'safe',
734
+ signal: options.signal,
735
+ deadline: callDeadline(this.config),
736
+ sandboxId: this.id,
737
+ });
738
+ const body = result.json;
739
+ const expiresAt = parseTime(body?.expiresAt);
740
+ if (typeof body?.url !== 'string' || typeof body.token !== 'string' || expiresAt === null) {
741
+ throw new PrizedError('bad_response', 'the API answered a preview token without its url, token or expiry', { sandboxId: this.id });
742
+ }
743
+ return {
744
+ url: body.url,
745
+ browserUrl: typeof body.browserUrl === 'string' ? body.browserUrl : null,
746
+ token: body.token,
747
+ expiresAt,
748
+ };
749
+ }
750
+ /**
751
+ * Make `port` public (anyone with its URL reaches it, with no
752
+ * credential) for `expiresIn` seconds (300 to 2 592 000, 7 days by
753
+ * default), or private again with `false`. Public needs the workspace
754
+ * owner's key and a paid workspace, and counts toward the workspace's 10
755
+ * public ports and links. The answer's `browserUrl` (prized.link) opens in
756
+ * a browser tab (a tab's first visit shows a notice); `url` answers
757
+ * programs, `fetch` and WebSockets. A public port never wakes a paused
758
+ * sandbox. A port already public keeps its expiry: make it private, then
759
+ * public again, to change it.
760
+ */
761
+ async setPortPublic(port, isPublic, options = {}) {
762
+ this.previewPort(port);
763
+ if (typeof isPublic !== 'boolean')
764
+ throw new PrizedError('invalid_argument', 'isPublic must be true or false', { sandboxId: this.id });
765
+ const expiresIn = wholeSeconds('expiresIn', options.expiresIn);
766
+ if (expiresIn !== undefined && !isPublic) {
767
+ throw new PrizedError('invalid_argument', 'expiresIn applies only when making a port public', { sandboxId: this.id });
768
+ }
769
+ const result = await request(this.config, {
770
+ method: 'PUT',
771
+ url: apiUrl(this.config, `${sandboxPathFor(this.id)}/ports/${port}`),
772
+ json: isPublic ? { public: true, ...(expiresIn === undefined ? {} : { expiresIn }) } : { public: false },
773
+ // Setting the same state again changes nothing.
774
+ retry: 'safe',
775
+ signal: options.signal,
776
+ deadline: callDeadline(this.config),
777
+ sandboxId: this.id,
778
+ });
779
+ const doc = result.json?.preview;
780
+ if (doc === undefined || typeof doc.url !== 'string' || typeof doc.public !== 'boolean') {
781
+ throw new PrizedError('bad_response', 'the API answered a port without its url or public setting', { sandboxId: this.id });
782
+ }
783
+ return {
784
+ sandboxId: typeof doc.sandboxId === 'string' ? doc.sandboxId : this.id,
785
+ port: typeof doc.port === 'number' ? doc.port : port,
786
+ url: doc.url,
787
+ browserUrl: typeof doc.browserUrl === 'string' ? doc.browserUrl : null,
788
+ public: doc.public,
789
+ publicExpiresAt: parseTime(doc.publicExpiresAt),
790
+ };
791
+ }
792
+ /** A port a preview link may name: 1 to 65 535, never 22 or 81 (the sandbox's own). */
793
+ previewPort(port) {
794
+ if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 22 || port === 81) {
795
+ throw new PrizedError('bad_request', `port must be a whole number from 1 to 65535 other than 22 and 81, not ${String(port)}`, { sandboxId: this.id });
796
+ }
797
+ }
702
798
  // --- internals ------------------------------------------------------------------
703
799
  /** POST the verb, the call and its retries within `deadline`; with `hold`,
704
800
  * held by the API until the sandbox is that (each attempt asks only for
@@ -1,2 +1,2 @@
1
1
  /** This package's version; test/version.test.ts holds it equal to package.json. */
2
- export declare const VERSION = "0.1.2";
2
+ export declare const VERSION = "0.1.3";
@@ -1,2 +1,2 @@
1
1
  /** This package's version; test/version.test.ts holds it equal to package.json. */
2
- export const VERSION = '0.1.2';
2
+ export const VERSION = '0.1.3';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prized/sdk",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Prized Sandboxes (beta): create microVM sandboxes, run commands, move files, pause, resume and fork them from TypeScript.",
5
5
  "homepage": "https://prized.dev/docs/sandboxes",
6
6
  "license": "Apache-2.0",