UNPKG

@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
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