@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
314 lines • 13.9 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 { Octokit } from '@octokit/core';
import { retry } from '@octokit/plugin-retry';
import { throttling } from '@octokit/plugin-throttling';
import { RequestError } from '@octokit/request-error';
import { bodyChunks, loweredHeaders, MAX_BODY_BYTES, readBytes, stallTimeout, } from "../api/client.js";
import { ApiError } from "../api/errors.js";
import { API_BASE, API_VERSION, GRAPHQL_PATH, RAW_MEDIA_TYPE } from "./constants.js";
// A rate limit is a wait, not a failure, but an unbounded wait is a hang;
// three attempts is what octokit's own docs use for an unattended client.
// The count rides plugin-retry's own options rather than every request's:
// its limiter re-sends any failed request whose options carry a count, so a
// count there sent a 404 or a 422 four times over before reporting it.
const GITHUB_RETRIES = 3;
// The statuses plugin-retry leaves alone: its own list, plus 500. Octokit
// reports a request that got no response at all as a 500, so retrying 500
// spent 14 s on a refused connection before failing anyway, and a real 500
// is no more worth a retry: gh retries neither.
const NO_RETRY_STATUSES = [400, 401, 403, 404, 410, 422, 451, 500];
// The resolver's codes for a host that does not resolve.
const DNS_FAILURES = new Set(['ENOTFOUND', 'EAI_AGAIN', 'EAI_NONAME']);
const Kit = Octokit.plugin(retry, throttling);
/**
* Octokit reads `{name}` in a url as a route-template placeholder, and a path's
* `:name` as the legacy spelling of one, and drops either when nothing fills
* it, silently and without an error. Every caller here passes a path that is
* already final -- `gh api` takes one straight from the agent's command line,
* and `compare/main...owner:branch` loses its owner's branch. Escape braces,
* query colons and every path colon Octokit would read before it can.
*
* Args:
* path (string): the request path as the caller spelled it.
*
* Returns:
* string: braces and those colons percent-encoded, preserving the URL scheme.
*/
function escapeRoute(path) {
const escaped = path.replace(/\{/g, '%7B').replace(/\}/g, '%7D');
const query = escaped.indexOf('?');
const route = query < 0 ? escaped : escaped.slice(0, query);
const rest = query < 0 ? '' : `?${escaped.slice(query + 1).replace(/:/g, '%3A')}`;
return route.replace(/:(?=[a-z]\w)/g, '%3A') + rest;
}
/**
* The GraphQL endpoint of the install whose REST base is `baseUrl`.
*
* gh pairs the two by host (internal/ghinstance, GraphQLEndpoint and
* RESTPrefix): github.com serves REST at https://api.github.com/ and GraphQL
* at https://api.github.com/graphql, while a GitHub Enterprise Server serves
* REST at https://HOST/api/v3/ and GraphQL at https://HOST/api/graphql, which
* is not under the REST base. Octokit's own graphql client draws the same
* line.
*
* Args:
* baseUrl (string): the REST base the install is configured with.
*
* Returns:
* string: the URL GraphQL queries are posted to.
*/
export function graphqlUrl(baseUrl) {
const base = baseUrl.replace(/\/+$/, '');
return base.endsWith('/api/v3') ? `${base.slice(0, -'/v3'.length)}/graphql` : `${base}/graphql`;
}
export class HttpGitHubTransport {
baseUrl;
kit;
fetcher;
constructor(opts) {
this.baseUrl = opts.baseUrl ?? API_BASE;
this.fetcher = stallTimeout(opts.timeout);
this.kit = new Kit({
auth: opts.token,
baseUrl: this.baseUrl,
retry: { retries: GITHUB_RETRIES, doNotRetry: NO_RETRY_STATUSES },
throttle: {
// The write limiter holds every non-GET a second apart, which is
// github.com's own guidance and its secondary rate limit. That limit
// is github.com's, not the API's: a GHES install does not impose it
// and a fake certainly does not, so paying it there would add a
// second per write for nothing.
enabled: this.baseUrl === API_BASE,
onRateLimit: (_after, _options, _kit, count) => count < GITHUB_RETRIES,
onSecondaryRateLimit: (_after, _options, _kit, count) => count < GITHUB_RETRIES,
},
});
}
get(path, params) {
return this.request('GET', path, undefined, params);
}
async request(method, path, body, params, headers) {
return (await this.requestWithResponse(method, path, body, params, headers)).data;
}
async requestWithResponse(method, path, body, params, headers) {
const r = await this.send(method, path, body, params, headers);
// Collect outside Octokit's retry policy: exceeding the byte limit
// is a refusal, never a reason to send the request again.
const source = r.data === '' ? null : r.data;
const data = await readBytes({ body: source, status: r.status }, undefined, MAX_BODY_BYTES);
const responseHeaders = {};
for (const [key, value] of Object.entries(r.headers)) {
if (value !== undefined)
responseHeaders[key.toLowerCase()] = String(value);
}
return {
data: decoded(data, responseHeaders['content-type'] ?? ''),
status: r.status,
headers: responseHeaders,
};
}
async *stream(path) {
const r = await this.send('GET', path, undefined, undefined, { accept: RAW_MEDIA_TYPE });
const body = r.data;
if (body !== null)
yield* bodyChunks(body);
}
async send(method, path, body, params, headers) {
let failed;
try {
// Octokit reads loose parameters off the same object that carries
// `url`, `method` and `headers`, so a field the agent typed would
// steer the request rather than ride in it: `gh api X -f url=...`
// retargeted the call. The query is spelled into the url and the body
// travels as `data`, which octokit sends verbatim, so neither can
// collide with a transport option. A call with neither sends no body
// at all, which is what a bare DELETE has to look like on the wire.
const query = new URLSearchParams(params ?? {}).toString();
const target = path === GRAPHQL_PATH ? graphqlUrl(this.baseUrl) : path;
return await this.kit.request({
method: method.toUpperCase(),
url: escapeRoute(target) + (query === '' ? '' : `${target.includes('?') ? '&' : '?'}${query}`),
headers: { 'X-GitHub-Api-Version': API_VERSION, ...headers },
request: {
fetch: async (input, init) => {
failed = undefined;
const response = await this.fetcher(input, init);
if (response.status >= 400)
failed = response.clone();
return response;
},
parseSuccessResponseBody: false,
},
...(body === undefined ? {} : { data: body }),
});
}
catch (err) {
if (err instanceof RequestError) {
// Octokit composes its message as `<message> - <documentation_url>`.
// The suffix is octokit's, not GitHub's: the service says only the
// message, real gh prints only the message, and the python client
// reports only the message. Read it off the body rather than
// trimming the composed string. The body itself travels verbatim,
// since `gh api` prints it.
if (err.response === undefined)
throw connectionError(err);
if (failed === undefined)
throw new GitHubApiError(err.message, err.status);
const body = await failed.text();
throw new GitHubApiError(apiMessage(body, failed.statusText), err.status, body, failed.url || err.request.url, loweredHeaders(failed.headers));
}
throw err;
}
}
}
/**
* The error for a call that got no response at all, worded as gh words it.
* A host that does not resolve reads "error connecting to HOST" with a
* pointer at GitHub's status page (gh's `printError`), a refused connection
* reads as Go's client reports one, `Get "URL": dial tcp ADDR: connect:
* connection refused`, and anything else as the transport said it.
*/
function connectionError(err) {
const reason = err.cause?.cause;
const code = typeof reason?.code === 'string' ? reason.code : '';
if (code === 'ECONNREFUSED' && typeof reason?.address === 'string') {
const method = err.request.method;
const verb = method.charAt(0) + method.slice(1).toLowerCase();
const address = reason.address.includes(':') ? `[${reason.address}]` : reason.address;
return new GitHubConnectionError(`${verb} "${err.request.url}": dial tcp ${address}:${String(reason.port)}: connect: connection refused`);
}
if (!DNS_FAILURES.has(code))
return new GitHubConnectionError(err.message);
let host = '';
try {
host = new URL(err.request.url).hostname;
}
catch (parse) {
if (!(parse instanceof TypeError))
throw parse;
host = err.request.url;
}
return new GitHubConnectionError(`error connecting to ${host}\ncheck your internet connection or https://githubstatus.com`);
}
/**
* A GitHub call that got no response: the connection was refused, the host
* did not resolve, or the transport failed before any status arrived. It is
* not a `GitHubApiError`, because there is no status to report, and it is
* never retried.
*/
export class GitHubConnectionError extends Error {
constructor(message) {
super(message);
this.name = 'GitHubConnectionError';
}
}
/** Decode a bounded successful response the same way as Python's _decoded. */
function decoded(body, contentType) {
if (body.length === 0)
return null;
const [type = '', ...parameters] = contentType.split(';');
const mime = type.trim().toLowerCase();
const parameter = parameters.find((part) => part.trim().toLowerCase().startsWith('charset='));
const charset = parameter
?.slice(parameter.indexOf('=') + 1)
.trim()
.replace(/^"+|"+$/g, '')
.toLowerCase();
if (mime === '')
return new TextDecoder().decode(body);
if (mime === 'application/json' || mime === 'application/scim+json') {
const text = new TextDecoder().decode(body);
try {
return JSON.parse(text);
}
catch {
return text;
}
}
if (mime.startsWith('text/') || charset === 'utf-8')
return new TextDecoder().decode(body);
return body;
}
/** A response body decoded as JSON, the text itself when it is not JSON. */
function decodedBody(body) {
if (body === '')
return null;
try {
return JSON.parse(body);
}
catch {
return body;
}
}
/**
* GitHub's own wording for a failure, or the status reason, the twin of
* python's `_api_message`.
*/
function apiMessage(body, reason) {
const data = decodedBody(body);
const message = typeof data === 'object' && data !== null ? data.message : undefined;
return typeof message === 'string' ? message : reason || body;
}
/**
* A GitHub call that answered with a status the caller cannot use.
*
* `body` is the response text as it arrived, `data` that text decoded (the
* text itself when it is not JSON), `url` the final request URL, query
* included, and `headers` the response's, lowercased, which `gh api -i`
* prints for a failing response as for any other.
*/
export class GitHubApiError extends ApiError {
body;
url;
data;
headers;
constructor(message, status, body = '', url = '', headers = {}) {
super(message, status);
this.name = 'GitHubApiError';
this.body = body;
this.url = url;
this.data = decodedBody(body);
this.headers = headers;
}
}
export async function fetchRepoInfo(transport, owner, repo) {
const data = (await transport.get(`/repos/${owner}/${repo}`));
return data;
}
export async function fetchBlob(transport, owner, repo, sha) {
const data = (await transport.get(blobPath(owner, repo, sha)));
if (data.encoding !== 'base64') {
throw new GitHubApiError(`unexpected blob encoding: ${data.encoding}`, 0);
}
const bin = atob(data.content.replace(/\n/g, ''));
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++)
out[i] = bin.charCodeAt(i);
return out;
}
/**
* A blob's bytes as they download, in GitHub's raw media type, or whole from
* a transport that cannot stream.
*/
export async function* streamBlob(transport, owner, repo, sha) {
if (transport.stream === undefined)
yield await fetchBlob(transport, owner, repo, sha);
else
yield* transport.stream(blobPath(owner, repo, sha));
}
function blobPath(owner, repo, sha) {
return `/repos/${owner}/${repo}/git/blobs/${sha}`;
}
//# sourceMappingURL=client.js.map