/**
* CAP HTTP client — thin wrapper over fetch.
*
* Every method reads the active instance URL and token from config
* unless overridden. This is the only place that makes HTTP calls.
*/
import { resolveInstanceUrl, getToken, resolveInstance } from './config.js';
const headers = (token, extra = {}) => ({
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...extra,
});
const knownSessionFailure = (body) => [body?.msg, body?.error, body?.message]
.some((value) => ['Token is not valid', 'Invalid API token', 'Account no longer exists'].includes(value));
export const sessionExpiredMessage = ({ instanceKey, baseUrl }) => (
`Session for ${instanceKey} (${baseUrl}) has expired.\nRun: commonly login --instance ${instanceKey}`
);
// A non-JSON error body is usually an edge proxy's HTML page; a Cloudflare
// tunnel error is ~120 lines, and the message used to be all of them, so every
// failed poll dumped a whole page into the agent log. The message names the
// page by its
; err.body keeps the full text for callers that classify it.
const MAX_TEXT_ERROR = 300;
const summarizeTextError = (text, status) => {
const trimmed = String(text || '').trim();
if (!trimmed) return null;
if (/^<(?:!doctype|html)/i.test(trimmed) || /]/i.test(trimmed)) {
const title = trimmed.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, ' ').trim();
return title ? `HTTP ${status}: ${title}` : `HTTP ${status} (HTML error page)`;
}
const line = trimmed.replace(/\s+/g, ' ');
return line.length > MAX_TEXT_ERROR ? `${line.slice(0, MAX_TEXT_ERROR)}…` : line;
};
const handleResponse = async (res, session = null) => {
const text = await res.text();
let body;
let isJson = true;
try { body = JSON.parse(text); } catch { body = { message: text }; isJson = false; }
if (!res.ok) {
const serverMessage = isJson
? body?.error || body?.message || body?.msg
: summarizeTextError(text, res.status);
const msg = res.status === 401 && session && knownSessionFailure(body)
? sessionExpiredMessage(session)
: serverMessage || `HTTP ${res.status}`;
const err = new Error(msg);
err.status = res.status;
err.body = body;
throw err;
}
return body;
};
// TASK-338: every CAP request carries a hard timeout. Without one, a request
// that a proxy or the network accepts and never answers leaves its promise
// pending for good, and the run loop awaiting it is alive but wedged: nib and
// folio each sat 8+ hours on one unanswered poll on 10-31, and their keepalives,
// which respawn only on exit, could not see it. The poll-retry backoff
// (TASK-025) only handles a fetch that throws, so a timeout throws: it is an
// ordinary failed request, retried with backoff, and never an ack.
//
// Reads get the base timeout; writes and uploads carry a body and a server-side
// write, so they get a multiple of it. COMMONLY_CAP_TIMEOUT_MS sets the base.
export const CAP_TIMEOUT_DEFAULT_MS = 30_000;
const TIMEOUT_SCALE = { read: 1, write: 2, upload: 4 };
export const capTimeoutMs = (kind, { baseMs, env = process.env } = {}) => {
const fromEnv = Number(env.COMMONLY_CAP_TIMEOUT_MS);
const base = [baseMs, fromEnv].find((value) => Number.isFinite(value) && value > 0)
?? CAP_TIMEOUT_DEFAULT_MS;
return Math.min(base * (TIMEOUT_SCALE[kind] || 1), 2_147_483_647);
};
// A caller's own signal (doctor, and the 5s identity and skills reads in the
// run loop) still applies; the hard timeout is added beside it, never instead.
// AbortSignal.any is Node 20.3+, and `engines` allows 20.0, so the fallback
// joins the two by hand. Either way the abort carries the source's own reason,
// which is what nameTimeout matches on.
const capSignal = (capTimeout, callerSignal) => {
if (!callerSignal) return capTimeout;
if (typeof AbortSignal.any === 'function') return AbortSignal.any([callerSignal, capTimeout]);
const joined = new AbortController();
for (const source of [callerSignal, capTimeout]) {
if (source.aborted) {
joined.abort(source.reason);
break;
}
source.addEventListener('abort', () => joined.abort(source.reason), { once: true });
}
return joined.signal;
};
export const formatLimit = (ms) => (ms < 1000 ? `${ms}ms` : `${Math.round(ms / 1000)}s`);
// The cap's timeout rejects with a DOMException named TimeoutError, from the
// fetch or from the body read. Name the request and the limit instead, so the
// log line reads as an unanswered request rather than "operation was aborted".
// Only the cap's own timeout is renamed: a caller's abort or its own shorter
// timeout passes through as it was thrown.
const nameTimeout = (capTimeout, method, path, ms) => (err) => {
// By identity: the caller's timeout can fire first and the cap's a moment
// later, and then `capTimeout.aborted` alone would claim the caller's error.
if (err?.name !== 'TimeoutError' || err !== capTimeout.reason) throw err;
throw Object.assign(
new Error(`${method} ${path} got no answer in ${formatLimit(ms)}`),
{ code: 'cap_timeout', timeoutMs: ms },
);
};
const timedFetch = (url, init, { method, path, ms, session }) => {
const capTimeout = AbortSignal.timeout(ms);
return fetch(url, { ...init, signal: capSignal(capTimeout, init.signal) })
.then((res) => handleResponse(res, session))
.catch(nameTimeout(capTimeout, method, path, ms));
};
export const createClient = ({ instance = null, token = undefined, timeoutMs = undefined } = {}) => {
const baseUrl = resolveInstanceUrl(instance);
const resolved = resolveInstance(instance);
const authToken = token === undefined ? getToken(instance) : token;
const session = {
instanceKey: resolved?.key || (typeof instance === 'string' && instance && !/^https?:\/\//i.test(instance) ? instance : 'default'),
baseUrl,
};
const limit = (kind) => capTimeoutMs(kind, { baseMs: timeoutMs });
const get = (path, params = {}, options = {}) => {
const url = new URL(`${baseUrl}${path}`);
Object.entries(params).forEach(([k, v]) => v != null && url.searchParams.set(k, v));
return timedFetch(url.toString(), { headers: headers(authToken), ...options }, {
method: 'GET', path, ms: limit('read'), session,
});
};
const post = (path, body = {}) => timedFetch(`${baseUrl}${path}`, {
method: 'POST',
headers: headers(authToken),
body: JSON.stringify(body),
}, { method: 'POST', path, ms: limit('write'), session });
const patch = (path, body = {}) => timedFetch(`${baseUrl}${path}`, {
method: 'PATCH',
headers: headers(authToken),
body: JSON.stringify(body),
}, { method: 'PATCH', path, ms: limit('write'), session });
const del = (path, body) => timedFetch(`${baseUrl}${path}`, {
method: 'DELETE',
headers: headers(authToken),
...(body === undefined ? {} : { body: JSON.stringify(body) }),
}, { method: 'DELETE', path, ms: limit('write'), session });
// Multipart upload via native FormData/Blob (Node 18+) — no runtime deps.
// Content-Type is deliberately NOT set: fetch writes the multipart boundary.
const upload = (path, {
fileBuffer, fileName, contentType, fileField = 'file', fields = {},
}) => {
const form = new FormData();
form.append(
fileField,
new Blob([fileBuffer], { type: contentType || 'application/octet-stream' }),
fileName,
);
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined && v !== null) form.append(k, String(v));
}
return timedFetch(`${baseUrl}${path}`, {
method: 'POST',
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
body: form,
}, { method: 'POST', path, ms: limit('upload'), session });
};
return {
get, post, patch, del, upload, baseUrl,
};
};
// Convenience: login doesn't need a token
export const login = (instanceUrl, email, password) => {
const ms = capTimeoutMs('write');
return timedFetch(`${instanceUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
}, { method: 'POST', path: '/api/auth/login', ms, session: null });
};