@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.
468 lines • 19 kB
JavaScript
import WebSocket from "ws";
import { ExponentialBackoff } from "./backoff.js";
import { WsCloseCode } from "./ws-frames.js";
import { logger } from "../logger.js";
import { loadPollCursor, savePollCursor } from "./poll-cursor.js";
/**
* WebSocket client with reconnection logic and poll fallback.
*
* Manages the full connection lifecycle:
* - Connects to server WS endpoint with token + peerId auth
* - Dispatches all 6 WsServerFrame types to callbacks
* - Falls back to HTTP polling on disconnect
* - Reconnects with exponential backoff (1s-30s with 10% jitter)
* - Supports deferred peerId via setPeerId() for lazy connection
* - Supports runtime credential updates via updateToken/updateSessionId
*/
export class CloudWsClient {
ws = null;
state = "disconnected";
backoff;
reconnectTimer = null;
pollTimer = null;
/**
* DELIVERY-PATH EVIDENCE. Before 3.23.9 a poll failure was caught and only logger.warn'd, so the
* bridge had no idea whether its FALLBACK delivery path worked — and `healthy` could not be
* computed honestly because the data did not exist. Captured evidence that nothing reads is the
* defect shape this codebase keeps hitting.
*/
lastPollOkAt = null;
lastPollError = null;
lastWsErrorSticky = null;
pingTimer = null;
lastMessageId = null;
/**
* Advance the poll cursor AND persist it.
*
* Every assignment must go through here. `lastMessageId` was in-memory only, so a restart sent
* no cursor and the relay replayed the whole channel ("If lastMessageId is not provided (first
* poll), returns all messages") — re-injecting days-old broadcasts on every restart.
*/
advanceCursor(id) {
if (!id)
return;
this.lastMessageId = id;
savePollCursor(this.opts.sessionId, this.opts.peerId, id);
}
/**
* Seed the cursor from disk when this process has not consumed anything yet.
*
* Only ever fills a NULL cursor: a live in-memory position is always newer than the stored one,
* so it must never be overwritten by a restore.
*/
restoreCursor() {
if (this.lastMessageId)
return;
const stored = loadPollCursor(this.opts.sessionId, this.opts.peerId);
if (stored) {
this.lastMessageId = stored;
logger.info(`CloudWsClient: resuming from stored poll cursor (${stored.slice(0, 8)}…) — the channel ` +
`backlog before it has already been consumed and will not be replayed.`);
}
}
lastWsError = null;
connectionGeneration = 0;
opts;
/**
* Wall-clock timestamp (ms since epoch) of the last frame received on this
* WebSocket. Survives reconnects and disconnects -- intentionally NOT reset
* on close, so an operator can see how stale the transport is.
* Surfaced to cogent_health_check as transport.wsLastFrameAt (ISO string).
*/
lastFrameAt = null;
constructor(options) {
this.opts = {
...options,
pollIntervalMs: options.pollIntervalMs ?? 5000,
};
this.backoff = new ExponentialBackoff();
}
/**
* Initiate WebSocket connection to the server.
* Guards against empty peerId -- call setPeerId() first.
*/
connect() {
this.restoreCursor();
if (!this.opts.peerId) {
logger.warn("CloudWsClient: Cannot connect WebSocket without peerId. Call setPeerId() first.");
return;
}
this.lastWsError = null;
this.clearReconnectTimer();
this.clearPingTimer();
const generation = ++this.connectionGeneration;
this.setState("connecting");
// Build WS URL: http->ws, https->wss
const wsUrl = this.opts.endpoint
.replace(/^https:/, "wss:")
.replace(/^http:/, "ws:");
const sessionPath = `/ws/${this.opts.sessionId}`;
const params = new URLSearchParams({
peerId: this.opts.peerId,
});
const url = `${wsUrl}${sessionPath}?${params.toString()}`;
const ws = new WebSocket(url, {
headers: {
Authorization: `Bearer ${this.opts.token}`,
},
});
this.ws = ws;
ws.on("open", () => {
if (generation !== this.connectionGeneration || this.ws !== ws)
return;
this.lastWsErrorSticky = null; // a successful open clears the standing complaint
this.setState("connected");
this.backoff.reset();
this.stopPolling();
// Send WS ping frames every 30s to keep the connection alive
// through nginx proxy_read_timeout (prevents idle disconnect)
this.clearPingTimer();
this.pingTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.ping();
}
}, 30_000);
this.pingTimer.unref?.();
});
ws.on("message", (data) => {
if (generation !== this.connectionGeneration || this.ws !== ws)
return;
// Stamp arrival time BEFORE attempting to parse: even an unparseable
// frame proves the transport is alive. This is what `transport.wsLastFrameAt`
// reports -- a "transport-alive" signal, not a "valid-data" signal.
this.lastFrameAt = Date.now();
try {
const frame = JSON.parse(data.toString());
this.dispatchFrame(frame);
}
catch {
logger.error("CloudWsClient: Failed to parse WebSocket frame");
}
});
// Control-frame liveness signals. The "message" listener only fires for
// data frames, but for low-traffic observer peers the only traffic on the
// wire may be our 30s ws.ping() keepalive (above) and the server's pong
// reply -- or a server-initiated ping. Without these listeners the
// EventEmitter contract (ws inherits from EventEmitter) means
// lastFrameAt never updates and wsHealth reports "stale" even though the
// transport is perfectly healthy. Record arrival under the same
// generation/instance guard as the message handler so stale-generation
// frames cannot mutate state on a superseded connection.
ws.on("pong", () => {
// Server response to our 30s ws.ping() keepalive — the primary
// freshness signal for low-traffic observer peers.
if (generation !== this.connectionGeneration || this.ws !== ws)
return;
this.lastFrameAt = Date.now();
});
ws.on("ping", () => {
// Server-initiated keepalive. The ws library auto-replies with a pong,
// so we only need to record arrival — no manual ws.pong() call.
if (generation !== this.connectionGeneration || this.ws !== ws)
return;
this.lastFrameAt = Date.now();
});
ws.on("close", (code, _reason) => {
if (generation !== this.connectionGeneration || this.ws !== ws)
return;
this.ws = null;
this.clearPingTimer();
// Check if the close was caused by a peer eviction (server returned 404
// on WS upgrade because peer was removed by stale cleanup). The ws library
// fires error with the HTTP status before close with code 1006.
const evicted = this.lastWsError !== null &&
(this.lastWsError.includes("404") || this.lastWsError.includes("PEER_NOT_FOUND"));
this.lastWsError = null;
if (evicted) {
// Peer was evicted by server -- don't reconnect (would just 404 again).
// Invoke callback so heartbeat/startup can trigger re-registration.
logger.warn("CloudWsClient: Peer evicted by server (stale cleanup). Stopping reconnect.");
this.setState("disconnected");
this.opts.onPeerEvicted?.();
return;
}
// Start polling fallback
this.setState("polling");
this.startPolling();
// Use longer initial delay for service restart
if (code === WsCloseCode.SERVICE_RESTART) {
// Create a fresh backoff with 5s base for graceful restarts
const restartBackoff = new ExponentialBackoff(5000);
const delay = restartBackoff.next();
this.clearReconnectTimer();
this.reconnectTimer = setTimeout(() => this.connect(), delay);
this.reconnectTimer.unref?.();
}
else {
this.scheduleReconnect();
}
});
ws.on("error", (err) => {
if (generation !== this.connectionGeneration || this.ws !== ws)
return;
this.lastWsError = err.message;
// A 401 here is auth-dead and never self-recovers — categorically different from a socket
// that is merely reconnecting. lastWsError is cleared by the close handler's eviction check,
// so keep a copy that survives for diagnosis.
this.lastWsErrorSticky = err.message;
logger.error(`CloudWsClient: WebSocket error: ${err.message}`);
// Don't change state here -- the "close" event follows and handles it
});
}
/**
* Disconnect from the WebSocket and stop all timers.
*/
disconnect() {
this.connectionGeneration++;
this.clearTimers();
const ws = this.ws;
this.ws = null;
if (ws &&
(ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING)) {
ws.close(WsCloseCode.NORMAL_CLOSURE);
}
this.setState("disconnected");
}
/**
* Set or update the peerId.
* This is the trigger mechanism for deferred WS connection.
* Plan 05 creates CloudWsClient at startup with empty peerId (peerId is
* only known after register-peer succeeds). After registration, it calls
* this method to initiate the WS connection.
*/
setPeerId(peerId) {
this.opts.peerId = peerId;
if (peerId && this.state === "disconnected") {
// Trigger initial connection now that we have a peerId
this.connect();
}
else if (peerId && this.state !== "disconnected") {
// Already connected/connecting -- disconnect and reconnect with new peerId
this.disconnect();
this.connect();
}
}
/**
* Get the current connection state.
*/
getState() {
return this.state;
}
/**
* True iff the WebSocket is in the "connected" state.
* Surfaced to cogent_health_check as transport.wsConnected.
*/
isConnected() {
return this.state === "connected";
}
/**
* ISO 8601 string of the last frame arrival time, or null if no frame has
* been received on this client instance yet. Survives reconnects --
* deliberately NOT reset on close so an operator can see staleness across
* reconnect cycles.
* Surfaced to cogent_health_check as transport.wsLastFrameAt.
*/
getLastFrameAt() {
return this.lastFrameAt === null
? null
: new Date(this.lastFrameAt).toISOString();
}
/**
* Raw wall-clock timestamp (ms since epoch) of the last frame, or null if
* no frame has arrived. Sibling of `getLastFrameAt()` -- same source field,
* but returns the underlying number so the consumer can compute deltas
* (e.g. transport.wsAgeMs in cogent_health_check) without re-parsing the
* ISO string. Survives reconnects, same as `getLastFrameAt()`.
* Audit L2.
*/
/**
* What this bridge's DELIVERY situation actually is — the input `healthy` needs in order to
* describe THIS BRIDGE rather than the relay's uptime.
*/
getDeliveryDiagnostics() {
return {
pollActive: this.pollTimer !== null,
lastPollOkAt: this.lastPollOkAt,
lastPollError: this.lastPollError,
lastWsError: this.lastWsErrorSticky,
};
}
getLastFrameAtMs() {
return this.lastFrameAt;
}
/**
* Update the last seen message ID (e.g., from HttpBackend operations).
*/
updateLastMessageId(id) {
this.advanceCursor(id);
}
/**
* Update the auth token for reconnection.
* If currently connected, disconnects and reconnects with the new token.
*/
updateToken(newToken) {
this.opts.token = newToken;
if (this.state === "connected" || this.state === "connecting") {
this.disconnect();
this.connect();
}
}
/**
* Point the socket at a DIFFERENT relay host.
*
* 🔴 WHY THIS EXISTS. The two relays are different HOSTS (cogent.tools vs app.cogent.tools), not
* just different credentials. `updateToken`/`updateSessionId` existed and `opts.endpoint` did
* not — so an agent that joined a FREE channel and then a TEAM channel in the SAME PROCESS kept
* dialling the OLD host while presenting the NEW token and session id. The upgrade 401s, the
* backoff retries it every 1-4s forever, and the inbound queue never drains — while HTTP looks
* perfectly healthy, because reinitCloudBackend builds a fresh HttpClient with the right base.
* Reported live by peer marvin-mobile on 2026-09-05 (queueLength stuck at 410).
*
* No-ops when the host is unchanged: re-joining another channel on the SAME relay is the common
* case, and tearing down a healthy socket for it would be a self-inflicted reconnect storm.
*/
updateEndpoint(newEndpoint) {
if (newEndpoint === this.opts.endpoint)
return;
this.opts.endpoint = newEndpoint;
if (this.state === "connected" || this.state === "connecting") {
this.disconnect();
this.connect();
}
}
/**
* Update the session ID.
* If currently connected, disconnects and reconnects.
*/
updateSessionId(newSessionId) {
this.opts.sessionId = newSessionId;
if (this.state === "connected" || this.state === "connecting") {
this.disconnect();
this.connect();
}
}
/**
* Replace the HttpClient used for polling.
* The next poll cycle will use the new client automatically.
*/
updateHttp(newHttp) {
this.opts.http = newHttp;
}
/**
* Send a delivery acknowledgment frame to the server, which routes it
* to the original sender so they know the message was received.
*/
sendDeliveryAck(messageId, fromPeerId, toPeerId) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
logger.warn("CloudWsClient: Cannot send delivery_ack: WebSocket not open");
return;
}
this.ws.send(JSON.stringify({
type: "delivery_ack",
payload: { messageId, fromPeerId, toPeerId },
}));
}
// -------------------------------------------------------------------------
// Private methods
// -------------------------------------------------------------------------
setState(newState) {
this.state = newState;
this.opts.onStateChange(newState);
}
dispatchFrame(frame) {
switch (frame.type) {
case "message":
this.opts.onMessage(frame.payload);
this.advanceCursor(frame.payload.id);
break;
case "queued_messages": {
const msgs = frame.payload.messages;
this.opts.onMessages(msgs);
if (msgs.length > 0) {
this.advanceCursor(msgs[msgs.length - 1].id);
}
break;
}
case "peer_connected":
this.opts.onPeerConnected(frame.payload);
break;
case "peer_disconnected":
this.opts.onPeerDisconnected(frame.payload.peerId);
break;
case "peers_snapshot":
this.opts.onPeersSnapshot(frame.payload.peers);
break;
case "error":
this.opts.onError?.(frame.payload);
break;
case "delivery_ack":
this.opts.onDeliveryAck?.(frame.payload);
break;
}
}
scheduleReconnect() {
this.clearReconnectTimer();
this.setState("reconnecting");
const delay = this.backoff.next();
this.reconnectTimer = setTimeout(() => this.connect(), delay);
this.reconnectTimer.unref?.();
}
startPolling() {
if (this.pollTimer)
return;
this.restoreCursor();
const poll = async () => {
try {
const path = `/api/sessions/${this.opts.sessionId}/poll`;
const query = {
peerId: this.opts.peerId,
};
if (this.lastMessageId) {
query.lastMessageId = this.lastMessageId;
}
const result = await this.opts.http.get(path, query);
if (result.messages && result.messages.length > 0) {
this.opts.onMessages(result.messages);
this.advanceCursor(result.messages[result.messages.length - 1].id);
}
// A COMPLETED poll is the proof that the fallback path delivers, whether or not it
// carried messages. Recorded, not just logged.
this.lastPollOkAt = Date.now();
this.lastPollError = null;
}
catch (err) {
this.lastPollError = err instanceof Error ? err.message : String(err);
logger.warn(`CloudWsClient: Poll failed: ${this.lastPollError}`);
}
};
// Poll immediately, then at interval
void poll();
this.pollTimer = setInterval(() => void poll(), this.opts.pollIntervalMs);
this.pollTimer.unref?.();
}
stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
}
clearTimers() {
this.clearReconnectTimer();
this.clearPingTimer();
this.stopPolling();
}
clearReconnectTimer() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
clearPingTimer() {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
}
//# sourceMappingURL=ws-client.js.map