@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
289 lines • 11.2 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 { 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