@selvajs/compute 4.2.2-beta.0 → 5.0.0-beta.10

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.
@@ -0,0 +1,1379 @@
1
+ // src/core/compute-fetch/retry.ts
2
+ const DEFAULT_RETRY = {
3
+ attempts: 0,
4
+ baseDelayMs: 500,
5
+ maxDelayMs: 3e4,
6
+ retryOn429: true
7
+ };
8
+ const RETRYABLE_STATUS = /* @__PURE__ */ new Set([
9
+ 502,
10
+ 503,
11
+ 504
12
+ ]);
13
+ /**
14
+ * Ceiling on a server-sent `Retry-After` wait. The server's window outranks
15
+ * `RetryPolicy.maxDelayMs` (retrying sooner all but guarantees another 429), but a bad or hostile
16
+ * header must not park the client for minutes. A `maxDelayMs` above this cap raises it.
17
+ */
18
+ const RETRY_AFTER_CAP_MS = 6e4;
19
+ function resolveRetryPolicy(policy) {
20
+ if (!policy) return DEFAULT_RETRY;
21
+ return {
22
+ attempts: policy.attempts ?? DEFAULT_RETRY.attempts,
23
+ baseDelayMs: policy.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
24
+ maxDelayMs: policy.maxDelayMs ?? DEFAULT_RETRY.maxDelayMs,
25
+ retryOn429: policy.retryOn429 ?? DEFAULT_RETRY.retryOn429
26
+ };
27
+ }
28
+ function isRetryableStatus(status, policy) {
29
+ return RETRYABLE_STATUS.has(status) || policy.retryOn429 && status === 429;
30
+ }
31
+ /** Delay before the next attempt: the server's `Retry-After` when it sent one, else backoff. */
32
+ function retryDelay(retryAfter, attempt, policy) {
33
+ const retryAfterMs = parseRetryAfter(retryAfter);
34
+ return retryAfterMs === null ? backoffDelay(attempt, policy) : Math.min(retryAfterMs, Math.max(policy.maxDelayMs, RETRY_AFTER_CAP_MS));
35
+ }
36
+ /** A `Retry-After` value (seconds or HTTP-date) in ms; null when missing or unparseable. */
37
+ function parseRetryAfter(headerValue) {
38
+ if (!headerValue) return null;
39
+ const seconds = Number(headerValue);
40
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
41
+ const dateMs = Date.parse(headerValue);
42
+ if (Number.isFinite(dateMs)) {
43
+ const delta = dateMs - Date.now();
44
+ return delta > 0 ? delta : 0;
45
+ }
46
+ return null;
47
+ }
48
+ function backoffDelay(attempt, policy) {
49
+ const exponential = policy.baseDelayMs * Math.pow(2, attempt);
50
+ const jitter = Math.random() * policy.baseDelayMs;
51
+ return Math.min(exponential + jitter, policy.maxDelayMs);
52
+ }
53
+ function sleep(ms, signal) {
54
+ return new Promise((resolve, reject) => {
55
+ if (signal?.aborted) {
56
+ reject(new DOMException("Aborted", "AbortError"));
57
+ return;
58
+ }
59
+ const id = setTimeout(() => {
60
+ signal?.removeEventListener("abort", onAbort);
61
+ resolve();
62
+ }, ms);
63
+ const onAbort = () => {
64
+ clearTimeout(id);
65
+ reject(new DOMException("Aborted", "AbortError"));
66
+ };
67
+ signal?.addEventListener("abort", onAbort, { once: true });
68
+ });
69
+ }
70
+ // src/core/errors.ts
71
+ const ErrorCodes = {
72
+ /** Connection failure, a gateway status (502/503/504), or a 2xx body cut off mid-stream. */
73
+ NETWORK_ERROR: "NETWORK_ERROR",
74
+ /** HTTP 401 or 403: the server rejected the credentials. */
75
+ AUTH_ERROR: "AUTH_ERROR",
76
+ /** The server rejected the request (HTTP 400, 413), or an input failed a client-side check. */
77
+ VALIDATION_ERROR: "VALIDATION_ERROR",
78
+ /** The server failed the solve (HTTP 500), or the solve reported errors. */
79
+ COMPUTATION_ERROR: "COMPUTATION_ERROR",
80
+ /** No response within `ComputeConfig.timeoutMs`. */
81
+ TIMEOUT_ERROR: "TIMEOUT_ERROR",
82
+ /** A browser fetch failed with no response: most often CORS, which the browser hides. */
83
+ CORS_ERROR: "CORS_ERROR",
84
+ /** HTTP 404: the endpoint doesn't exist on this server. Never retried. */
85
+ NOT_FOUND: "NOT_FOUND",
86
+ /** HTTP 429. Retried, honouring `Retry-After`, when the policy allows (`RetryPolicy.retryOn429`). */
87
+ RATE_LIMIT: "RATE_LIMIT",
88
+ /**
89
+ * A 2xx with a body the client can't use: declared non-JSON (a captive portal, a proxy login page)
90
+ * or JSON that isn't an object. Never retried.
91
+ */
92
+ INVALID_RESPONSE: "INVALID_RESPONSE",
93
+ /**
94
+ * The response body passed `ComputeConfig.maxResponseBytes`; the download was cancelled there.
95
+ * Never retried. `context` carries `{ maxResponseBytes }`.
96
+ */
97
+ RESPONSE_TOO_LARGE: "RESPONSE_TOO_LARGE",
98
+ /** Any failure not classified by another code. */
99
+ UNKNOWN_ERROR: "UNKNOWN_ERROR",
100
+ /** An object was used after `dispose()`, or a file download failed part-way. */
101
+ INVALID_STATE: "INVALID_STATE",
102
+ /** A caller-supplied argument is unusable: an empty definition, an unserializable body. */
103
+ INVALID_INPUT: "INVALID_INPUT",
104
+ /**
105
+ * Bad configuration: an invalid `serverUrl`, an unsendable header, an unusable number, a server
106
+ * that answers with a redirect, an incomplete logger, an interval out of range.
107
+ */
108
+ INVALID_CONFIG: "INVALID_CONFIG",
109
+ /** A browser-only API (file download) was called outside a browser. */
110
+ BROWSER_ONLY: "BROWSER_ONLY",
111
+ /** The runtime has no base64 codec: neither `Buffer` nor `atob`/`btoa`. */
112
+ ENVIRONMENT_ERROR: "ENVIRONMENT_ERROR",
113
+ /** A string that should be base64 isn't. */
114
+ ENCODING_ERROR: "ENCODING_ERROR",
115
+ /** An input's `default` had a shape the normalizer didn't recognize. */
116
+ MALFORMED_DEFAULT: "MALFORMED_DEFAULT",
117
+ /** Latest-wins scheduling: a newer call replaced this one. */
118
+ SUPERSEDED: "SUPERSEDED",
119
+ /** The scheduler or a caller-supplied `AbortSignal` aborted this call. */
120
+ ABORTED: "ABORTED",
121
+ /**
122
+ * The queue was at `maxQueueDepth`, so the call was rejected without queueing. Retryable after a
123
+ * back-off. `context` carries `{ queueDepth, maxQueueDepth }`.
124
+ */
125
+ QUEUE_FULL: "QUEUE_FULL",
126
+ /**
127
+ * The call waited longer than `queueWaitMs` without starting, and was rejected rather than run
128
+ * stale. Retryable. `context` carries `{ waitedMs, queueWaitMs }`.
129
+ */
130
+ QUEUE_TIMEOUT: "QUEUE_TIMEOUT",
131
+ /**
132
+ * The server no longer holds the definition a `pointer` named (evicted, restarted, another
133
+ * child). Retry with the full definition. Read from the error body's
134
+ * `code: "definition_not_cached"`.
135
+ */
136
+ DEFINITION_NOT_CACHED: "DEFINITION_NOT_CACHED"
137
+ };
138
+ /** Error with a typed `code`: switch on it rather than on the message. */
139
+ var ComputeError = class extends Error {
140
+ code;
141
+ statusCode;
142
+ context;
143
+ originalError;
144
+ constructor(message, code = ErrorCodes.UNKNOWN_ERROR, options) {
145
+ const original = options?.originalError;
146
+ const cause = original === void 0 || original instanceof Error ? original : new Error(String(original));
147
+ super(message, cause && { cause });
148
+ this.name = "ComputeError";
149
+ this.code = code;
150
+ this.statusCode = options?.statusCode;
151
+ this.context = options?.context;
152
+ this.originalError = cause;
153
+ }
154
+ };
155
+ /** A caller's signal cancelled the work. */
156
+ function abortedByCaller(context, originalError) {
157
+ return new ComputeError("Request aborted by caller", ErrorCodes.ABORTED, {
158
+ context,
159
+ originalError
160
+ });
161
+ }
162
+ // src/core/utils/logger.ts
163
+ const silent = {
164
+ debug() {},
165
+ info() {},
166
+ warn() {},
167
+ error() {}
168
+ };
169
+ let internalLogger = silent;
170
+ function getLogger() {
171
+ return internalLogger;
172
+ }
173
+ /**
174
+ * Route the package's logs to `logger`; `null` silences them (the default).
175
+ *
176
+ * @throws {ComputeError} `INVALID_CONFIG` if the logger lacks one of the four methods, rather
177
+ * than a "debug is not a function" later at an unrelated call site.
178
+ *
179
+ * @example
180
+ * ```typescript
181
+ * import { setLogger } from '@selvajs/compute/core';
182
+ *
183
+ * setLogger(console);
184
+ * setLogger({
185
+ * debug: (msg, ...args) => myLogger.debug(msg, ...args),
186
+ * info: (msg, ...args) => myLogger.info(msg, ...args),
187
+ * warn: (msg, ...args) => myLogger.warn(msg, ...args),
188
+ * error: (msg, ...args) => myLogger.error(msg, ...args)
189
+ * });
190
+ * setLogger(null);
191
+ * ```
192
+ */
193
+ function setLogger(logger) {
194
+ if (logger === null) {
195
+ internalLogger = silent;
196
+ return;
197
+ }
198
+ const missing = [
199
+ "debug",
200
+ "info",
201
+ "warn",
202
+ "error"
203
+ ].filter((method) => typeof logger[method] !== "function");
204
+ if (missing.length > 0) throw new ComputeError(`Logger is missing required method(s): ${missing.join(", ")}. A logger must implement debug, info, warn and error.`, ErrorCodes.INVALID_CONFIG, { context: { missingMethods: missing } });
205
+ internalLogger = logger;
206
+ }
207
+ /** Shorthand for `setLogger(console)`. */
208
+ function enableDebugLogging() {
209
+ setLogger(console);
210
+ }
211
+ // src/core/utils/read-field.ts
212
+ /**
213
+ * Case-insensitive field reads for wire payloads.
214
+ *
215
+ * Rhino Compute branches serialize the same field in different casings. mcneel 8.x/9.x send the IO
216
+ * schema PascalCase (`ParamType`, `Default`, `InnerTree`): those C# classes carry no
217
+ * `[JsonProperty]`. The VektorNode fork sends camelCase, except inside the `default` DataTree
218
+ * (`ParamName`, `InnerTree`), an external type it can't attribute. So one response can mix casings.
219
+ * Read the fields you need through here and leave the rest verbatim: rewriting every key corrupts
220
+ * user-authored ones such as value-list labels.
221
+ */
222
+ /** Own keys only: a payload probed for `constructor` must not find `Object.prototype`'s. */
223
+ function findOwnKey(obj, name) {
224
+ if (!obj || typeof obj !== "object") return void 0;
225
+ if (Object.hasOwn(obj, name)) return name;
226
+ const lower = name.toLowerCase();
227
+ return Object.keys(obj).find((key) => key.toLowerCase() === lower);
228
+ }
229
+ /**
230
+ * Read `name` in any casing, preferring an exact-case match. `undefined` when no own key matches or
231
+ * `obj` isn't an object.
232
+ */
233
+ function readField(obj, name) {
234
+ const key = findOwnKey(obj, name);
235
+ return key === void 0 ? void 0 : obj[key];
236
+ }
237
+ /** Whether `name` is present in any casing, even with a null or undefined value. */
238
+ function hasField(obj, name) {
239
+ return findOwnKey(obj, name) !== void 0;
240
+ }
241
+ // src/core/utils/redact-url.ts
242
+ const UNREADABLE = "<unreadable URL>";
243
+ /**
244
+ * Only a URL with a real host can be cut safely. Anything else could hold a secret at any position:
245
+ * `https://admin:p/ss@host` splits into a bad port and a path.
246
+ */
247
+ function parseWithHost(url) {
248
+ try {
249
+ const parsed = new URL(url);
250
+ return parsed.host ? parsed : null;
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+ /** `url` without credentials, query or fragment, for errors and logs. */
256
+ function redactUrl(url) {
257
+ const parsed = parseWithHost(url);
258
+ if (!parsed) return UNREADABLE;
259
+ parsed.username = "";
260
+ parsed.password = "";
261
+ parsed.search = "";
262
+ parsed.hash = "";
263
+ return parsed.href;
264
+ }
265
+ /** Scheme and host alone. For a URL that was rejected, whose path can't be trusted either. */
266
+ function redactToOrigin(url) {
267
+ const parsed = parseWithHost(url);
268
+ return parsed ? `${parsed.protocol}//${parsed.host}` : UNREADABLE;
269
+ }
270
+ // src/core/utils/try-parse-json.ts
271
+ /** The parsed value, or `undefined` when `text` isn't JSON. */
272
+ function tryParseJson(text) {
273
+ try {
274
+ return JSON.parse(text);
275
+ } catch {
276
+ return;
277
+ }
278
+ }
279
+ // src/core/server/validate-server-url.ts
280
+ /**
281
+ * The public McNeel endpoint, blocked by default: users must point at their own server. Its raw IP
282
+ * isn't blocked: it sits behind a load balancer with no stable address to pin.
283
+ */
284
+ const DEFAULT_BLOCKED_HOST = "compute.rhino3d.com";
285
+ /**
286
+ * Validate a compute `serverUrl` and return it trimmed, without trailing slashes.
287
+ *
288
+ * @throws {ComputeError} `INVALID_CONFIG` when the URL is empty, isn't http(s), doesn't parse,
289
+ * embeds credentials, has a query or fragment, or names a blocked host.
290
+ */
291
+ function validateServerUrl(raw, options) {
292
+ const trimmed = raw?.trim() ?? "";
293
+ if (!trimmed) throw new ComputeError("serverUrl is required", ErrorCodes.INVALID_CONFIG, { context: { receivedServerUrl: raw } });
294
+ const redacted = redactToOrigin(trimmed);
295
+ const invalid = (reason) => new ComputeError(`Invalid serverUrl: "${redacted}". ${reason}`, ErrorCodes.INVALID_CONFIG, { context: { receivedServerUrl: redacted } });
296
+ if (!/^https?:\/\//i.test(trimmed)) throw invalid("Must start with \"http://\" or \"https://\". For example: \"http://localhost:6500\" or \"https://example.com\"");
297
+ let parsed;
298
+ try {
299
+ parsed = new URL(trimmed);
300
+ } catch {
301
+ throw invalid("Must be a valid URL.");
302
+ }
303
+ if (parsed.username !== "" || parsed.password !== "") throw invalid("Must not embed credentials (user:pass@host) — fetch rejects credentialed URLs at request time. Pass the API key separately.");
304
+ if (trimmed.includes("?") || trimmed.includes("#")) throw invalid("Must not contain a query string or fragment — endpoint paths are appended to this URL (e.g. \"${serverUrl}/version\"), and a \"?\" or \"#\" suffix would corrupt every request path.");
305
+ const hostname = trimTrailing(parsed.hostname.toLowerCase(), ".");
306
+ if ((options?.blockedHosts ?? ["compute.rhino3d.com"]).map((h) => trimTrailing(h.toLowerCase(), ".")).includes(hostname)) throw new ComputeError("serverUrl must be set to your Compute server URL. The shared public endpoint is not allowed.", ErrorCodes.INVALID_CONFIG, { context: { receivedServerUrl: redacted } });
307
+ return trimTrailing(trimmed, "/");
308
+ }
309
+ /** A loop, not `/x+$/`: that regex retries from every position of a long run and goes quadratic. */
310
+ function trimTrailing(value, char) {
311
+ let end = value.length;
312
+ while (end > 0 && value[end - 1] === char) end--;
313
+ return value.slice(0, end);
314
+ }
315
+ // src/core/utils/warnings.ts
316
+ const warned = /* @__PURE__ */ new Set();
317
+ /** Log `message` the first time `key` is seen: what it reports doesn't change per call. */
318
+ function warnOnce(key, message) {
319
+ if (warned.has(key)) return;
320
+ warned.add(key);
321
+ getLogger().warn(message);
322
+ }
323
+ /**
324
+ * True only in a real browser, where an API key in the config is exposed to users. jsdom defines
325
+ * `window` too, so Node is ruled out by `process.versions.node`, and by jsdom's user-agent marker
326
+ * for setups that hide `process`.
327
+ */
328
+ function isRealBrowser() {
329
+ if (typeof window === "undefined") return false;
330
+ if (globalThis.process?.versions?.node != null) return false;
331
+ const nav = globalThis.navigator;
332
+ const userAgent = typeof nav?.userAgent === "string" ? nav.userAgent : "";
333
+ if (/jsdom/i.test(userAgent)) return false;
334
+ return true;
335
+ }
336
+ function warnIfClientSide(functionName, suppress) {
337
+ if (suppress || !isRealBrowser()) return;
338
+ warnOnce(`client-side:${functionName}`, `Warning: ${functionName} is running on the client side. For better performance and security, consider running this on the server side.`);
339
+ }
340
+ // src/core/compute-fetch/request.ts
341
+ /** `serverUrl` as `validateServerUrl` returns it: no trailing slash. */
342
+ function buildUrl(endpoint, serverUrl) {
343
+ return `${serverUrl}/${endpoint.replace(/^\/+/, "")}`;
344
+ }
345
+ function isLocalhost(serverUrl) {
346
+ const hostname = new URL(serverUrl).hostname.toLowerCase();
347
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
348
+ }
349
+ const DEFAULT_API_KEY_HEADER = "RhinoComputeKey";
350
+ /**
351
+ * Caller headers plus credentials. Every request to the server builds its headers here,
352
+ * so a liveness probe authenticates exactly like the solve it gates.
353
+ */
354
+ function buildAuthHeaders(auth) {
355
+ return assertSendable(mergeUnder(auth.headers, credentialHeaders(auth)));
356
+ }
357
+ function buildHeaders(requestId, config) {
358
+ const headers = assertSendable(mergeUnder(config.headers, {
359
+ ...credentialHeaders(config),
360
+ "X-Request-ID": requestId,
361
+ "Content-Type": "application/json"
362
+ }));
363
+ if (!config.apiKey && !config.authToken && !isLocalhost(config.serverUrl)) warnOnce(`no-auth:${config.serverUrl}`, `[Compute] Request [${requestId}] targets remote server (${config.serverUrl}) but no API key or auth token is configured. Requests may fail or be rate-limited. (warned once per server)`);
364
+ return headers;
365
+ }
366
+ /** `headers` with a JSON `Content-Type` that replaces one of any casing already there. */
367
+ function withJsonContentType(headers) {
368
+ return mergeUnder(headers, { "Content-Type": "application/json" });
369
+ }
370
+ function credentialHeaders(auth) {
371
+ return {
372
+ ...auth.authToken && { Authorization: auth.authToken },
373
+ ...auth.apiKey && { [auth.apiKeyHeader ?? DEFAULT_API_KEY_HEADER]: auth.apiKey }
374
+ };
375
+ }
376
+ /**
377
+ * Header names are case-insensitive and fetch joins duplicates, so a caller key
378
+ * matching a transport key in any case is dropped rather than sent alongside it.
379
+ */
380
+ function mergeUnder(caller, transport) {
381
+ const taken = new Set(Object.keys(transport).map((name) => name.toLowerCase()));
382
+ const kept = Object.entries(caller ?? {}).filter(([name]) => !taken.has(name.toLowerCase()));
383
+ return {
384
+ ...Object.fromEntries(kept),
385
+ ...transport
386
+ };
387
+ }
388
+ /**
389
+ * Left to fetch, a bad value becomes a retried TypeError that quotes it, and the value may be the
390
+ * API key. `Headers` throws the same message, so its error is not kept as the cause.
391
+ */
392
+ function assertSendable(headers) {
393
+ const check = new Headers();
394
+ for (const [name, value] of Object.entries(headers)) try {
395
+ check.set(name, value);
396
+ } catch {
397
+ throw new ComputeError(`Header "${name}" has an invalid name or value`, ErrorCodes.INVALID_CONFIG, { context: { header: name } });
398
+ }
399
+ return headers;
400
+ }
401
+ function generateRequestId() {
402
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
403
+ }
404
+ // src/core/utils/encoding.ts
405
+ function getNodeBuffer() {
406
+ const buf = globalThis.Buffer;
407
+ return typeof buf === "function" ? buf : void 0;
408
+ }
409
+ function encodeStringToBase64(str) {
410
+ const Buffer = getNodeBuffer();
411
+ if (Buffer) return Buffer.from(str, "utf-8").toString("base64");
412
+ return base64ByteArray(new TextEncoder().encode(str));
413
+ }
414
+ /**
415
+ * WHATWG forgiving-base64 normalization: strip ASCII whitespace, then drop trailing padding where
416
+ * the spec allows it (total length a multiple of 4). `null` when the length can't be base64. Leaves
417
+ * the alphabet check to the decoders, which make it faster than a regex over a multi-MB payload.
418
+ */
419
+ function stripBase64(str) {
420
+ let data = str.replace(/[\t\n\f\r ]/g, "");
421
+ if (data.length % 4 === 0) data = data.replace(/={1,2}$/, "");
422
+ return data.length % 4 === 1 ? null : data;
423
+ }
424
+ const B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
425
+ /**
426
+ * Whether an untyped string is base64 content: the canonical padded form when confident, else
427
+ * `null` (treat it as plain text). A heuristic; pass a `Uint8Array` when it must be exact.
428
+ */
429
+ function detectBase64Payload(str) {
430
+ const data = stripBase64(str);
431
+ if (data === null || data.length < 64) return null;
432
+ if (/[^A-Za-z0-9+/]/.test(data)) return null;
433
+ const rem = data.length % 4;
434
+ const lastValue = B64_ALPHABET.indexOf(data.charAt(data.length - 1));
435
+ if (rem === 2 && (lastValue & 15) !== 0) return null;
436
+ if (rem === 3 && (lastValue & 3) !== 0) return null;
437
+ return rem === 0 ? data : data + "==".slice(0, 4 - rem);
438
+ }
439
+ /**
440
+ * Decode base64 to bytes, normalizing and validating the same way in Node and browsers.
441
+ * `packages/visualization/src/shared/encoding.ts` keeps a copy that throws its own error class;
442
+ * change both together.
443
+ *
444
+ * @throws {ComputeError} `ENCODING_ERROR` if invalid, or `ENVIRONMENT_ERROR` if the runtime has no decoder
445
+ */
446
+ function decodeBase64ToBinary(base64File) {
447
+ const data = stripBase64(base64File);
448
+ const bytes = data === null ? null : decodeStripped(data);
449
+ if (bytes === null) throw new ComputeError("Invalid base64 input.", ErrorCodes.ENCODING_ERROR, { context: { inputLength: base64File.length } });
450
+ return bytes;
451
+ }
452
+ /** Decode {@link stripBase64} output; `null` when a character is outside the alphabet. */
453
+ function decodeStripped(data) {
454
+ const Buffer = getNodeBuffer();
455
+ if (Buffer) {
456
+ if (Buffer.byteLength(data, "utf8") !== data.length) return null;
457
+ if (data.includes("-") || data.includes("_")) return null;
458
+ const decoded = Buffer.from(data, "base64");
459
+ if (decoded.length !== Math.floor(data.length * 3 / 4)) return null;
460
+ return decoded.byteOffset === 0 && decoded.buffer.byteLength === decoded.length ? new Uint8Array(decoded.buffer, 0, decoded.length) : new Uint8Array(decoded);
461
+ }
462
+ if (typeof globalThis.atob === "function") {
463
+ let binary;
464
+ try {
465
+ binary = globalThis.atob(data);
466
+ } catch {
467
+ return null;
468
+ }
469
+ const bytes = new Uint8Array(binary.length);
470
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) & 255;
471
+ return bytes;
472
+ }
473
+ throw new ComputeError("Base64 decoding not supported in this environment.", ErrorCodes.ENVIRONMENT_ERROR, { context: { environmentInfo: "atob or Buffer not available" } });
474
+ }
475
+ /** UTF-8 byte length. Sizes every request body: `Buffer` counts without allocating the copy. */
476
+ function utf8ByteLength(str) {
477
+ const Buffer = getNodeBuffer();
478
+ return Buffer ? Buffer.byteLength(str, "utf-8") : new TextEncoder().encode(str).length;
479
+ }
480
+ function base64ByteArray(bytes) {
481
+ const Buffer = getNodeBuffer();
482
+ if (Buffer) return Buffer.from(bytes).toString("base64");
483
+ if (typeof globalThis.btoa === "function") {
484
+ const CHUNK = 32766;
485
+ const parts = [];
486
+ for (let i = 0; i < bytes.length; i += CHUNK) parts.push(globalThis.btoa(String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK))));
487
+ return parts.join("");
488
+ }
489
+ throw new ComputeError("Base64 encoding not supported in this environment.", ErrorCodes.ENVIRONMENT_ERROR, { context: { environmentInfo: "btoa or Buffer not available" } });
490
+ }
491
+ // src/core/compute-fetch/read-body.ts
492
+ /**
493
+ * Cap on an error body: it is read for its `code` and message only. A 500 is read in full instead,
494
+ * since a partial success carries the computed values.
495
+ */
496
+ const ERROR_BODY_MAX_BYTES = 1 << 20;
497
+ /** Read a body as text, cancelling the download once it passes `limit` UTF-8 bytes. */
498
+ async function readBodyText(response, limit = Infinity) {
499
+ if (limit === Infinity) return {
500
+ text: await response.text(),
501
+ truncated: false
502
+ };
503
+ if (!response.body) {
504
+ const text = await response.text();
505
+ return {
506
+ text,
507
+ truncated: utf8ByteLength(text) > limit
508
+ };
509
+ }
510
+ const declared = Number(response.headers.get("Content-Length"));
511
+ if (!response.headers.has("Content-Encoding") && declared > limit) {
512
+ await response.body.cancel().catch(() => {});
513
+ return {
514
+ text: "",
515
+ truncated: true
516
+ };
517
+ }
518
+ const reader = response.body.getReader();
519
+ const decoder = new TextDecoder();
520
+ const parts = [];
521
+ let received = 0;
522
+ for (;;) {
523
+ const { done, value } = await reader.read();
524
+ if (done) break;
525
+ received += value.byteLength;
526
+ parts.push(decoder.decode(value, { stream: true }));
527
+ if (received > limit) {
528
+ await reader.cancel().catch(() => {});
529
+ return {
530
+ text: parts.join(""),
531
+ truncated: true
532
+ };
533
+ }
534
+ }
535
+ parts.push(decoder.decode());
536
+ return {
537
+ text: parts.join(""),
538
+ truncated: false
539
+ };
540
+ }
541
+ // src/core/compute-fetch/server-timing.ts
542
+ /**
543
+ * Parse a W3C Server Timing header, as the solve endpoint emits it:
544
+ * `decode;dur=3, solve;dur=120, gh;dur=110, encode;dur=8, recomputed;desc="4/97"`, or
545
+ * `cache;desc="hit"` when the server answered from its result cache. Null when it carries no
546
+ * recognised metric.
547
+ */
548
+ function parseServerTiming(headerValue) {
549
+ if (!headerValue) return null;
550
+ const timing = { raw: headerValue };
551
+ let sawMetric = false;
552
+ for (const part of headerValue.split(",")) {
553
+ const [rawName, ...params] = part.trim().split(";");
554
+ const name = rawName.trim().toLowerCase();
555
+ if (name === "decode" || name === "solve" || name === "encode" || name === "gh") {
556
+ const dur = Number(param(params, "dur"));
557
+ if (!Number.isFinite(dur)) continue;
558
+ timing[name] = dur;
559
+ sawMetric = true;
560
+ } else if (name === "recomputed") {
561
+ const [ran, total] = (param(params, "desc") ?? "").split("/").map(Number);
562
+ if (!Number.isInteger(ran) || !Number.isInteger(total)) continue;
563
+ timing.recomputed = {
564
+ ran,
565
+ total
566
+ };
567
+ sawMetric = true;
568
+ } else if (name === "cache" && param(params, "desc") === "hit") {
569
+ timing.cacheHit = true;
570
+ sawMetric = true;
571
+ }
572
+ }
573
+ return sawMetric ? timing : null;
574
+ }
575
+ function param(params, key) {
576
+ for (const p of params) {
577
+ const [k, ...v] = p.split("=");
578
+ if (k.trim().toLowerCase() !== key) continue;
579
+ return v.join("=").trim().replace(/^"(.*)"$/, "$1");
580
+ }
581
+ }
582
+ function fireServerTiming(response, requestId, onServerTiming) {
583
+ if (!onServerTiming) return;
584
+ const timing = parseServerTiming(response.headers.get("Server-Timing"));
585
+ if (!timing) return;
586
+ try {
587
+ onServerTiming(timing, requestId);
588
+ } catch (err) {
589
+ getLogger().debug(`onServerTiming callback threw ${err instanceof Error ? err.name : typeof err}`);
590
+ }
591
+ }
592
+ // src/core/compute-fetch/signal.ts
593
+ /** What fetch, or a body read, rejects with when its signal fires: a caller abort or a timeout. */
594
+ function isAbortError(error) {
595
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
596
+ }
597
+ /** The most `AbortSignal.timeout` takes on Node, which wants a whole uint32. */
598
+ const MAX_TIMEOUT_MS = 2 ** 32 - 1;
599
+ /** `promise`'s outcome, or a rejection with `signal`'s reason if that fires first. */
600
+ function raceSignal(promise, signal) {
601
+ if (!signal) return promise;
602
+ return new Promise((resolve, reject) => {
603
+ const onAbort = () => reject(signal.reason);
604
+ if (signal.aborted) onAbort();
605
+ signal.addEventListener("abort", onAbort, { once: true });
606
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
607
+ });
608
+ }
609
+ /**
610
+ * Compose a caller-supplied AbortSignal with an optional timeout; `undefined` when neither was
611
+ * given. `AbortSignal.timeout` rather than `setTimeout`: its timer isn't throttled in a hidden tab.
612
+ */
613
+ function composeSignal(callerSignal, timeoutMs) {
614
+ const noCleanup = () => {};
615
+ if (typeof timeoutMs !== "number" || timeoutMs <= 0) return {
616
+ signal: callerSignal,
617
+ cleanup: noCleanup
618
+ };
619
+ const timeoutSignal = AbortSignal.timeout(Math.floor(timeoutMs));
620
+ if (!callerSignal) return {
621
+ signal: timeoutSignal,
622
+ cleanup: noCleanup
623
+ };
624
+ const ctrl = new AbortController();
625
+ const sources = [callerSignal, timeoutSignal];
626
+ const onAbort = function() {
627
+ ctrl.abort(this.reason);
628
+ };
629
+ for (const s of sources) {
630
+ if (s.aborted) {
631
+ ctrl.abort(s.reason);
632
+ break;
633
+ }
634
+ s.addEventListener("abort", onAbort, { once: true });
635
+ }
636
+ return {
637
+ signal: ctrl.signal,
638
+ cleanup: () => {
639
+ for (const s of sources) s.removeEventListener("abort", onAbort);
640
+ }
641
+ };
642
+ }
643
+ // src/core/compute-fetch/wire-size.ts
644
+ /**
645
+ * A response's JSON text length, so byte-budgeted caches never re-serialize a huge tree. A WeakMap
646
+ * rather than a field keeps `JSON.stringify(response)` clean; it is keyed by identity, so a derived
647
+ * copy must re-register. `.length` stands in for UTF-8 bytes: the JSON is near-pure ASCII.
648
+ */
649
+ const wireSizes = /* @__PURE__ */ new WeakMap();
650
+ /** Record `response`'s wire size. No-op for non-object/null values. */
651
+ function setResponseWireSize(response, size) {
652
+ if (typeof response !== "object" || response === null) return;
653
+ wireSizes.set(response, size);
654
+ }
655
+ /** The wire size recorded for `response`, or undefined when never registered. */
656
+ function getResponseWireSize(response) {
657
+ if (typeof response !== "object" || response === null) return void 0;
658
+ return wireSizes.get(response);
659
+ }
660
+ // src/core/compute-fetch/response.ts
661
+ /** Upper bound for the raw server body stored on error `context.responseBody`. */
662
+ const MAX_CONTEXT_BODY_CHARS = 4096;
663
+ /** An allowlist: errors reach logs and telemetry, and `Set-Cookie` or a proxy's token must not. */
664
+ const CONTEXT_HEADERS = [
665
+ "content-length",
666
+ "content-type",
667
+ "retry-after",
668
+ "server-timing",
669
+ "www-authenticate",
670
+ "x-request-id"
671
+ ];
672
+ function truncateBody(body) {
673
+ if (body.length <= MAX_CONTEXT_BODY_CHARS) return body;
674
+ return `${body.slice(0, MAX_CONTEXT_BODY_CHARS)}… [truncated ${body.length - MAX_CONTEXT_BODY_CHARS} chars]`;
675
+ }
676
+ function contextHeaders(headers) {
677
+ const kept = {};
678
+ for (const name of CONTEXT_HEADERS) {
679
+ const value = headers.get(name);
680
+ if (value !== null) kept[name] = value;
681
+ }
682
+ return kept;
683
+ }
684
+ const plainMessage = ({ status, statusText, bodyHint }) => `HTTP ${status}: ${statusText}${bodyHint}`;
685
+ const labelled = (label) => ({ statusText, bodyHint }) => `${label}: ${statusText}${bodyHint}`;
686
+ const STATUS_RULES = {
687
+ 400: {
688
+ code: ErrorCodes.VALIDATION_ERROR,
689
+ message: labelled("Bad request")
690
+ },
691
+ 401: { code: ErrorCodes.AUTH_ERROR },
692
+ 403: { code: ErrorCodes.AUTH_ERROR },
693
+ 404: {
694
+ code: ErrorCodes.NOT_FOUND,
695
+ message: ({ fullUrl }) => `Endpoint not found: ${fullUrl}`
696
+ },
697
+ 413: {
698
+ code: ErrorCodes.VALIDATION_ERROR,
699
+ message: ({ requestSize }) => `Request too large: ${(requestSize / 1024).toFixed(2)}KB`
700
+ },
701
+ 429: {
702
+ code: ErrorCodes.RATE_LIMIT,
703
+ message: ({ bodyHint }) => `Rate limit exceeded${bodyHint}`
704
+ },
705
+ 500: {
706
+ code: ErrorCodes.COMPUTATION_ERROR,
707
+ message: labelled("Server error")
708
+ },
709
+ 502: {
710
+ code: ErrorCodes.NETWORK_ERROR,
711
+ message: labelled("Bad gateway")
712
+ },
713
+ 503: {
714
+ code: ErrorCodes.NETWORK_ERROR,
715
+ message: labelled("Service unavailable")
716
+ },
717
+ 504: {
718
+ code: ErrorCodes.NETWORK_ERROR,
719
+ message: labelled("Gateway timeout")
720
+ }
721
+ };
722
+ /**
723
+ * `rawBody` is what came over the wire and goes on `context.responseBody`; `detail` may be a
724
+ * message rewritten from it and only feeds the error message.
725
+ */
726
+ function throwHttpError(response, ctx, rawBody, detail, serverCode) {
727
+ const { status, statusText } = response;
728
+ const { fullUrl, requestId, requestSize, config } = ctx;
729
+ const trimmed = detail.trim();
730
+ const bodyHint = trimmed ? ` — ${trimmed.slice(0, 200)}${trimmed.length > 200 ? "…" : ""}` : "";
731
+ const rule = STATUS_RULES[status] ?? { code: ErrorCodes.UNKNOWN_ERROR };
732
+ const message = (rule.message ?? plainMessage)({
733
+ status,
734
+ statusText,
735
+ bodyHint,
736
+ fullUrl,
737
+ requestSize
738
+ });
739
+ const codes = config.serverErrorCodes;
740
+ throw new ComputeError(message, (serverCode && codes && Object.hasOwn(codes, serverCode) ? codes[serverCode] : void 0) ?? rule.code, {
741
+ statusCode: status,
742
+ context: {
743
+ url: fullUrl,
744
+ requestId,
745
+ method: "POST",
746
+ requestSize,
747
+ serverUrl: config.serverUrl,
748
+ responseBody: rawBody ? truncateBody(rawBody) : void 0,
749
+ responseHeaders: contextHeaders(response.headers)
750
+ }
751
+ });
752
+ }
753
+ /** A 3xx, or a browser's `redirect: 'manual'` stand-in for one, which hides the status. */
754
+ function isRedirect(response) {
755
+ return response.type === "opaqueredirect" || response.status >= 300 && response.status < 400;
756
+ }
757
+ /** Following a redirect would send the API key header, which fetch keeps cross-origin, to the target. */
758
+ function redirectError(response, ctx) {
759
+ const header = response.headers.get("Location");
760
+ const location = header ? redactUrl(header) : void 0;
761
+ return new ComputeError(`Server redirected the request${location ? ` to ${location}` : ""}. Redirects are not followed, so credentials never leave serverUrl: set serverUrl to the address it redirects to.`, ErrorCodes.INVALID_CONFIG, {
762
+ ...response.status > 0 && { statusCode: response.status },
763
+ context: {
764
+ url: ctx.fullUrl,
765
+ requestId: ctx.requestId,
766
+ location
767
+ }
768
+ });
769
+ }
770
+ function tooLargeError(response, ctx) {
771
+ const { maxResponseBytes } = ctx.config;
772
+ return new ComputeError(`Response body exceeded maxResponseBytes (${maxResponseBytes} bytes)`, ErrorCodes.RESPONSE_TOO_LARGE, {
773
+ statusCode: response.status,
774
+ context: {
775
+ url: ctx.fullUrl,
776
+ requestId: ctx.requestId,
777
+ maxResponseBytes
778
+ }
779
+ });
780
+ }
781
+ /** Sizes and identifiers only: bodies and solver messages can hold user data. */
782
+ function logDebug(ctx, message) {
783
+ if (ctx.config.debug) getLogger().debug(`Request [${ctx.requestId}] ${message}`);
784
+ }
785
+ /** Values plus errors or warnings: the solve ran, so this 500 is a partial success. */
786
+ function partialSuccessCounts(parsed) {
787
+ const solveErrors = readField(parsed, "errors");
788
+ const solveWarnings = readField(parsed, "warnings");
789
+ if (!readField(parsed, "values") || !(solveErrors || solveWarnings)) return null;
790
+ return {
791
+ errors: solveErrors?.length ?? 0,
792
+ warnings: solveWarnings?.length ?? 0
793
+ };
794
+ }
795
+ /**
796
+ * Compute8's exception handler sends `{ error, message: "<category>: <detail>", stackTrace? }`;
797
+ * older servers sent PascalCase `Message`/`ExceptionType`/`StackTrace`.
798
+ */
799
+ function serverErrorDetail(parsed) {
800
+ const serverMessage = typeof parsed?.message === "string" && parsed.message || typeof parsed?.Message === "string" && parsed.Message || "";
801
+ if (serverMessage) {
802
+ const exceptionType = typeof parsed?.ExceptionType === "string" && parsed.ExceptionType || "";
803
+ const prefix = exceptionType ? `${exceptionType}: ` : "";
804
+ const stack = parsed?.stackTrace ?? parsed?.StackTrace;
805
+ const stackStr = Array.isArray(stack) ? stack.join("\n") : stack || "";
806
+ return `${prefix}${serverMessage}${stackStr ? `\n${stackStr}` : ""}`;
807
+ }
808
+ if (parsed?.error) return typeof parsed.error === "string" ? parsed.error : JSON.stringify(parsed.error, null, 2);
809
+ }
810
+ async function handleErrorStatus(response, ctx, responseTime) {
811
+ const { maxResponseBytes = Infinity, onServerTiming } = ctx.config;
812
+ const isServerError = response.status === 500;
813
+ const { text: rawBody, truncated } = await readBodyText(response, isServerError ? maxResponseBytes : Math.min(ERROR_BODY_MAX_BYTES, maxResponseBytes));
814
+ if (truncated && isServerError) throw tooLargeError(response, ctx);
815
+ logDebug(ctx, `failed with HTTP ${response.status} in ${responseTime}ms (${rawBody.length} chars)`);
816
+ const parsed = truncated ? void 0 : tryParseJson(rawBody);
817
+ let detail = rawBody;
818
+ if (parsed !== void 0) {
819
+ const partial = isServerError ? partialSuccessCounts(parsed) : null;
820
+ if (partial) {
821
+ setResponseWireSize(parsed, rawBody.length);
822
+ logDebug(ctx, `completed in ${responseTime}ms with ${partial.errors} solver error(s), ${partial.warnings} warning(s)`);
823
+ fireServerTiming(response, ctx.requestId, onServerTiming);
824
+ return {
825
+ ok: true,
826
+ value: parsed
827
+ };
828
+ }
829
+ detail = serverErrorDetail(parsed) ?? rawBody;
830
+ }
831
+ const code = parsed?.code;
832
+ throwHttpError(response, ctx, rawBody, detail, typeof code === "string" ? code : void 0);
833
+ }
834
+ async function parseSuccessBody(response, ctx) {
835
+ const { maxResponseBytes = Infinity, signal } = ctx.config;
836
+ try {
837
+ const { text: rawBody, truncated } = await readBodyText(response, maxResponseBytes);
838
+ if (truncated) throw tooLargeError(response, ctx);
839
+ const parsed = JSON.parse(rawBody);
840
+ setResponseWireSize(parsed, rawBody.length);
841
+ return {
842
+ ok: true,
843
+ value: parsed
844
+ };
845
+ } catch (error) {
846
+ if (signal?.aborted || isAbortError(error) || error instanceof ComputeError) throw error;
847
+ const contentType = (response.headers.get("Content-Type") ?? "").toLowerCase();
848
+ if (contentType !== "" && !contentType.includes("json")) throw new ComputeError(`Server returned a non-JSON response (Content-Type: ${contentType}) — check the server URL / proxy configuration`, ErrorCodes.INVALID_RESPONSE, {
849
+ statusCode: response.status,
850
+ context: {
851
+ url: ctx.fullUrl,
852
+ requestId: ctx.requestId,
853
+ contentType
854
+ },
855
+ originalError: error
856
+ });
857
+ return {
858
+ ok: false,
859
+ retryable: true,
860
+ cause: new ComputeError("Failed to parse JSON response", ErrorCodes.NETWORK_ERROR, {
861
+ statusCode: response.status,
862
+ context: {
863
+ url: ctx.fullUrl,
864
+ requestId: ctx.requestId
865
+ },
866
+ originalError: error
867
+ })
868
+ };
869
+ }
870
+ }
871
+ /** Throws a {@link ComputeError} for anything a retry can't fix. */
872
+ async function handleResponse(response, ctx, startTime) {
873
+ const responseTime = Math.round(performance.now() - startTime);
874
+ if (isRedirect(response)) {
875
+ await response.body?.cancel().catch(() => {});
876
+ throw redirectError(response, ctx);
877
+ }
878
+ if (!response.ok) return handleErrorStatus(response, ctx, responseTime);
879
+ logDebug(ctx, `completed in ${responseTime}ms`);
880
+ fireServerTiming(response, ctx.requestId, ctx.config.onServerTiming);
881
+ return parseSuccessBody(response, ctx);
882
+ }
883
+ // src/core/server/classify-probe-failure.ts
884
+ /**
885
+ * Matched against the probe's error string, which `probeServer` builds with undici's `cause` in
886
+ * it. A browser collapses every failure into an opaque "Failed to fetch", which reads as unknown.
887
+ */
888
+ const REFUSED_PATTERN = /ECONNREFUSED|ERR_CONNECTION_REFUSED|connection refused/i;
889
+ const DNS_PATTERN = /ENOTFOUND|EAI_AGAIN|getaddrinfo|ERR_NAME_NOT_RESOLVED|dns/i;
890
+ const TIMEOUT_PATTERN = /TimeoutError|ETIMEDOUT|ECONNRESET|EHOSTUNREACH|ENETUNREACH|abort|timed? ?out/i;
891
+ /**
892
+ * Classify a `ComputeServerStats.probeServer` result.
893
+ *
894
+ * A DNS failure is treated as retryable even though a missing record won't fix
895
+ * itself: `EAI_AGAIN` is a *timeout* talking to the resolver, and the two are
896
+ * not distinguishable from the error alone. Retrying a genuinely-wrong hostname
897
+ * costs one window; giving up on a resolver hiccup breaks a server that is fine.
898
+ *
899
+ * @param probe - The `{ online, status?, error? }` a probe returned. An `online`
900
+ * probe is not a failure and yields `null`.
901
+ */
902
+ function classifyProbeFailure(probe) {
903
+ if (probe.online) return null;
904
+ if (probe.status !== void 0) {
905
+ if (probe.status === 401 || probe.status === 403) return {
906
+ verdict: "unauthorized",
907
+ retryable: false,
908
+ summary: `The server rejected the liveness probe with HTTP ${probe.status} — check the API key.`
909
+ };
910
+ const retryable = probe.status >= 500;
911
+ return {
912
+ verdict: "http_error",
913
+ retryable,
914
+ summary: retryable ? `The server answered HTTP ${probe.status} — it may still be starting up.` : `The server answered HTTP ${probe.status}, which will not change on retry.`
915
+ };
916
+ }
917
+ const error = probe.error ?? "";
918
+ if (REFUSED_PATTERN.test(error)) return {
919
+ verdict: "refused",
920
+ retryable: false,
921
+ summary: "Connection refused — the host is reachable but nothing is listening on that port. Rhino.Compute is not running, or the URL names the wrong port."
922
+ };
923
+ if (DNS_PATTERN.test(error)) return {
924
+ verdict: "dns",
925
+ retryable: true,
926
+ summary: "The server hostname could not be resolved — check the URL and DNS."
927
+ };
928
+ if (TIMEOUT_PATTERN.test(error)) return {
929
+ verdict: "timeout",
930
+ retryable: true,
931
+ summary: "No response before the timeout — the machine may be powered off, blocked by a firewall, or still booting."
932
+ };
933
+ return {
934
+ verdict: "unknown",
935
+ retryable: true,
936
+ summary: error ? `The connection failed: ${error}` : "The connection failed."
937
+ };
938
+ }
939
+ // src/core/compute-fetch/request-compression.ts
940
+ /**
941
+ * Gzip request bodies, but only for a server that advertised it: its last response carried
942
+ * `Accept-Encoding: gzip` (RFC 7694), as Rhino.Compute does with `RHINO_COMPUTE_COMPRESSION` on.
943
+ * An older server reads a gzipped body as malformed JSON. A response without the header forgets
944
+ * the server, so one failed request recovers from compression being turned off. Browsers only
945
+ * learn it same-origin: CORS hides the header otherwise.
946
+ */
947
+ const gzipServers = /* @__PURE__ */ new Set();
948
+ function noteRequestEncodings(serverUrl, response) {
949
+ const accepted = response.headers?.get("Accept-Encoding");
950
+ if (accepted && /(^|[\s,])gzip($|[\s,;])/i.test(accepted)) gzipServers.add(serverUrl);
951
+ else gzipServers.delete(serverUrl);
952
+ }
953
+ /** Synchronous, so an uncompressed request reaches fetch in the same tick as before. */
954
+ function shouldGzipRequest(serverUrl, byteLength) {
955
+ return byteLength >= 1024 && gzipServers.has(serverUrl) && typeof CompressionStream !== "undefined";
956
+ }
957
+ async function gzip(body) {
958
+ const stream = new Blob([body]).stream().pipeThrough(new CompressionStream("gzip"));
959
+ return new Uint8Array(await new Response(stream).arrayBuffer());
960
+ }
961
+ // src/core/compute-fetch/compute-fetch.ts
962
+ async function attemptFetch(ctx, policy, attempt, isFinal) {
963
+ const { signal, cleanup } = composeSignal(ctx.config.signal, ctx.config.timeoutMs);
964
+ const startTime = performance.now();
965
+ const send = ctx.config.fetch ?? fetch;
966
+ try {
967
+ const gzipped = shouldGzipRequest(ctx.serverUrl, ctx.requestSize) ? await gzip(ctx.body) : null;
968
+ const response = await send(ctx.fullUrl, {
969
+ method: "POST",
970
+ body: gzipped ?? ctx.body,
971
+ headers: gzipped ? {
972
+ ...ctx.headers,
973
+ "Content-Encoding": "gzip"
974
+ } : ctx.headers,
975
+ signal,
976
+ redirect: "manual"
977
+ });
978
+ noteRequestEncodings(ctx.serverUrl, response);
979
+ if (!isFinal && isRetryableStatus(response.status, policy)) {
980
+ await readBodyText(response, ERROR_BODY_MAX_BYTES).catch(() => {});
981
+ return {
982
+ ok: false,
983
+ retryable: true,
984
+ delayMs: retryDelay(response.headers.get("Retry-After"), attempt, policy),
985
+ cause: new ComputeError(`HTTP ${response.status} ${response.statusText} (will retry)`, response.status === 429 ? ErrorCodes.RATE_LIMIT : ErrorCodes.NETWORK_ERROR, {
986
+ statusCode: response.status,
987
+ context: ctx.errorContext
988
+ })
989
+ };
990
+ }
991
+ return await handleResponse(response, ctx, startTime);
992
+ } catch (error) {
993
+ return classifyFetchError(error, ctx);
994
+ } finally {
995
+ cleanup();
996
+ }
997
+ }
998
+ function classifyFetchError(error, ctx) {
999
+ if (error instanceof ComputeError) return {
1000
+ ok: false,
1001
+ retryable: false,
1002
+ cause: error
1003
+ };
1004
+ if (ctx.config.signal?.aborted) return {
1005
+ ok: false,
1006
+ retryable: false,
1007
+ cause: abortedByCaller(ctx.errorContext, error)
1008
+ };
1009
+ if (isAbortError(error)) {
1010
+ const { timeoutMs } = ctx.config;
1011
+ return {
1012
+ ok: false,
1013
+ retryable: true,
1014
+ cause: typeof timeoutMs === "number" && timeoutMs > 0 ? new ComputeError(`Request timed out after ${timeoutMs}ms`, ErrorCodes.TIMEOUT_ERROR, {
1015
+ context: {
1016
+ ...ctx.errorContext,
1017
+ timeoutMs
1018
+ },
1019
+ originalError: error
1020
+ }) : new ComputeError(`Request aborted by the runtime (${error.name}): ${error.message}`, ErrorCodes.NETWORK_ERROR, {
1021
+ context: ctx.errorContext,
1022
+ originalError: error
1023
+ })
1024
+ };
1025
+ }
1026
+ if (error instanceof TypeError) return {
1027
+ ok: false,
1028
+ retryable: true,
1029
+ cause: typeof window !== "undefined" && typeof window.document !== "undefined" ? new ComputeError(`Request failed: ${error.message} — in a browser this usually means a CORS misconfiguration on the server (or a network failure; the browser does not distinguish them)`, ErrorCodes.CORS_ERROR, {
1030
+ context: ctx.errorContext,
1031
+ originalError: error
1032
+ }) : new ComputeError(`Network error: ${error.message}`, ErrorCodes.NETWORK_ERROR, {
1033
+ context: ctx.errorContext,
1034
+ originalError: error
1035
+ })
1036
+ };
1037
+ return {
1038
+ ok: false,
1039
+ retryable: false,
1040
+ cause: new ComputeError(error instanceof Error ? error.message : String(error), ErrorCodes.UNKNOWN_ERROR, {
1041
+ context: ctx.errorContext,
1042
+ originalError: error
1043
+ })
1044
+ };
1045
+ }
1046
+ function requireNumber(name, value, isValid) {
1047
+ if (value !== void 0 && !isValid(value)) throw new ComputeError(`${name} is not usable: ${value}`, ErrorCodes.INVALID_CONFIG, { context: { [name]: value } });
1048
+ }
1049
+ /** `NaN` fails every comparison, so unchecked it disables a loop's exit or a limit. */
1050
+ function validateNumbers({ timeoutMs, maxResponseBytes, retry }) {
1051
+ const notNegative = (n) => n >= 0;
1052
+ requireNumber("timeoutMs", timeoutMs, (n) => n <= MAX_TIMEOUT_MS);
1053
+ requireNumber("maxResponseBytes", maxResponseBytes, notNegative);
1054
+ requireNumber("retry.attempts", retry?.attempts, (n) => notNegative(n) && Number.isFinite(n));
1055
+ requireNumber("retry.baseDelayMs", retry?.baseDelayMs, notNegative);
1056
+ requireNumber("retry.maxDelayMs", retry?.maxDelayMs, notNegative);
1057
+ }
1058
+ /**
1059
+ * POST `args` as JSON to `<serverUrl>/<endpoint>` with retry, timeout and abort handling, and
1060
+ * return the parsed response. Most callers want a higher-level API.
1061
+ *
1062
+ * `R` names the expected response shape; nothing checks it, so the default `unknown` forces a
1063
+ * narrowing.
1064
+ *
1065
+ * @example
1066
+ * const response = await fetchCompute('grasshopper', args, {
1067
+ * serverUrl: 'https://my-server.com',
1068
+ * timeoutMs: 30_000,
1069
+ * retry: { attempts: 2 },
1070
+ * signal: controller.signal
1071
+ * });
1072
+ */
1073
+ async function fetchCompute(endpoint, args, config) {
1074
+ const requestId = generateRequestId();
1075
+ let body;
1076
+ try {
1077
+ body = JSON.stringify(args);
1078
+ } catch (error) {
1079
+ throw new ComputeError(`Request body is not JSON-serializable: ${error instanceof Error ? error.message : String(error)}`, ErrorCodes.INVALID_INPUT, {
1080
+ context: {
1081
+ endpoint,
1082
+ requestId
1083
+ },
1084
+ originalError: error
1085
+ });
1086
+ }
1087
+ const requestSize = utf8ByteLength(body);
1088
+ const serverUrl = validateServerUrl(config.serverUrl, { blockedHosts: [] });
1089
+ validateNumbers(config);
1090
+ const fullUrl = buildUrl(endpoint, serverUrl);
1091
+ const headers = buildHeaders(requestId, config);
1092
+ const retryPolicy = resolveRetryPolicy(config.retry);
1093
+ const totalAttempts = retryPolicy.attempts + 1;
1094
+ if (config.debug) {
1095
+ const sizeKb = (requestSize / 1024).toFixed(2);
1096
+ getLogger().debug(`Starting compute request [${requestId}]: ${endpoint} (${sizeKb}KB)`);
1097
+ }
1098
+ const ctx = {
1099
+ endpoint,
1100
+ serverUrl,
1101
+ body,
1102
+ requestSize,
1103
+ fullUrl,
1104
+ requestId,
1105
+ headers,
1106
+ config,
1107
+ errorContext: {
1108
+ serverUrl,
1109
+ url: fullUrl,
1110
+ endpoint,
1111
+ requestId,
1112
+ requestSize
1113
+ }
1114
+ };
1115
+ for (let attempt = 0;; attempt++) {
1116
+ const isFinal = attempt >= retryPolicy.attempts;
1117
+ const result = await attemptFetch(ctx, retryPolicy, attempt, isFinal);
1118
+ if (result.ok) return result.value;
1119
+ if (isFinal || !result.retryable) throw result.cause;
1120
+ const delayMs = result.delayMs ?? backoffDelay(attempt, retryPolicy);
1121
+ if (config.debug) getLogger().debug(`Request [${requestId}] retrying after ${delayMs}ms (attempt ${attempt + 2}/${totalAttempts}): ${result.cause.code}`);
1122
+ try {
1123
+ await sleep(delayMs, config.signal);
1124
+ } catch {
1125
+ throw abortedByCaller(ctx.errorContext, result.cause);
1126
+ }
1127
+ }
1128
+ }
1129
+ // src/core/definition-ref.ts
1130
+ function isDefinitionRef(definition) {
1131
+ return typeof definition === "object" && !(definition instanceof Uint8Array) && typeof definition.key === "string" && typeof definition.load === "function";
1132
+ }
1133
+ /** A URL is already a server-side reference: it is never uploaded or keyed by server cache key. */
1134
+ function isDefinitionUrl(definition) {
1135
+ return typeof definition === "string" && /^https?:\/\//i.test(definition);
1136
+ }
1137
+ // src/core/files/sub-folder.ts
1138
+ /**
1139
+ * The `Sub Folder` convention, as authored on Selva's Grasshopper file components.
1140
+ *
1141
+ * `::` nests, matching Rhino's layer separator (`ROOT::Panels`), and `/` and `\` are accepted
1142
+ * because people type them out of habit. The plugin normalizes to `/` before sending, so these
1143
+ * helpers also cover payloads from an older plugin that still emit `::`.
1144
+ *
1145
+ * The first segment is the **root**, which names the archive rather than becoming a folder inside
1146
+ * it: `ROOT::Panels` downloads as `ROOT.zip` containing `Panels/…`. Files sharing a root travel
1147
+ * together; distinct roots produce separate archives.
1148
+ */
1149
+ /**
1150
+ * Split a `Sub Folder` value into folder segments; `/`, `\` and `::` all separate.
1151
+ *
1152
+ * Exported because anything rendering a folder tree has to agree with the archive on where the
1153
+ * boundaries are — splitting on `/` alone leaves `Main::Panels` as one literal segment and shows
1154
+ * a folder named after the separator.
1155
+ */
1156
+ const subFolderSegments = (subFolder) => (subFolder ?? "").replace(/::/g, "/").replace(/\\/g, "/").split("/").map((segment) => segment.trim()).filter((segment) => segment !== "");
1157
+ /** First `Sub Folder` segment, or `''` when the file sits at the top level. */
1158
+ const rootOf = (file) => subFolderSegments(file.subFolder)[0] ?? "";
1159
+ /**
1160
+ * Everything below the root, as a `/`-joined path. The root names the archive, so repeating it
1161
+ * inside would nest it twice.
1162
+ */
1163
+ const pathBelowRoot = (file) => subFolderSegments(file.subFolder).slice(1).join("/");
1164
+ /**
1165
+ * Group files by their `Sub Folder` root, preserving encounter order.
1166
+ *
1167
+ * Rootless files group under `''` — a caller naming archives supplies its own fallback for that
1168
+ * bucket. Useful beyond downloading: a consumer writing to disk gets the same grouping without
1169
+ * touching the DOM.
1170
+ */
1171
+ const groupFilesByRoot = (files) => {
1172
+ const groups = /* @__PURE__ */ new Map();
1173
+ for (const file of files) {
1174
+ const root = rootOf(file);
1175
+ const bucket = groups.get(root);
1176
+ if (bucket) bucket.push(file);
1177
+ else groups.set(root, [file]);
1178
+ }
1179
+ return Array.from(groups, ([root, grouped]) => ({
1180
+ root,
1181
+ files: grouped
1182
+ }));
1183
+ };
1184
+ /**
1185
+ * Make a root usable as a download filename: path separators and the characters Windows forbids
1186
+ * would otherwise reach the download attribute verbatim and produce a broken or misdirected save.
1187
+ */
1188
+ const toArchiveName = (root) => {
1189
+ const safe = root.replace(/[/\\:*?"<>|]/g, "_").replace(/\s+/g, " ").trim().replace(/^\.+/, "");
1190
+ return safe === "" ? "files" : safe;
1191
+ };
1192
+ // src/core/files/handle-files.ts
1193
+ /**
1194
+ * Decode the files in a compute response, plus any external `additionalFiles`, without
1195
+ * downloading them. Never throws: an unnamed or undecodable item and a failed external fetch are
1196
+ * logged and dropped, the rest still resolve. Colliding archive paths are renamed
1197
+ * (`model.txt` → `model-2.txt`), so a consumer keying by `path` loses nothing.
1198
+ */
1199
+ const extractFilesFromComputeResponse = async (downloadableFiles, additionalFiles = null) => {
1200
+ const processedFiles = decodeResponseFiles(downloadableFiles);
1201
+ if (additionalFiles) {
1202
+ const filesArray = Array.isArray(additionalFiles) ? additionalFiles : [additionalFiles];
1203
+ processedFiles.push(...await fetchRemoteFiles(filesArray));
1204
+ }
1205
+ const taken = /* @__PURE__ */ new Set();
1206
+ return processedFiles.map((file) => {
1207
+ const path = uniqueArchivePath(file.path, taken);
1208
+ taken.add(path);
1209
+ if (path === file.path) return file;
1210
+ getLogger().warn(`Duplicate archive path "${file.path}" — storing as "${path}".`);
1211
+ return {
1212
+ ...file,
1213
+ path,
1214
+ fileName: path.slice(path.lastIndexOf("/") + 1)
1215
+ };
1216
+ });
1217
+ };
1218
+ /** Downloads files from a compute response as a ZIP archive. */
1219
+ const downloadFileData = async (downloadableFiles, fileFoldername, additionalFiles = null, options = {}) => {
1220
+ if (typeof document === "undefined" || typeof Blob === "undefined") throw new ComputeError("File download functionality is only available in browser environments. This function requires the DOM API (document, Blob).", ErrorCodes.BROWSER_ONLY, { context: {
1221
+ environment: typeof window !== "undefined" ? "browser (SSR)" : "Node.js",
1222
+ documentAvailable: typeof document !== "undefined",
1223
+ blobAvailable: typeof Blob !== "undefined"
1224
+ } });
1225
+ try {
1226
+ await createAndDownloadZip(await extractFilesFromComputeResponse(downloadableFiles, additionalFiles), fileFoldername, options.saveBlob ?? saveFile);
1227
+ } catch (err) {
1228
+ if (err instanceof ComputeError) throw err;
1229
+ throw new ComputeError("Failed to download files from compute response", ErrorCodes.INVALID_STATE, {
1230
+ context: {
1231
+ fileCount: downloadableFiles.length,
1232
+ zipName: fileFoldername
1233
+ },
1234
+ originalError: err
1235
+ });
1236
+ }
1237
+ };
1238
+ /**
1239
+ * Download one archive per `Sub Folder` root (see {@link groupFilesByRoot}). Files with no root go
1240
+ * into `fallbackName`, so a definition that never sets `Sub Folder` downloads as one archive.
1241
+ *
1242
+ * Archives are saved one at a time: browsers discard concurrent downloads issued in the same tick.
1243
+ * More than one save per gesture may prompt the user for permission.
1244
+ *
1245
+ * @param downloadableFiles - `FileData` items from the compute response.
1246
+ * @param fallbackName - Archive name for files with no `Sub Folder` root.
1247
+ * @param additionalFiles - Extra files to package; they carry no root and join the fallback archive.
1248
+ * @param options - `saveBlob` replaces the anchor download for every archive.
1249
+ */
1250
+ const downloadFileDataByRoot = async (downloadableFiles, fallbackName, additionalFiles = null, options = {}) => {
1251
+ const groups = groupFilesByRoot(downloadableFiles);
1252
+ if (groups.length === 0) {
1253
+ await downloadFileData([], fallbackName, additionalFiles, options);
1254
+ return;
1255
+ }
1256
+ const extrasRoot = (groups.find((group) => group.root === "") ?? groups[0]).root;
1257
+ for (const { root, files } of groups) await downloadFileData(root === "" ? files : files.map((file) => ({
1258
+ ...file,
1259
+ subFolder: pathBelowRoot(file)
1260
+ })), root === "" ? fallbackName : toArchiveName(root), root === extrasRoot ? additionalFiles : null, options);
1261
+ };
1262
+ /**
1263
+ * Zip-slip defense for a server-controlled path: drop `.`, `..` and drive-prefixed segments
1264
+ * (`C:`, and `C:evil`, which is relative to that drive's working directory) so no entry escapes
1265
+ * the extraction directory. `''` when nothing safe remains.
1266
+ */
1267
+ const sanitizeArchivePath = (raw) => subFolderSegments(raw).filter((segment) => segment !== "." && segment !== ".." && !/^[a-zA-Z]:/.test(segment)).join("/");
1268
+ /** The `isBase64Encoded` wire flag. Some server branches send booleans as strings (`"True"`). */
1269
+ const isBase64Flag = (flag) => flag === true || typeof flag === "string" && flag.trim().toLowerCase() === "true";
1270
+ const joinArchivePath = (subFolder, name) => subFolder !== "" ? `${subFolder}/${name}` : name;
1271
+ const decodeResponseFiles = (dataItems) => {
1272
+ const processedFiles = [];
1273
+ dataItems.forEach((item) => {
1274
+ const rawName = readField(item, "fileName") ?? "";
1275
+ const rawType = readField(item, "fileType") ?? "";
1276
+ const fileName = sanitizeArchivePath(`${rawName}${rawType}`);
1277
+ if (fileName === "") {
1278
+ getLogger().warn(`Skipping file with unusable name "${rawName}${rawType}": no safe archive path remains after sanitization.`);
1279
+ return;
1280
+ }
1281
+ const rawSubFolder = readField(item, "subFolder");
1282
+ const subFolder = rawSubFolder ? sanitizeArchivePath(rawSubFolder) : "";
1283
+ const filePath = joinArchivePath(subFolder, fileName);
1284
+ const data = readField(item, "data");
1285
+ if (typeof data !== "string" || data === "") {
1286
+ getLogger().warn(`Skipping file "${filePath}": item carries no usable data.`);
1287
+ return;
1288
+ }
1289
+ let content = data;
1290
+ if (isBase64Flag(readField(item, "isBase64Encoded"))) try {
1291
+ content = decodeBase64ToBinary(data);
1292
+ } catch (err) {
1293
+ getLogger().warn(`Skipping file "${filePath}": base64 decode failed.`, err);
1294
+ return;
1295
+ }
1296
+ const metadata = readField(item, "metadata");
1297
+ processedFiles.push({
1298
+ fileName,
1299
+ content,
1300
+ path: filePath,
1301
+ subFolder,
1302
+ ...metadata ? { metadata } : {}
1303
+ });
1304
+ });
1305
+ return processedFiles;
1306
+ };
1307
+ /** One dead URL must degrade the batch, not stall it forever. */
1308
+ const REMOTE_FILE_TIMEOUT_MS = 3e4;
1309
+ const fetchRemoteFiles = async (refs) => {
1310
+ return (await Promise.all(refs.map(async (file) => {
1311
+ try {
1312
+ const response = await fetch(file.filePath, { signal: AbortSignal.timeout(REMOTE_FILE_TIMEOUT_MS) });
1313
+ if (!response.ok) {
1314
+ getLogger().warn(`Failed to fetch additional file: ${redactUrl(file.filePath)}`);
1315
+ return null;
1316
+ }
1317
+ const arrayBuffer = await response.arrayBuffer();
1318
+ const safeName = sanitizeArchivePath(file.fileName);
1319
+ if (safeName === "") {
1320
+ getLogger().warn(`Skipping fetched file with unusable name: ${file.fileName}`);
1321
+ return null;
1322
+ }
1323
+ const subFolder = file.subFolder ? sanitizeArchivePath(file.subFolder) : "";
1324
+ return {
1325
+ fileName: safeName,
1326
+ content: new Uint8Array(arrayBuffer),
1327
+ path: joinArchivePath(subFolder, safeName),
1328
+ subFolder
1329
+ };
1330
+ } catch (error) {
1331
+ const reason = error instanceof Error ? error.name : "unknown error";
1332
+ getLogger().error(`Error fetching additional file ${redactUrl(file.filePath)}: ${reason}`);
1333
+ return null;
1334
+ }
1335
+ }))).filter((f) => f !== null);
1336
+ };
1337
+ async function createAndDownloadZip(files, zipName, save) {
1338
+ const { zip, strToU8 } = await import("fflate");
1339
+ const taken = new Set(files.map((file) => file.path));
1340
+ const zipData = {};
1341
+ for (const file of files) {
1342
+ const path = file.path === "__proto__" ? uniqueArchivePath(file.path, taken) : file.path;
1343
+ taken.add(path);
1344
+ zipData[path] = typeof file.content === "string" ? strToU8(file.content) : file.content;
1345
+ }
1346
+ const zipped = await new Promise((resolve, reject) => {
1347
+ zip(zipData, { level: 6 }, (err, data) => err ? reject(err) : resolve(data));
1348
+ });
1349
+ await save(new Blob([zipped], { type: "application/zip" }), `${zipName}.zip`);
1350
+ }
1351
+ /**
1352
+ * First archive path not already taken in `taken`, disambiguating with a
1353
+ * numeric suffix before the extension: `dir/model.txt` → `dir/model-2.txt`.
1354
+ */
1355
+ function uniqueArchivePath(path, taken) {
1356
+ if (!taken.has(path)) return path;
1357
+ const slash = path.lastIndexOf("/");
1358
+ const dot = path.lastIndexOf(".");
1359
+ const stemEnd = dot > slash ? dot : path.length;
1360
+ const stem = path.slice(0, stemEnd);
1361
+ const ext = path.slice(stemEnd);
1362
+ for (let i = 2;; i++) {
1363
+ const candidate = `${stem}-${i}${ext}`;
1364
+ if (!taken.has(candidate)) return candidate;
1365
+ }
1366
+ }
1367
+ function saveFile(blob, filename) {
1368
+ const url = URL.createObjectURL(blob);
1369
+ const a = document.createElement("a");
1370
+ a.href = url;
1371
+ a.download = filename;
1372
+ document.body.appendChild(a);
1373
+ a.click();
1374
+ a.remove();
1375
+ setTimeout(() => URL.revokeObjectURL(url), 1e4);
1376
+ }
1377
+ export { readField as A, withJsonContentType as C, tryParseJson as D, validateServerUrl as E, ErrorCodes as F, abortedByCaller as I, sleep as L, getLogger as M, setLogger as N, redactUrl as O, ComputeError as P, buildAuthHeaders as S, DEFAULT_BLOCKED_HOST as T, readBodyText as _, groupFilesByRoot as a, detectBase64Payload as b, isDefinitionUrl as c, classifyProbeFailure as d, isRedirect as f, raceSignal as g, composeSignal as h, isBase64Flag as i, enableDebugLogging as j, hasField as k, fetchCompute as l, setResponseWireSize as m, downloadFileDataByRoot as n, subFolderSegments as o, getResponseWireSize as p, extractFilesFromComputeResponse as r, isDefinitionRef as s, downloadFileData as t, validateNumbers as u, base64ByteArray as v, warnIfClientSide as w, encodeStringToBase64 as x, decodeBase64ToBinary as y };
1378
+
1379
+ //# sourceMappingURL=handle-files-D-4X4Uh_.js.map