import { type WSClientError } from "./errors"; import { type ServerMessageEnvelope } from "./frame-handler"; export interface WSWelcome { readonly type: "welcome"; readonly data: { readonly server_protocol_version: string; readonly agent_id: string; readonly agent_name: string; readonly server_time: string; readonly games: readonly string[]; }; readonly match_id?: string; } /** * Discriminated union of valid outbound messages the client may * `send()` to the server. Mirrors the four `client_*.schema.json` * files at the TypeScript layer (rev 3 P2 #4 — TS first line of * defence; ajv via `serializeClientMessage` is the authoritative * runtime check). * * Required-vs-optional contracts here track schema `required` arrays: * - join_queue: `data` required (game name lives here) * - leave_queue: `data` optional (no fields needed) * - match_confirm: `data` required (carries confirm_id) * - action: `match_id` required (server uses it for the * per-player session_id) AND `data` required * (the chosen action object) * * `match_id` on the three non-`action` types is envelope-level * scaffolding — the schemas accept it but no current message uses it. * Keep optional so callers don't have to thread an empty string. * * The `unknown` typing on `data` is deliberate: per-message strict * payload typing is M1-22 codegen territory. ajv enforces the inner * shape at runtime; surfacing it through TS now would duplicate * conformance work. */ export type WSClientMessage = { type: "join_queue"; data: unknown; match_id?: string; } | { type: "leave_queue"; data?: unknown; match_id?: string; } | { type: "match_confirm"; data: unknown; match_id?: string; } | { type: "action"; match_id: string; data: unknown; /** REQUIRED echo of action_request.data.request_id (protocol v1.2, * F07/R3-01; enforced 2026-07-16). Pins the submission to the decision * it answers; the server refuses an id-less action unjudged * (error + action_stale, no penalty). */ request_id: string; /** Optional model usage metadata (protocol v1.1) — token counts * only, never content. See client_action.schema.json `usage`. */ usage?: { model: string; input_tokens?: number; output_tokens?: number; reasoning_tokens?: number; cached_tokens?: number; }; /** Optional decision provenance (protocol v1.2, F09/AIF-03): model / * model_retry / fallback. See client_action.schema.json `decision`. */ decision?: { source: "model" | "model_retry" | "fallback"; illegal_retries?: number; fallback_reason?: string; }; } | { type: "runtime_status"; data: unknown; match_id?: string; }; export interface WSClientOptions { /** WebSocket URL — e.g. "wss://aifight.ai/api/ws" or * "ws://127.0.0.1:/api/ws" in tests. */ url: string; /** In-memory plaintext API key. Caller resolves from credentials.ts * (M1-05) before this call; transport never touches keychain. */ apiKey: string; /** Per-device id (sha256 of the device secret and this machine's own id), * sent as the X-Device-Id header so the server can enforce single-device * binding (anti-theft). Optional: when absent the header is omitted, which a * lenient server treats as a client that cannot identify its machine. */ deviceId?: string; /** Which program is running this agent: "desktop" | "cli". The server binds an * agent to ONE client on its device — the device id cannot distinguish them, * because the app and the CLI share ~/.aifight/device.key — and 403s any * other kind. Omitted when unknown, which the server treats as "binds * nothing" so an older client keeps working. */ clientKind?: string; /** Capability tokens sent as the X-AIFight-Capabilities header (comma * separated). Each token opts this connection INTO a server push behaviour * the server otherwise withholds — e.g. CLIENT_CAPABILITY_MATCH_FEED gates * the match_feed event stream. Omitted/empty means the header is not sent * at all, so a client that says nothing keeps the old wire behaviour. */ capabilities?: readonly string[]; /** This build's own version (RUNTIME_VERSION), sent as * X-AIFight-Bridge-Version. The server has read this header and persisted it * into agent_runtime_presence since day one — but no client ever sent it, so * the platform could never say which build an online agent runs (readiness * audit 2026-08-06). Purely operational telemetry: nothing gates on it, and * omitting it keeps the old wire behaviour (column stays empty). */ bridgeVersion?: string; /** Process connection-instance id sent as X-AIFight-Instance. Defaults to * the module-wide PROCESS_INSTANCE_ID — override only in tests that need * to simulate two distinct processes from one test runner. */ instanceId?: string; /** Runtime's compiled-in protocol version, SemVer (e.g. "1.0.0" * or "v1.0.0"). The optional "v" prefix is stripped before * comparison. Major component must match server's * server_protocol_version per plan §5.8 / ADR-016. */ expectedProtocolVersion: string; /** Time after WS open() to receive the welcome frame. Default * 10_000 ms. Tests can lower for fast-fail coverage. */ welcomeTimeoutMs?: number; /** Client-initiated WS ping frame (opcode 0x9) interval in ms. * Default 25_000 (per plan §5.8 / ADR-015 — keeps server's * 60s ReadDeadline safely fed). Set to 0 to DISABLE the * client-initiated ping entirely (the server's own ping + * the `ws` library's automatic pong reply still keep the * link alive in that case, but the structural Batch D fix * — independent client ping timer — is not active). */ pingIntervalMs?: number; /** Optional caller-controlled AbortSignal. If pre-aborted * (signal.aborted=true at the moment createWSClient is called), * rejects synchronously with WSAbortedError. If aborted * mid-handshake, terminates the socket and rejects with * WSAbortedError. If aborted AFTER createWSClient resolves, * the WSClient transitions to "closed" (timers cleared, * socket terminated) — equivalent to calling close(); no * error is thrown since the signal owner is the actor. */ signal?: AbortSignal; /** Maximum inbound WS frame size in bytes (R13-F03). `ws` closes the * connection with code 1009 ("message too big") when the server sends a * larger frame, which surfaces here as a normal close → reconnect. Defaults * to DEFAULT_MAX_PAYLOAD_BYTES; the `ws` library default is 100 MiB, far * above any legitimate AIFight frame, so an unbounded default would let a * buggy/hostile server pin ~100 MiB of client memory per frame. */ maxPayloadBytes?: number; /** Hard ceiling on the pre-open phase — DNS + TCP + TLS + HTTP upgrade * (`ws` handshakeTimeout). Without it a half-open connect (e.g. the edge * accepting TCP while the upstream is mid-restart) hangs `createWSClient` * FOREVER: the welcome timer only arms at WS "open", so the reconnect loop * sits in "connecting" with no further attempts — the exact 2026-07-24 * field failure (app stuck 2600+s after a server deploy, attempt counter * frozen). On timeout `ws` emits an error → WSConnectError → retriable → * the backoff loop continues. Defaults to DEFAULT_HANDSHAKE_TIMEOUT_MS. */ handshakeTimeoutMs?: number; /** Inbound-liveness watchdog: if NOTHING arrives from the server (no * message, ping or pong frame) for this long while connected, the link is * presumed half-open (proxy/NAT black hole, server hard-killed mid-deploy — * the 2026-07-24 zombie-tunnel field failure: a reconnect "succeeded" into * a local proxy whose upstream was dead, and with no inbound check the * bridge sat on the corpse forever) and the socket is terminated locally. * The resulting 1006 close drives the normal reconnect path. Both sides * heartbeat (client ping default 25s, server ping ~25s), so a healthy link * sees inbound traffic at least every ~25s. 0 disables the watchdog. * Defaults to DEFAULT_LIVENESS_TIMEOUT_MS. */ livenessTimeoutMs?: number; } export type WSMessageHandler = (msg: ServerMessageEnvelope) => void | Promise; /** Frame-level errors surfaced AFTER createWSClient resolves — * malformed inbound JSON, schema violations, unknown server * message types. Connect / handshake / welcome errors are * rejected by createWSClient itself and never reach onError. */ export type WSErrorHandler = (err: WSClientError) => void | Promise; /** Information passed to onClose handlers. The connection is * fully torn down by the time this fires; no further onMessage * / onError handlers will run. */ export interface WSCloseInfo { /** WS close code from the close frame, or 0 for synthetic * closes (abort, transport error before close frame). */ readonly code: number; /** Human-readable reason. For client-initiated close, the * string passed to close(); for server-initiated, what the * server sent; for abort, the constant "aborted". */ readonly reason: string; /** Who initiated the close: client (close() call), server * (server-side close), or abort (AbortSignal fired * post-connect). */ readonly initiator: "client" | "server" | "abort"; } export type WSCloseHandler = (info: WSCloseInfo) => void | Promise; /** @internal — passed from createWSClient to WSClientImpl constructor. */ interface WSClientInternalOpts { pingIntervalMs?: number; livenessTimeoutMs?: number; signal?: AbortSignal; } declare class WSClientImpl { #private; /** The authenticated welcome frame received from the server. */ readonly welcome: WSWelcome; /** * Constructor is REACHABLE only from this module's createWSClient * factory. Because WSClientImpl is not exported, consumers of * @aifight/aifight cannot `new` it (they only see the * `export type WSClient = WSClientImpl` alias, which carries * no constructor signature). The `socket` parameter is typed as * `unknown` belt-and-suspenders so that even if an internal * caller someday passes the wrong shape it fails as a cast * mistake rather than a structural type leak. * * Step 5a additions: * - Starts a client-initiated ping timer (default 25s; pass * pingIntervalMs=0 to disable). * - Wires opts.signal so a post-connect abort triggers a forced * close-equivalent transition (state→closed, timer cleared, * socket terminated) without throwing — the signal owner is * the actor and already knows. * * Step 5b1 additions: * - Re-attaches "message", "error", "close" listeners on the * socket (createWSClient's cleanup() removed all listeners * during the handshake settle; this is the post-handshake * re-attachment Roy flagged in the Step 5b1 brief). * - "message" routes through parseServerFrame and dispatches * to onMessage / onError handlers. * - "close" emits onClose (idempotent via #closeDispatched). * - "error" goes to a silent sink; transport errors usually * pair with "close" which carries the user-facing signal. * Frame-level errors (parse / schema / unknown) come through * the message path, not the error event. */ constructor(socket: unknown, welcome: WSWelcome, opts: WSClientInternalOpts); /** Current lifecycle state. Step 5b1 expanded to three values: * "connected" after createWSClient resolves, "closing" while * close() is awaiting the socket close handshake (briefly), * "closed" after close completes (or abort / server close * fires). M1-07 may add "reconnecting". */ get state(): "connected" | "closing" | "closed"; onMessage(handler: WSMessageHandler): () => void; onError(handler: WSErrorHandler): () => void; onClose(handler: WSCloseHandler): () => void; /** * Send an outbound message to the server. Synchronous. * * @throws {WSClosedError} when the client is not in the "connected" * state (i.e. close()/abort/server-close has fired). * @throws {WSOutboundSchemaError} when `msg` fails ajv validation * against client_.schema.json. The error carries the * offending message type and the raw ajv error array. */ send(msg: WSClientMessage): void; /** * Initiate a clean WS close. State transitions * connected → closing → closed; onClose fires exactly once when * the socket's "close" event arrives. * * Idempotent semantics: * - Calling on a "closing" instance returns the in-flight * close promise (so two awaiters share the same await). * - Calling on a "closed" instance returns immediately. * - Subsequent call after either resolves does nothing. */ close(code?: number, reason?: string): Promise; } /** The public WSClient type. This is a type alias over the internal * WSClientImpl class — consumers can use `WSClient` as a TypeScript * type (parameter / return / variable annotation) but cannot * construct one directly because no class value with this name is * exported. The only way to obtain a WSClient is via the * createWSClient() factory. */ export type WSClient = WSClientImpl; /** * Open a WebSocket to `opts.url`, send `X-API-Key` in the upgrade * request, wait for the server's welcome frame, validate it * (ajv + protocol-version major), and return a WSClient bound to * the open socket. * * Resolves on welcome accepted. Rejects with one of: * - WSConnectError — TCP/TLS/DNS failure (no HTTP response) * - WSHandshakeError — HTTP upgrade returned non-101 (4xx/5xx) * - WSWelcomeTimeoutError — open succeeded but no frame within * welcomeTimeoutMs (default 10s) * - WSWelcomeInvalidError — first frame is not a valid welcome: * malformed JSON, unknown type, * wrong type, or schema-invalid welcome * (ajv errors carried) * - WSProtocolVersionError — welcome valid but * server_protocol_version major * differs from expectedProtocolVersion * * Step 4 caveats: * - No AbortSignal (Step 5). * - No reconnect (M1-07). * - No retry on any error class — caller decides. */ export declare function createWSClient(opts: WSClientOptions): Promise; export {};