@prized/sdk 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/esm/http.js CHANGED
@@ -17,7 +17,15 @@ function envApiKey() {
17
17
  function isLoopback(hostname) {
18
18
  return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
19
19
  }
20
- /** An http(s) origin, trailing slashes and a trailing /api/v1 dropped. */
20
+ /**
21
+ * https anywhere, plain http only to this machine: the API key rides in
22
+ * every request's Authorization header. The same rule for the options and
23
+ * for an edge the API names.
24
+ */
25
+ function secureEndpoint(url) {
26
+ return url.protocol === 'https:' || (url.protocol === 'http:' && isLoopback(url.hostname));
27
+ }
28
+ /** An https (or loopback http) origin, trailing slashes and a trailing /api/v1 dropped. */
21
29
  function normalizeOrigin(name, raw) {
22
30
  let url;
23
31
  try {
@@ -29,6 +37,9 @@ function normalizeOrigin(name, raw) {
29
37
  if (url.protocol !== 'https:' && url.protocol !== 'http:') {
30
38
  throw new PrizedError('invalid_argument', `${name} must be an http(s) URL`);
31
39
  }
40
+ if (!secureEndpoint(url)) {
41
+ throw new PrizedError('invalid_argument', `${name} must use https (plain http only for localhost, 127.0.0.1 or [::1])`);
42
+ }
32
43
  if (url.username || url.password || url.search || url.hash) {
33
44
  throw new PrizedError('invalid_argument', `${name} must not carry credentials, a query or a fragment`);
34
45
  }
@@ -53,10 +64,10 @@ export function abortableSleep(ms, signal) {
53
64
  });
54
65
  }
55
66
  /**
56
- * How long a streamed or long exec keeps polling while its polls fail
57
- * before it stops the command (pollOutageMs): long enough to ride out a
58
- * dropped connection or an edge that restarts. The Python SDK's
59
- * DEFAULT_POLL_OUTAGE.
67
+ * How long a wait (a streamed or long exec, a handle's wait()) keeps polling
68
+ * while its polls fail before it gives up (pollOutageMs), and a streamed or
69
+ * long exec stops its command: long enough to ride out a dropped connection
70
+ * or an edge that restarts. The Python SDK's DEFAULT_POLL_OUTAGE.
60
71
  */
61
72
  export const DEFAULT_POLL_OUTAGE_MS = 120_000;
62
73
  /** A pollOutageMs given (a number of ms ≥ 0), or undefined. */
@@ -100,20 +111,51 @@ export function resolveConfig(options = {}) {
100
111
  random: options.random ?? Math.random,
101
112
  };
102
113
  }
114
+ /** Prized's own domain: its API and its edges are hosts under it. */
115
+ const PRIZED_DOMAIN = 'prized.dev';
116
+ function under(host, domain) {
117
+ return host === domain || host.endsWith(`.${domain}`);
118
+ }
119
+ /** A URL's hostname (lowercase, as URL gives it), or '' if it does not parse. */
120
+ function hostOf(raw) {
121
+ try {
122
+ return new URL(raw).hostname;
123
+ }
124
+ catch {
125
+ return '';
126
+ }
127
+ }
128
+ /**
129
+ * Where a toolboxBase may point, given the API's host: the API host itself;
130
+ * prized.dev's own hosts when the API is one of them; loopback when the API
131
+ * is. Never a domain guessed from the API host's name (a public suffix such
132
+ * as co.uk would trust everyone under it). The Python SDK's _trusted_edge.
133
+ */
134
+ function trustedEdge(edgeHost, apiHost) {
135
+ if (edgeHost === '' || apiHost === '')
136
+ return false;
137
+ if (isLoopback(apiHost) || isLoopback(edgeHost))
138
+ return isLoopback(apiHost) && isLoopback(edgeHost);
139
+ return edgeHost === apiHost || (under(apiHost, PRIZED_DOMAIN) && under(edgeHost, PRIZED_DOMAIN));
140
+ }
103
141
  /**
104
142
  * Where exec and files go for one sandbox. An explicit edgeUrl wins; else the
105
143
  * API's own `toolboxBase` when it has the expected shape (https, or plain
106
- * http on loopback for local development); else the default edge.
144
+ * http on loopback for local development, ending /v1/sandbox/<id>) on a host
145
+ * trusted beside the API's (term.prized.dev beside api.prized.dev; see
146
+ * trustedEdge); else the default edge, for Prized's own API only. The API key
147
+ * is never sent anywhere else: discovery is routing, not authority.
107
148
  */
108
149
  export function toolboxBaseFor(config, sandboxId, toolboxBase) {
109
150
  const path = `/v1/sandbox/${encodeURIComponent(sandboxId)}`;
110
151
  if (config.edgeUrl !== null)
111
152
  return `${config.edgeUrl}${path}`;
153
+ const apiHost = hostOf(config.baseUrl);
112
154
  if (toolboxBase) {
113
155
  try {
114
156
  const url = new URL(toolboxBase);
115
- const secure = url.protocol === 'https:' || (url.protocol === 'http:' && isLoopback(url.hostname));
116
- if (secure &&
157
+ if (secureEndpoint(url) &&
158
+ trustedEdge(url.hostname, apiHost) &&
117
159
  !url.username &&
118
160
  !url.password &&
119
161
  !url.search &&
@@ -126,7 +168,11 @@ export function toolboxBaseFor(config, sandboxId, toolboxBase) {
126
168
  // fall through to the default
127
169
  }
128
170
  }
129
- return `${DEFAULT_EDGE_URL}${path}`;
171
+ // Prized's own edge only for Prized's own API: a key for another
172
+ // deployment is never sent to it. That deployment names its edge.
173
+ if (under(apiHost, PRIZED_DOMAIN))
174
+ return `${DEFAULT_EDGE_URL}${path}`;
175
+ throw new PrizedError('edge_unknown', `The API at ${config.baseUrl} named no command endpoint this SDK trusts for ${sandboxId}; pass edgeUrl for that deployment.`, { sandboxId });
130
176
  }
131
177
  /**
132
178
  * The edge this config's sandboxes answer on, before the API has named one:
@@ -223,7 +269,7 @@ export function retryable(error, mode, method) {
223
269
  // have done a write's work, so a write is left to its caller (a create
224
270
  // that may have made a sandbox finds and deletes it). A GET did nothing:
225
271
  // a poll that meets one is sent again rather than failing its create.
226
- if (error.status === 500 && method === 'GET')
272
+ if (mode === 'safe' && error.status === 500 && method === 'GET')
227
273
  return true;
228
274
  return error.status === 429 || error.status === 502 || error.status === 503 || error.status === 504;
229
275
  }
@@ -410,8 +456,9 @@ export function outcomeOf(error) {
410
456
  /**
411
457
  * The error a request throws. With an `effect`, it says whether the sandbox
412
458
  * acted on the request: by this attempt, or by an earlier one that was sent
413
- * again (`acted`: only a keyed request, the background start, is sent again
414
- * after it may have arrived). `cutStatus`: the 2xx whose body broke off.
459
+ * again (`acted`: only a keyed request, the background start, and a staged
460
+ * upload are sent again after they may have arrived). `cutStatus`: the 2xx
461
+ * whose body broke off.
415
462
  */
416
463
  function raised(failure, outcome, acted, effect, cutStatus = null) {
417
464
  if (effect === undefined)
@@ -426,7 +473,8 @@ export async function request(config, spec) {
426
473
  const timeoutMs = spec.timeoutMs ?? config.requestTimeoutMs;
427
474
  const sandboxId = spec.sandboxId ?? null;
428
475
  const effect = spec.effect ?? (spec.retry === 'exec' ? commandEffect() : undefined);
429
- const unsent = spec.retry === 'exec' ? effect : undefined;
476
+ // Whether an attempt that was sent again may have reached the sandbox.
477
+ let acted = false;
430
478
  // Names the last attempt's failure, if any, so a call cut short by its
431
479
  // deadline still says what it was waiting out (a 503, a rate limit).
432
480
  const pastDeadline = (last) => {
@@ -434,9 +482,10 @@ export async function request(config, spec) {
434
482
  const tail = what === null ? '' : ` The last attempt failed with ${what}.`;
435
483
  let error = new PrizedError('timeout', `The time allowed for this call ran out.${tail}`, { sandboxId, cause: last });
436
484
  // A command is sent again only when it provably never arrived, so every
437
- // attempt of one cut short this way never reached the sandbox.
438
- if (unsent !== undefined)
439
- error = withOutcome(error, unsent, 'never');
485
+ // attempt of one cut short this way never reached the sandbox. A staged
486
+ // upload's copies may have, unless each provably never arrived.
487
+ if (effect !== undefined && spec.retry !== 'safe')
488
+ error = withOutcome(error, effect, spec.retry === 'staged' && acted ? 'maybe' : 'never');
440
489
  cutShort.add(error);
441
490
  return error;
442
491
  };
@@ -445,8 +494,6 @@ export async function request(config, spec) {
445
494
  return error;
446
495
  };
447
496
  let last;
448
- // Whether an attempt that was sent again may have reached the sandbox.
449
- let acted = false;
450
497
  // An abort with an `effect` says whether the sandbox may have acted:
451
498
  // before an attempt, or while waiting to send one again, nothing was sent
452
499
  // that might have, unless an earlier attempt of a keyed request (`acted`)
@@ -483,8 +530,9 @@ export async function request(config, spec) {
483
530
  // The status came and the body did not: the sandbox acted on it.
484
531
  const cut = outcome.status !== null && outcome.status >= 200 && outcome.status < 300;
485
532
  const said = cut ? 'did' : unsentAttempt ? 'never' : 'maybe';
486
- // A command or upload is sent again only if it never left.
487
- const resend = spec.retry === 'safe' || (spec.retry === 'exec' && unsentAttempt);
533
+ // A command is sent again only if it never left; a staged upload
534
+ // unless its 2xx came (its bytes are there).
535
+ const resend = spec.retry === 'safe' || (spec.retry === 'exec' && unsentAttempt) || (spec.retry === 'staged' && !cut);
488
536
  if (!resend || attempt >= config.maxRetries)
489
537
  throw raised(failure, said, acted, effect, cut ? outcome.status : null);
490
538
  acted = acted || said !== 'never';
@@ -4,9 +4,9 @@ export { CommandHandle, DEFAULT_EXEC_TIMEOUT_MS, MAX_BLOCKING_EXEC_MS } from './
4
4
  export type { CommandStatus, ExecOptions, WaitOptions } from './commands.js';
5
5
  export { Files } from './files.js';
6
6
  export type { FileData, WriteOptions } from './files.js';
7
- export { CommandTimeoutError, PrizedError } from './errors.js';
8
- export type { PrizedErrorInit } from './errors.js';
7
+ export { CommandTimeoutError, OutputReadError, PrizedError } from './errors.js';
8
+ export type { OutputReadErrorInit, PrizedErrorInit } from './errors.js';
9
9
  export { DEFAULT_BASE_URL, DEFAULT_EDGE_URL } from './http.js';
10
10
  export type { ClientOptions } from './http.js';
11
- export type { ExecResult, FileInfo, Resources, ResourcesInput, SandboxInfo, SandboxState, Template, WriteInfo, } from './types.js';
11
+ export type { ExecResult, FileInfo, Resources, ResourcesInput, SandboxInfo, SandboxState, Template, TruncatedReason, WriteInfo, } from './types.js';
12
12
  export { VERSION } from './version.js';
package/dist/esm/index.js CHANGED
@@ -2,6 +2,6 @@
2
2
  export { Sandbox, DEFAULT_TEMPLATE, PREVIEW_KEY_HEADER } from './sandbox.js';
3
3
  export { CommandHandle, DEFAULT_EXEC_TIMEOUT_MS, MAX_BLOCKING_EXEC_MS } from './commands.js';
4
4
  export { Files } from './files.js';
5
- export { CommandTimeoutError, PrizedError } from './errors.js';
5
+ export { CommandTimeoutError, OutputReadError, PrizedError } from './errors.js';
6
6
  export { DEFAULT_BASE_URL, DEFAULT_EDGE_URL } from './http.js';
7
7
  export { VERSION } from './version.js';
@@ -30,11 +30,12 @@ export declare function helperEffect(): Effect;
30
30
  export type Outcome = 'never' | 'maybe' | 'did' | null;
31
31
  /** Whether withOutcome said the sandbox may have acted on the request. */
32
32
  export declare function mayHaveActed(error: unknown): error is PrizedError;
33
- /** `error` with another message (and, given, another status, code, sandbox or cause), every other field kept. */
33
+ /** `error` with another message (and, given, another status, code, sandbox, exit code or cause), every other field kept. */
34
34
  export declare function restate(error: PrizedError, message: string, init?: {
35
35
  status?: number;
36
36
  code?: string;
37
37
  sandboxId?: string;
38
+ exitCode?: number;
38
39
  cause?: unknown;
39
40
  }): PrizedError;
40
41
  /** `error` (same code, status and body), its message saying what it means for `effect`. */
@@ -1,7 +1,9 @@
1
1
  // What an error says about a command that may have run. A command, a file
2
- // write, a remove or a signal (`exec` retry mode) is never sent again once it
3
- // may have reached the sandbox, so its error has to say what the caller cannot
4
- // see: whether it happened. The Python SDK words these the same (_core.py).
2
+ // write's rename, a remove or a signal (`exec` retry mode) is never sent again
3
+ // once it may have reached the sandbox, so its error has to say what the
4
+ // caller cannot see: whether it happened. A write's staged upload (`staged`)
5
+ // may have been sent again, and its error says whether a copy may have
6
+ // reached the sandbox. The Python SDK words these the same (_core.py).
5
7
  import { defaultMessage, PrizedError } from './errors.js';
6
8
  const NOT_SENT_AGAIN = 'It was not sent again';
7
9
  export function commandEffect() {
@@ -50,7 +52,7 @@ const acted = new WeakSet();
50
52
  export function mayHaveActed(error) {
51
53
  return error instanceof PrizedError && acted.has(error);
52
54
  }
53
- /** `error` with another message (and, given, another status, code, sandbox or cause), every other field kept. */
55
+ /** `error` with another message (and, given, another status, code, sandbox, exit code or cause), every other field kept. */
54
56
  export function restate(error, message, init = {}) {
55
57
  return new PrizedError(init.code ?? error.code, message, {
56
58
  status: init.status ?? error.status,
@@ -58,6 +60,7 @@ export function restate(error, message, init = {}) {
58
60
  sandboxId: error.sandboxId ?? init.sandboxId ?? null,
59
61
  body: error.body,
60
62
  retryAfter: error.retryAfter,
63
+ exitCode: init.exitCode ?? error.exitCode,
61
64
  cause: 'cause' in init ? init.cause : error.cause,
62
65
  });
63
66
  }
@@ -1,5 +1,5 @@
1
1
  import { type Toolbox } from './toolbox.js';
2
- import type { ExecResult } from './types.js';
2
+ import type { ExecResult, TruncatedReason } from './types.js';
3
3
  export declare const OUTPUT_DIR = "/home/prized/.prized/exec";
4
4
  export declare const OUTPUT_ID_RE: RegExp;
5
5
  /** Each stream of a fullOutput command keeps at most this many bytes: one download. */
@@ -22,17 +22,22 @@ export declare const RUN_SCRIPT = "eval \"${PRIZED_EXEC_SCRIPT:?}\"";
22
22
  * Nothing is assigned before it runs but the positional parameters, so every
23
23
  * variable the caller's env sets reaches it unchanged, PATH included; and
24
24
  * every step holds under an errexit the env may switch on (SHELLOPTS). As in
25
- * a plain exec, the readers stop 5 s after the command ends if a process it
25
+ * a plain exec, the relays stop 5 s after the command ends if a process it
26
26
  * left running still holds its output, keeping what came through. A command
27
- * a signal ends exits 128+n, as a background command does. Without tee,
28
- * setsid or a directory to write to, the command runs as a plain exec.
27
+ * a signal ends exits 128+n, as a background command does. A reader that
28
+ * could not write its file (failedLines) sends the output to the files API
29
+ * whatever its size, with the marker saying which. Without /bin/sh, setsid,
30
+ * cat or a directory to write to, the command runs as a plain exec.
29
31
  */
30
32
  export declare function captureScript(id: string): string;
31
33
  /**
32
34
  * What a fullOutput command left when the script could not print it (its
33
35
  * time limit, or a signal, ended the script): the marker line, or `gone`,
34
36
  * with the directory removed, when there is nothing to read (it was never
35
- * made, or its output was already printed back).
37
+ * made, or its output was already printed back). A failed write is read from
38
+ * the reader's flag, before the size, which is final once the flag is there
39
+ * (the script's wait for the reader's exit status went with the script); a
40
+ * reader killed, or one that could not make its flag, is not seen here.
36
41
  */
37
42
  export declare function collectScript(id: string): string;
38
43
  /**
@@ -41,11 +46,17 @@ export declare function collectScript(id: string): string;
41
46
  * answer to remove its own.
42
47
  */
43
48
  export declare function removeOutputScript(id: string): string;
44
- /** The marker's byte counts (a stream cut at FULL_OUTPUT_BYTES reads more), `gone`, or null for no marker. */
45
- export declare function parseMarker(stdout: string, id: string): {
49
+ /** What the marker line says: each stream's byte count (one cut at FULL_OUTPUT_BYTES reads more), and whether its reader failed to write it. */
50
+ export interface Marker {
46
51
  out: number;
47
52
  err: number;
48
- } | 'gone' | null;
53
+ outFailed: boolean;
54
+ errFailed: boolean;
55
+ }
56
+ /** The marker (one without failure flags, as an earlier SDK's collect printed, has none), `gone`, or null for no marker. */
57
+ export declare function parseMarker(stdout: string, id: string): Marker | 'gone' | null;
58
+ /** Why the read-back output is cut, if it is: a failed write first, since it is the one the caller cannot foresee. */
59
+ export declare function markerTruncation(marker: Marker): TruncatedReason | null;
49
60
  /**
50
61
  * A blocking exec that keeps the whole output (up to FULL_OUTPUT_BYTES of
51
62
  * each stream). The result is a plain exec's, timeouts included: the caller