@essentialai/cogent-bridge
Version:
Cogent Bridge — cross-agent comms for Claude Code, OpenAI Codex and Slack. Codex users: install with `curl -fsSL https://cogent.tools/install.sh | sh`, then start with `cogent-codex` for real-time peer wake.
208 lines • 8.79 kB
JavaScript
import { BridgeError, BridgeErrorCode } from "../errors.js";
import { getConfig } from "../config.js";
/** Hard cap on any single HTTP request even if COGENT_TIMEOUT_MS is larger. */
const MAX_HTTP_TIMEOUT_MS = 30_000;
/**
* Per-request HTTP timeout: the configured COGENT_TIMEOUT_MS capped at 30s
* (control-plane calls are quick; the long response wait happens over the
* WebSocket, not a single fetch). Falls back to the cap when config is not
* loaded (e.g. unit tests).
*/
function httpTimeoutMs() {
try {
return Math.min(getConfig().COGENT_TIMEOUT_MS, MAX_HTTP_TIMEOUT_MS);
}
catch {
return MAX_HTTP_TIMEOUT_MS;
}
}
/**
* Authenticated HTTP client for cloud relay server communication.
* Uses Node built-in fetch() (Node 18+) with Bearer token auth.
* Maps HTTP error responses to BridgeError for consistent error handling.
*/
export class HttpClient {
baseUrl;
token;
constructor(baseUrl, token) {
this.baseUrl = baseUrl;
this.token = token;
}
headers() {
return {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.token}`,
};
}
/**
* fetch() with an AbortController timeout. Node's fetch has NO default
* timeout, so a stalled relay connection would otherwise block forever and
* stall the WS poll fallback. An abort maps to a NETWORK_TIMEOUT BridgeError;
* every other rejection (DNS, connection refused, etc.) propagates unchanged.
*/
async fetchWithTimeout(url, init) {
const timeoutMs = httpTimeoutMs();
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: ac.signal });
}
catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw new BridgeError(BridgeErrorCode.NETWORK_TIMEOUT, `HTTP request timed out after ${timeoutMs}ms`, "The relay server may be unreachable or overloaded; check connectivity.");
}
throw err;
}
finally {
clearTimeout(timer);
}
}
/**
* Parse a SUCCESS body, turning an unparseable one into a BridgeError.
*
* 🔴 WHY THIS EXISTS. `throwMappedError` has always guarded the NON-2xx path, so an nginx 502 or
* 413 HTML page arrives as a clean BridgeError. The 2xx path did not: `return resp.json()` let a
* truncated or empty success body escape as a raw `SyntaxError` carrying no code, no suggestion,
* and nothing in the signature to warn a caller it could happen — callers at
* http-backend.ts (list peers / get history / health check) simply propagated it.
*
* That is the worst corner of the trade-off the vault describes (how much information a failure
* carries vs how strictly consumers must handle it —
* memory/typescript-master-vault/concepts/Error_Handling.md), in a codebase whose own
* BridgeError(code, message, suggestion) is the answer to it.
*
* A 2xx with an unreadable body is REAL: @hono/node-server can answer with a null body or append
* `Error: …` to an already-streaming response (handleResponseError), and any proxy may truncate a
* long /poll body. STARTUP_FAILED is deliberately the same code throwMappedError falls back to
* for a non-JSON error body, so both directions of "the relay said something we cannot read"
* report identically rather than inventing a second vocabulary.
*/
async parseJson(resp, method, url) {
try {
return (await resp.json());
}
catch (e) {
const detail = e instanceof Error ? e.message : String(e);
throw new BridgeError(BridgeErrorCode.STARTUP_FAILED, `Relay returned HTTP ${resp.status} with an unreadable body for ${method} ${url.pathname}: ${detail}`, "The response was not valid JSON — usually a proxy error page or a truncated body. Retry; if it persists, check the relay and any proxy in front of it.");
}
}
/**
* Perform an authenticated GET request.
* Appends query parameters to the URL if provided.
*/
async get(path, query) {
const url = new URL(path, this.baseUrl);
if (query) {
for (const [k, v] of Object.entries(query)) {
url.searchParams.set(k, v);
}
}
const resp = await this.fetchWithTimeout(url, { headers: this.headers() });
if (!resp.ok) {
await this.throwMappedError(resp);
}
return this.parseJson(resp, "GET", url);
}
/**
* Perform an authenticated POST request with a JSON body.
*/
async post(path, body) {
const url = new URL(path, this.baseUrl);
const resp = await this.fetchWithTimeout(url, {
method: "POST",
headers: this.headers(),
body: JSON.stringify(body),
});
if (!resp.ok) {
await this.throwMappedError(resp);
}
// Handle 204 No Content (e.g., heartbeat endpoint)
if (resp.status === 204 || resp.headers?.get("content-length") === "0") {
return undefined;
}
return this.parseJson(resp, "POST", url);
}
/**
* Perform an authenticated PATCH request with a JSON body.
* Used to update existing resources (e.g., message response fields).
*/
async patch(path, body) {
const url = new URL(path, this.baseUrl);
const resp = await this.fetchWithTimeout(url, {
method: "PATCH",
headers: this.headers(),
body: JSON.stringify(body),
});
if (!resp.ok) {
await this.throwMappedError(resp);
}
if (resp.status === 204 || resp.headers?.get("content-length") === "0") {
return undefined;
}
return this.parseJson(resp, "PATCH", url);
}
/**
* Perform an authenticated DELETE request.
* Returns the RESPONSE HEADERS (a 204 carries no body, but the relay conveys
* out-of-band signals — e.g. Cogent Mail F3's `X-Cogent-Mailbox-Deprovisioned` —
* via headers, so callers that need them can read them without a body).
*
* When no body is provided, Content-Type is omitted so servers don't try
* to parse an empty payload as JSON. This was the root cause of a
* "Malformed JSON in request body" 400 from cogent-server <=3.1.2 when
* any client (including third-party tools) DELETEd a peer without a body.
*/
async delete(path, body) {
const url = new URL(path, this.baseUrl);
const headers = {
"Authorization": `Bearer ${this.token}`,
};
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
const resp = await this.fetchWithTimeout(url, {
method: "DELETE",
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!resp.ok && resp.status !== 204) {
await this.throwMappedError(resp);
}
return resp.headers;
}
/**
* Update the Bearer token (e.g., after join-session returns a new token).
*/
updateToken(newToken) {
this.token = newToken;
}
/**
* Parse an error response from the server and throw a BridgeError.
* If the server returns a cogent ErrorCode, it is passed through.
* For non-JSON error responses, STARTUP_FAILED is used as a fallback code.
*/
async throwMappedError(resp) {
let errorBody;
try {
errorBody = (await resp.json());
}
catch {
errorBody = { message: resp.statusText };
}
// Try to use the server's error code if it maps to a BridgeErrorCode,
// otherwise fall back to STARTUP_FAILED as the most generic code.
// 🔴 A peer-ownership refusal arrives as AUTH_INVALID_TOKEN with a "PEER_NOT_YOURS:" message.
// Reported as STARTUP_FAILED it read as a bridge fault, and the agent improvised (renamed).
const code = /PEER_NOT_YOURS/.test(errorBody.message ?? "")
? BridgeErrorCode.PEER_NOT_YOURS
: errorBody.code && errorBody.code in BridgeErrorCode
? errorBody.code
: BridgeErrorCode.STARTUP_FAILED;
const err = new BridgeError(code, errorBody.message ?? `HTTP ${resp.status}`, errorBody.suggestion);
if (typeof errorBody.retryAfterMs === "number" && Number.isFinite(errorBody.retryAfterMs)) {
err.retryAfterMs = errorBody.retryAfterMs;
}
throw err;
}
}
//# sourceMappingURL=http-client.js.map