/** * RelayClient — long-lived WebSocket connection to the Aexol backend's * `/agent-connection` endpoint. * * Responsibilities: * - Open and maintain a single WS to the relay, authenticated with the * machine JWT via `Authorization: Bearer `. * - Reply `{kind:"pong"}` to backend `{kind:"ping"}`. Backend closes * `4408 heartbeat-timeout` if it doesn't hear from us within 90s; we * keep this app-level reply in addition to the protocol-level heartbeat * below. * - **Client heartbeat:** additionally emits a protocol-level `ws` ping * every `HEARTBEAT_INTERVAL_MS` and requires a `pong` within * `HEARTBEAT_TIMEOUT_MS`. This detects a half-open send path (e.g. after * laptop sleep/wake) where `readyState` still reports `OPEN`. * - **Watchdog timer:** tracks `lastActivityMs` on every received frame / * ping. Every 15 s, if no activity within `WATCHDOG_MS` (120 s = 2× * backend timeout), terminates the socket and triggers a reconnect. * Detects silent backend death (Docker OOM * kill, network partition without TCP RST) where the socket stays open * but no data flows. * - **Pre-reconnect health check:** before opening a new WS, does a quick * HTTP GET to `backendUrl/health` with a 5 s timeout. If the backend is * unhealthy or unreachable, skips the WS attempt and re-schedules. This * avoids a slow TCP connect timeout (up to 75 s on some platforms) when * the backend is down. * - On unexpected close, reconnect forever with exponential backoff + * ±20% jitter, capped at 30s. There is no "give up" state — if the * machine is offline for hours, we just keep trying. Operators can * `Ctrl-C` to stop. * - Buffer outbound frames in a small queue while the socket is closed. * Capped at 100 frames; oldest is dropped on overflow. * * Events (typed via `RelayClientEvents`): * - `open` - connected and welcomed * - `welcome` - backend `{kind:"welcome"}` frame * - `frame` - any non-pong, non-welcome frame * - `close` - socket closed (we may reconnect after) * - `error` - non-fatal error (parse failure, send failure) * - `reconnect-scheduled` - emitted with the delay in ms before each retry * - `auth-failed` - WS upgrade rejected with 401/403 (caller should re-register) * * Threading model: `node:events` is single-threaded; all callbacks fire on * the same event loop turn. Listeners must be cheap. */ import { EventEmitter } from "node:events"; import WebSocket from "ws"; export interface RelayClientOptions { /** Full WS URL, e.g. wss://api.aexol.ai/agent-connection */ relayUrl: string; /** Bearer JWT for the WS handshake. */ machineJwt: string; /** * Backend HTTP(S) base URL for the pre-reconnect health check * (`GET /health`). When omitted, the health check is * skipped (tests, local-only deploys without a /health endpoint). */ backendUrl?: string; /** * Override the WebSocket constructor (tests inject a fake or a wrapper). * Default: `ws` package's `WebSocket`. */ webSocketImpl?: typeof WebSocket; /** * Override `fetch` (tests inject a stub for health checks). * Default: global `fetch`. */ fetchImpl?: typeof fetch; /** * Optional logger. Defaults to console. Tests pass a noop. */ logger?: Pick; /** Runtime metadata sent after every welcome/reconnect. */ runtimeInfo?: { version: string; pid: number; }; /** * Override `process.exit` for tests. Called when the backend evicts us * with `replaced-by-newer-registration` to avoid a reconnect ping-pong. * Default: `process.exit`. */ exit?: (code: number) => never; } /** Strongly-typed event surface. Used for ergonomic `.on(...)` callsites. */ export interface RelayFrame { /** Echoed-out shape; concrete type varies by Batch. */ kind?: string; [key: string]: unknown; } export declare class RelayClient extends EventEmitter { private readonly relayUrl; private readonly machineJwt; private readonly backendUrl; private readonly WS; private readonly fetchImpl; private readonly logger; private readonly exit; private readonly runtimeInfo; private ws; private disposed; private reconnectAttempt; /** Timestamp of the last `open` event; 0 when socket is closed. */ private openedAtMs; /** Consecutive closes where the socket was open < STABLE_OPEN_MS. */ private consecutiveRapidCloses; /** Start of the current rapid-close counting window (ms epoch). */ private tightLoopWindowStart; private reconnectTimer; private sendQueue; /** Wire currently waiting on a `ws.send` callback. Only one in flight to preserve FIFO. */ private sendInFlight; private sendTimer; /** Set true when the WS error handler sees an HTTP 401/403 upgrade rejection. */ private authFailed; /** Timestamp of last received frame / ping — drives the watchdog. */ private lastActivityMs; private watchdogTimer; private heartbeatTimer; private heartbeatPongTimer; private heartbeatPending; /** Guards the async window of `scheduleReconnect` (health check). */ private reconnectInFlight; constructor(opts: RelayClientOptions); /** * Emit a non-fatal error without being able to crash the host process. * * The `error` event is documented as non-fatal (parse failure, transport * noise, send failure), but a bare `this.emit("error", ...)` on a Node * EventEmitter THROWS when no listener is registered. Embeddings that * never subscribe to `error` (SDK consumers, `serve` running in silent * mode, ad-hoc clients in tests) would die on the first transport hiccup * instead of logging it. When a listener exists, behaviour is unchanged. */ private emitError; /** Open the connection. Idempotent — calling twice is a no-op. */ connect(): void; /** * Send a frame. If the socket is open it goes immediately; otherwise * it's queued (capped at 100 — oldest dropped) and flushed on the next * successful `open`. * * Returns `true` if the frame was sent or queued, `false` if it was * dropped due to dispose. */ send(frame: RelayFrame | string): boolean; /** * Close the connection and stop reconnecting. After dispose, this client * is dead — create a new one to reconnect. */ dispose(): void; private openSocket; private startWatchdog; private stopWatchdog; private startHeartbeat; private stopHeartbeat; private isRestResponseWire; private enqueueWire; private clearSendInFlight; /** * Drain the FIFO send queue over the current socket. Exactly one frame is * in flight at a time so ordering is preserved. If `ws.send` never calls * its callback (half-open socket), the frame stays at the front of the * queue and the connection is force-reconnected; the next `open` flushes * it again. */ private flushSendQueue; private setTcpKeepAlive; /** * Tear down a suspected-dead socket and schedule a reconnect immediately. * Unlike the `close` path, this does not wait for a (possibly never * arriving) `close` event — `terminate()` destroys the socket directly. */ private forceReconnect; /** * Single disconnect entry point for both real `close` events and forced * reconnects. Ignored for sockets that have already been replaced. */ private handleDisconnect; /** * Pre-reconnect health check: quick HTTP GET to `/health`. * Returns true when the backend is reachable (or when no backendUrl is * configured, which skips the check entirely). Returns false when the * backend is unhealthy/down — the caller should re-schedule. */ private healthCheck; private scheduleReconnect; } //# sourceMappingURL=client.d.ts.map