@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
439 lines • 17.3 kB
JavaScript
// ========= 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