UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

439 lines • 17.3 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { callerSignal } from "../../context/session_context.js"; import { mergeSignals, sleep } from "../../utils/abort.js"; import { rangeHeader } from "../../utils/ranges.js"; import { rstripSlash } from "../../utils/slash.js"; import { CHUNK_SIZE, chunks } from "../../io/cooperative.js"; import { DEFAULT_TIMEOUT } from "./constants.js"; import { ResponseTooLargeError } from "./errors.js"; export const MAX_BODY_BYTES = 64 * 1024 * 1024; /** * `fetchFn` (the global fetch, read per call, when absent) bounded by how * long a request may wait on the server: to connect and answer, then for * each chunk of the body the caller asks for. The clock restarts when the * headers arrive and on every chunk, and stops while the caller is not * reading, once the body ends and when the caller cancels it, so a download * that keeps flowing is never cut off. A request that waits longer fails * with a `TimeoutError` DOMException. The body is relayed, locking the * original only while a read is pending, so a body drained, failed or * cancelled is left unlocked; the relay keeps the original's `url` and * `redirected`. Python's twin is `stall_timeout`, which aiohttp spells as * sock_connect and sock_read. */ export function stallTimeout(seconds = DEFAULT_TIMEOUT, fetchFn) { const send = fetchFn ?? ((input, init) => fetch(input, init)); return async (input, init) => { const controller = new AbortController(); let timer; const stop = () => { clearTimeout(timer); }; const arm = () => { stop(); timer = setTimeout(() => { controller.abort(new DOMException(`no progress for ${String(seconds)}s`, 'TimeoutError')); }, Math.min(seconds * 1000, 2 ** 31 - 1)); }; const timedOut = (err) => controller.signal.aborted ? controller.signal.reason : err; const signal = init?.signal == null ? controller.signal : AbortSignal.any([init.signal, controller.signal]); arm(); let response; try { response = await send(input, { ...init, signal }); } catch (err) { throw timedOut(err); } finally { stop(); } const source = response.body; if (source == null || [204, 205, 304].includes(response.status)) return response; let reading; const body = new ReadableStream({ async pull(sink) { const reader = source.getReader(); reading = reader; arm(); try { const result = await reader.read(); if (result.done) sink.close(); else sink.enqueue(result.value); } catch (err) { throw timedOut(err); } finally { stop(); reading = undefined; reader.releaseLock(); } }, async cancel(reason) { stop(); await (reading ?? source).cancel(reason); }, }, { highWaterMark: 0 }); const relayed = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers, }); Object.defineProperties(relayed, { url: { value: response.url }, redirected: { value: response.redirected }, }); return relayed; }; } /** * Pull a GET response in bounded chunks, cancelling its body on early exit. * `retry` applies to opening the response only: once a chunk has reached the * caller a failure propagates, since a second attempt would hand it the same * bytes twice. */ export async function* apiStream(url, options) { // The caller's signal and the call's (a line's stop, a job's kill or // limit), so a stopped command ends its request as Python's cancelled // task does. const signal = mergeSignals(options.signal, callerSignal()); const retry = options.retry ?? NO_RETRY; const wait = options.sleep ?? ((seconds) => sleep(seconds * 1000, signal)); const doFetch = stallTimeout(options.timeout, options.fetchFn); for (let attempt = 0;; attempt += 1) { const last = attempt >= retry.maxRetries; let response; try { response = await doFetch(url, { method: 'GET', ...(options.headers === undefined ? {} : { headers: options.headers }), ...(signal === undefined ? {} : { signal }), }); } catch (err) { // a caller's abort is not a transport failure worth another attempt if (last || retry.retryTransport !== true || signal?.aborted === true) throw err; await wait(Math.min(2 ** attempt, retry.maxBackoff)); continue; } let text = ''; let chunksOf = null; let first = null; // Reading the error body or the first chunk is still opening the // response: nothing has reached the caller, so a drop here retries. try { if (response.status >= 400) text = await response.text(); else if (response.body !== null) { chunksOf = bodyChunks(response.body, signal); first = await chunksOf.next(); } } catch (err) { if (last || retry.retryTransport !== true || signal?.aborted === true) throw err; await wait(Math.min(2 ** attempt, retry.maxBackoff)); continue; } if (response.status >= 400) { if (last || !worthRetrying(response.status, text, retry)) { throw options.errorOf(response, text); } await wait(retryDelay(response, text, attempt, retry)); continue; } options.onResponse?.(loweredHeaders(response.headers)); if (chunksOf === null || first === null || first.done === true) return; try { yield first.value; yield* chunksOf; } finally { // a caller that stops at the first chunk never entered the delegation, // so the body is released here rather than by it await chunksOf.return(); } return; } } /** Pull a response body in bounded chunks, cancelling it on early exit. */ export async function* bodyChunks(body, signal) { const reader = body.getReader(); let finished = false; try { for (;;) { const result = await reader.read(); if (result.done) { finished = true; return; } yield* chunks(result.value, signal); } } finally { try { if (!finished) await reader.cancel(); } finally { reader.releaseLock(); } } } export const NO_RETRY = { statuses: new Set(), maxRetries: 0, maxBackoff: 30, delaySource: 'header', }; /** * `base` and `path` joined by one slash, with each defined `query` entry * URI-encoded onto the end; an undefined value is left out. */ export function buildUrl(base, path, query) { const trimmed = rstripSlash(base); const cleanPath = path.startsWith('/') ? path : `/${path}`; const params = []; for (const [k, v] of Object.entries(query)) { if (v === undefined) continue; params.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`); } const qs = params.length > 0 ? `?${params.join('&')}` : ''; return `${trimmed}${cleanPath}${qs}`; } /** * A response's headers as a plain record, names lower-cased; fetch already * joins a repeated header, as python's lowered_headers does. */ export function loweredHeaders(headers) { const out = {}; headers.forEach((value, name) => { out[name.toLowerCase()] = value; }); return out; } /** * Whether a server-supplied delay is one we can actually wait out. NaN and * infinity are unusable (`setTimeout` silently clamps both to 1ms, turning * the wait into a hot retry), and a negative delay is malformed per RFC * 9110, so all three fall back the way an unparseable header does. */ export function usableDelay(value) { return Number.isFinite(value) && value >= 0; } export function headerDelay(response, attempt, retry) { const value = response.headers.get('Retry-After'); if (value !== null) { const parsed = Number.parseFloat(value); // maxBackoff is the policy's ceiling on every inter-attempt wait, so a // server asking for more gets the ceiling if (usableDelay(parsed)) return Math.min(parsed, retry.maxBackoff); } return Math.min(2 ** attempt, retry.maxBackoff); } export function bodyDelay(text, retry) { let data = {}; try { data = JSON.parse(text); } catch { data = {}; } const value = typeof data === 'object' && data !== null ? data.retry_after : undefined; // JSON.parse rejects a bare NaN literal but overflows 1e999 to Infinity, // so a body delay needs the same guard as a header one. return typeof value === 'number' && usableDelay(value) ? Math.min(value, retry.maxBackoff) : Math.min(1, retry.maxBackoff); } /** Raise a wait to the policy's floor for this status, then cap it. */ export function flooredDelay(delay, status, retry) { const floor = retry.minDelays?.[status]; if (floor === undefined) return delay; return Math.min(Math.max(delay, floor), retry.maxBackoff); } function worthRetrying(status, text, retry) { return (retry.statuses.has(status) && (retry.retryable === undefined || retry.retryable(status, text))); } function retryDelay(response, text, attempt, retry) { const delay = retry.delaySource === 'body' ? bodyDelay(text, retry) : headerDelay(response, attempt, retry); return flooredDelay(delay, response.status, retry); } /** Collect a bounded byte response, discarding an ignored Range prefix. */ export async function readBytes(response, window, maxBodyBytes) { if (response.body === null) return new Uint8Array(); const ranged = response.status === 206; let skip = ranged ? 0 : (window?.offset ?? 0); const size = window?.size; const limit = size === undefined || size === null ? maxBodyBytes : Math.min(size, maxBodyBytes); let data = new Uint8Array(); let length = 0; for await (const chunk of bodyChunks(response.body)) { const skipped = Math.min(skip, chunk.length); skip -= skipped; const available = chunk.subarray(skipped); const take = !ranged && size != null ? Math.min(available.length, size - length) : available.length; if (length + take > limit) throw new ResponseTooLargeError(limit); if (take > 0) { const needed = length + take; if (needed > data.length) { const capacity = Math.min(limit, Math.max(CHUNK_SIZE, needed, data.length * 2)); const grown = new Uint8Array(capacity); grown.set(data); data = grown; } data.set(available.subarray(0, take), length); length = needed; } if (!ranged && size != null && length === size) break; } return data.slice(0, length); } /** * One round-trip against an HTTP API, with retry and error mapping. * Returns the reply per `options.read`, defaulting to the parsed JSON * body (null when the body is empty, e.g. a 204). */ export async function apiRequest(method, url, options) { const { maxBodyBytes = MAX_BODY_BYTES } = options; if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 0) { throw new RangeError('max_body_bytes must be a non-negative safe integer'); } const doFetch = stallTimeout(options.timeout, options.fetchFn); const target = new URL(url); for (const [name, value] of Object.entries(options.params ?? {})) { target.searchParams.set(name, String(value)); } const headers = { ...options.headers }; if (options.window !== undefined) { const range = rangeHeader(options.window.offset, options.window.size); if (range !== null) headers.Range = range; } // The call's signal (a line's stop, a job's kill or limit), so a stopped // command ends its request as Python's cancelled task does. const signal = options.signal ?? callerSignal(); const retry = options.retry ?? NO_RETRY; const wait = options.sleep ?? ((seconds) => sleep(seconds * 1000, signal)); const read = options.read ?? 'json'; const asBytes = read === 'bytes' || read === 'bytes_response'; const discard = read === 'none' || read === 'location'; let attempt = 0; for (;;) { const init = { method, headers: { ...headers } }; if (signal !== undefined) init.signal = signal; if (options.json !== undefined) init.body = JSON.stringify(options.json); else if (options.body !== undefined) init.body = options.body; let response; try { response = await doFetch(target.toString(), init); } catch (err) { // a rejection is connection-level: HTTP errors resolve normally; a // caller's abort is not one worth another attempt if (signal?.aborted !== true && retry.retryTransport === true && attempt < retry.maxRetries) { await wait(Math.min(2 ** attempt, retry.maxBackoff)); attempt += 1; continue; } throw err; } let text = ''; let data = new Uint8Array(); // A body that drops mid-read is a transport failure like one before the // answer: a policy with retryTransport resends on either, so a write that // must never repeat rides a policy without it. Nothing past this block // touches the network. try { if (response.status >= 400 || (!asBytes && !discard)) text = await response.text(); else if (discard) await response.body?.cancel(); else data = await readBytes(response, options.window, maxBodyBytes); } catch (err) { if (err instanceof ResponseTooLargeError) throw err; if (signal?.aborted !== true && retry.retryTransport === true && attempt < retry.maxRetries) { await wait(Math.min(2 ** attempt, retry.maxBackoff)); attempt += 1; continue; } throw err; } if (retry.statuses.has(response.status)) { if (worthRetrying(response.status, text, retry) && attempt < retry.maxRetries) { await wait(retryDelay(response, text, attempt, retry)); attempt += 1; continue; } // retries ran dry or were vetoed: the final retryable response maps // through the same hook a plain error status does throw options.errorOf(response, text); } if (response.status >= 400) throw options.errorOf(response, text); if (read === 'none') return null; if (read === 'location') return response.headers.get('Location'); if (read === 'bytes') return data; if (read === 'bytes_response') { return { data, status: response.status, headers: loweredHeaders(response.headers), }; } if (read === 'text') return text; if (read === 'response') { let decoded = null; if (text !== '') { try { decoded = JSON.parse(text); } catch { decoded = text; } } return { data: decoded, status: response.status, headers: loweredHeaders(response.headers), }; } return text === '' ? null : JSON.parse(text); } } //# sourceMappingURL=client.js.map