UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

289 lines • 11.2 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 { sleep } from "../../utils/abort.js"; import {} from "../../utils/ranges.js"; import { apiRequest, headerDelay, stallTimeout, } from "../api/client.js"; import { ApiError } from "../api/errors.js"; import { graphApi } from "./config.js"; import { MAX_BACKOFF, RETRY_STATUSES } from "./constants.js"; // A drive-relative item path as a Graph URL path: each segment escaped on // its own, so a `/` inside the path stays a separator. export function encodedPath(path) { return path .split('/') .filter((part) => part !== '') .map(encodeURIComponent) .join('/'); } export class GraphError extends ApiError { code; constructor(status, code, message) { super(`Graph API error ${String(status)} (${code}): ${message}`, status); this.name = 'GraphError'; this.code = code; } } async function tokenOf(config) { return typeof config.accessToken === 'function' ? await config.accessToken() : config.accessToken; } async function graphHeaders(config) { return { Authorization: `Bearer ${await tokenOf(config)}`, 'Content-Type': 'application/json', }; } function policy(config) { return { statuses: RETRY_STATUSES, maxRetries: config.maxRetries, maxBackoff: MAX_BACKOFF, delaySource: 'header', }; } function graphErrorOf(method, url, response, text) { let code = 'unknownError'; let message = `${method} ${url}`; try { const payload = JSON.parse(text); if (typeof payload.error?.code === 'string') code = payload.error.code; if (typeof payload.error?.message === 'string') message = payload.error.message; } catch { message = `${method} ${url}`; } return new GraphError(response.status, code, message); } // Graph answers 204 and the odd empty 200 for calls that worked; the caller // gets an empty object rather than a parse error. A non-object body reads as // empty too. function lenientJson(text) { if (text === '') return {}; const value = JSON.parse(text); return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}; } async function graphRequest(config, method, rawUrl, options = {}) { const target = new URL(rawUrl); for (const [name, value] of Object.entries(options.params ?? {})) { target.searchParams.set(name, String(value)); } const url = target.toString(); const read = options.read ?? 'json'; let refreshed = false; for (;;) { const headers = options.auth === false ? {} : await graphHeaders(config); Object.assign(headers, options.headers); const opts = { errorOf: (response, text) => graphErrorOf(method, url, response, text), headers, retry: policy(config), read: read === 'json' ? 'text' : read, window: options.window, timeout: config.timeout, }; if (options.json !== undefined) opts.json = options.json; else if (options.data !== undefined) opts.body = options.data; try { const result = await apiRequest(method, url, opts); return read === 'json' ? lenientJson(result) : result; } catch (err) { // a 401 under a token provider means the token aged out mid-flight: // mint a fresh one and replay the call once if (err instanceof GraphError && err.status === 401 && options.auth !== false && !refreshed && typeof config.accessToken === 'function') { refreshed = true; continue; } throw err; } } } export async function graphGet(config, url, params) { return (await graphRequest(config, 'GET', url, params === undefined ? {} : { params })); } /** * Every item a Graph collection answers, a page at a time; no page past the * one that brings `limit` items. Mirrors Python's `graph_list`. */ export async function graphList(config, url, params, limit) { const items = []; let next = url; let nextParams = params; while (next !== null) { const payload = await graphGet(config, next, nextParams); if (Array.isArray(payload.value)) { for (const item of payload.value) { if (item !== null && typeof item === 'object' && !Array.isArray(item)) { items.push(item); } } } if (limit !== undefined && items.length >= limit) break; next = typeof payload['@odata.nextLink'] === 'string' ? payload['@odata.nextLink'] : null; nextParams = undefined; } return items; } export async function graphGetBytes(config, url, window, auth = true) { return (await graphRequest(config, 'GET', url, { auth, read: 'bytes', window, })); } export async function* graphStream(config, url, auth = true) { // A chunked generator cannot ride apiRequest: the body outlives the call, // so the response must stay open while the caller consumes it. let attempt = 0; let refreshed = false; let response; for (;;) { const headers = auth ? await graphHeaders(config) : {}; response = await stallTimeout(config.timeout)(url, { method: 'GET', headers }); if (RETRY_STATUSES.has(response.status) && attempt < config.maxRetries) { await sleep(headerDelay(response, attempt, policy(config)) * 1000); attempt += 1; continue; } if (response.status === 401 && auth && !refreshed && typeof config.accessToken === 'function') { refreshed = true; continue; } if (!response.ok) throw graphErrorOf('GET', url, response, await response.text()); break; } if (response.body === null) return; const reader = response.body.getReader(); for (;;) { const result = await reader.read(); if (result.done) return; yield result.value; } } export async function graphPost(config, url, body = {}) { return (await graphRequest(config, 'POST', url, { json: body })); } /** * The answers to several GETs from one JSON batch (`$batch`), each as its * status, its body and the seconds its `Retry-After` asks to be waited, in the * order of `urls`. * * Graph runs at most `BATCH_LIMIT` requests in a batch. Each URL is a full one * under the mount's service root, and goes in relative to it, as a batch names * its requests. A request the reply leaves out answers status 0, a body that * is not an object answers empty, and a wait is at most `MAX_BACKOFF`, 0 when * the answer names none. Mirrors Python's `graph_batch`. */ export async function graphBatch(config, urls) { const root = graphApi(config); const requests = urls.map((url, i) => ({ id: String(i), method: 'GET', url: url.startsWith(root) ? url.slice(root.length) : url, })); const data = await graphPost(config, `${root}/$batch`, { requests }); const answers = new Map(); for (const reply of Array.isArray(data.responses) ? data.responses : []) { if (reply === null || typeof reply !== 'object' || Array.isArray(reply)) continue; const { id, status, body, headers } = reply; const page = body !== null && typeof body === 'object' && !Array.isArray(body); const named = headers !== null && typeof headers === 'object' && !Array.isArray(headers); answers.set(String(id), [ typeof status === 'number' ? status : 0, page ? body : {}, retryAfter(named ? headers : {}), ]); } return urls.map((_, i) => answers.get(String(i)) ?? [0, {}, 0]); } /** * The seconds a batched answer's `Retry-After` asks to be waited, at most * `MAX_BACKOFF`; 0 for none, or for one that is not a count of seconds. * Mirrors Python's `_retry_after`. */ export function retryAfter(headers) { for (const [name, value] of Object.entries(headers)) { if (name.toLowerCase() === 'retry-after') { const text = String(value).trim(); return /^[0-9]+$/.test(text) ? Math.min(Number(text), MAX_BACKOFF) : 0; } } return 0; } export async function graphPostMonitor(config, url, body = {}) { const location = await graphRequest(config, 'POST', url, { json: body, read: 'location' }); if (typeof location !== 'string' || location === '') { throw new GraphError(502, 'missingMonitor', `POST ${url} did not return a Location header`); } return location; } export async function graphPatch(config, url, body) { return (await graphRequest(config, 'PATCH', url, { json: body })); } export async function graphDelete(config, url) { await graphRequest(config, 'DELETE', url, { read: 'none' }); } export async function graphPutBytes(config, url, data) { return (await graphRequest(config, 'PUT', url, { data, headers: { 'Content-Type': 'application/octet-stream' }, })); } function monitorError(url, response) { return new GraphError(response.status, 'monitorError', `GET ${url}`); } export async function pollMonitor(url, timeout, interval = 1) { let waited = 0; for (;;) { const raw = await apiRequest('GET', url, { errorOf: (response) => monitorError(url, response), }); const payload = (raw ?? {}); if (typeof payload.status !== 'string' || payload.status === '') { throw new GraphError(502, 'invalidMonitorResponse', `GET ${url} did not return a status`); } if (payload.status === 'completed' || payload.status === 'failed' || waited >= timeout) { return payload; } await sleep(interval * 1000); waited += interval; } } export async function uploadChunk(config, uploadUrl, data, start, total) { const end = start + data.length - 1; return (await graphRequest(config, 'PUT', uploadUrl, { auth: false, data, headers: { 'Content-Range': `bytes ${String(start)}-${String(end)}/${String(total)}` }, })); } export async function cancelUpload(config, uploadUrl) { await graphRequest(config, 'DELETE', uploadUrl, { auth: false, read: 'none' }); } //# sourceMappingURL=client.js.map