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