UNPKG

convex

Version:

Client for the Convex Cloud

302 lines (274 loc) 10.5 kB
import { ClientMessage, parseServerMessage, ServerMessage, } from "./protocol.js"; const CLOSE_NORMAL = 1000; const CLOSE_NO_STATUS = 1005; type PromisePair<T> = { promise: Promise<T>; resolve: (value: T) => void }; /** * The various states our WebSocket can be in: * * - "disconnected": We don't have a WebSocket, but plan to create one. * - "connecting": We have created the WebSocket and are waiting for the * `onOpen` callback. * - "ready": We have an open WebSocket. * - "closing": We called `.close()` on the WebSocket and are waiting for the * `onClose` callback before we schedule a reconnect. * - "stopping": The application decided to totally stop the WebSocket. We are * waiting for the `onClose` callback before we consider this WebSocket stopped. * - "stopped": We have stopped the WebSocket and will never create a new one. * * WebSocket State Machine ┌─────────────┐ ┌─────────────┐ ┌────onclose─────▶│disconnected │─stop()─▶│ stopped │ │ └─────────────┘ └─────────────┘ │ │ ▲ │ new WebSocket() │ │ │ onclose │ ▼ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ closing │◀─close()─│ connecting │─stop()─▶│ stopping │ └─────────────┘ └─────────────┘ └─────────────┘ │ ▲ │ ▲ ▲ │ │ onopen │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ ┌─────────────┐ │ │ │ └─────────close()─│ ready │─stop()─────────┘ │ │ └─────────────┘ │ └─────────────────────────────────────stop()────────────┘ */ type Socket = | { state: "disconnected" } | { state: "connecting"; ws: WebSocket } | { state: "ready"; ws: WebSocket } | { state: "closing" } | { state: "stopping"; promisePair: PromisePair<null> } | { state: "stopped" }; function promisePair<T>(): PromisePair<T> { let resolvePromise: (value: T) => void; const promise = new Promise<T>(resolve => { resolvePromise = resolve; }); return { promise, resolve: resolvePromise! }; } export type ReconnectMetadata = { connectionCount: number; lastCloseReason: string | null; }; /** * A wrapper around a websocket that handles errors, reconnection, and message * parsing. */ export class WebSocketManager { private socket: Socket; private connectionCount: number; private lastCloseReason: string | null; /** Upon HTTPS/WSS failure, the first jittered backoff duration, in ms. */ private readonly initialBackoff: number; /** We backoff exponentially, but we need to cap that--this is the jittered max. */ private readonly maxBackoff: number; /** How many times have we failed consecutively? */ private retries: number; /** How long before lack of server response causes us to initiate a reconnect, * in ms */ private readonly serverInactivityThreshold: number; private reconnectDueToServerInactivityTimeout: ReturnType< typeof setTimeout > | null; private readonly uri: string; private readonly onOpen: (reconnectMetadata: ReconnectMetadata) => void; private readonly onMessage: (message: ServerMessage) => void; private readonly webSocketConstructor: typeof WebSocket; constructor( uri: string, onOpen: (reconnectMetadata: ReconnectMetadata) => void, onMessage: (message: ServerMessage) => void, webSocketConstructor: typeof WebSocket ) { this.webSocketConstructor = webSocketConstructor; this.socket = { state: "disconnected" }; this.connectionCount = 0; this.lastCloseReason = "InitialConnect"; this.initialBackoff = 100; this.maxBackoff = 16000; this.retries = 0; this.serverInactivityThreshold = 30000; this.reconnectDueToServerInactivityTimeout = null; this.uri = uri; this.onOpen = onOpen; this.onMessage = onMessage; // Kick off connection but don't wait for it. void this.connect(); } private async connect() { if ( this.socket.state === "closing" || this.socket.state === "stopping" || this.socket.state === "stopped" ) { return; } if (this.socket.state !== "disconnected") { throw new Error("Didn't start connection from disconnected state"); } const ws = new this.webSocketConstructor(this.uri); this.socket = { state: "connecting", ws, }; ws.onopen = () => { if (this.socket.state !== "connecting") { throw new Error("onopen called with socket not in connecting state"); } this.socket = { state: "ready", ws }; this.onServerActivity(); this.onOpen({ connectionCount: this.connectionCount, lastCloseReason: this.lastCloseReason, }); this.connectionCount += 1; this.lastCloseReason = null; }; // NB: The WebSocket API calls `onclose` even if connection fails, so we can route all error paths through `onclose`. ws.onerror = error => { const message = (<ErrorEvent>error).message; console.log(`WebSocket error: ${message}`); this.closeAndReconnect("WebSocketError"); }; ws.onmessage = message => { // TODO(CX-1498): We reset the retry counter on any successful message. // This is not ideal and we should improve this further. this.retries = 0; this.onServerActivity(); const serverMessage = parseServerMessage(JSON.parse(message.data)); this.onMessage(serverMessage); }; ws.onclose = event => { if (this.lastCloseReason === null) { this.lastCloseReason = event.reason ?? "OnCloseInvoked"; } if (event.code !== CLOSE_NORMAL && event.code !== CLOSE_NO_STATUS) { let msg = `WebSocket closed unexpectedly with code ${event.code}`; if (event.reason) { msg += `: ${event.reason}`; } console.error(msg); } if (this.socket.state === "stopping") { this.socket.promisePair.resolve(null); this.socket = { state: "stopped" }; return; } this.socket = { state: "disconnected" }; const backoff = this.nextBackoff(); console.log(`Attempting reconnect in ${backoff}ms`); setTimeout(() => this.connect(), backoff); }; } /** * @returns The state of the {@link Socket}. */ socketState(): string { return this.socket.state; } sendMessage(message: ClientMessage) { if (this.socket.state === "ready") { const request = JSON.stringify(message); try { this.socket.ws.send(request); } catch (error: any) { console.log( `Failed to send message on WebSocket, reconnecting: ${error}` ); this.closeAndReconnect("FailedToSendMessage"); } } } private onServerActivity() { if (this.reconnectDueToServerInactivityTimeout !== null) { clearTimeout(this.reconnectDueToServerInactivityTimeout); this.reconnectDueToServerInactivityTimeout = null; } this.reconnectDueToServerInactivityTimeout = setTimeout(() => { this.closeAndReconnect("InactiveServer"); }, this.serverInactivityThreshold); } /** * Close the WebSocket and schedule a reconnect when it completes closing. * * This should be used when we hit an error and would like to restart the session. */ private closeAndReconnect(closeReason: string) { switch (this.socket.state) { case "disconnected": case "closing": case "stopping": case "stopped": // Nothing to do if we don't have a WebSocket. return; case "connecting": case "ready": this.lastCloseReason = closeReason; this.socket.ws.close(); this.socket = { state: "closing", }; return; default: { // Enforce that the switch-case is exhaustive. // eslint-disable-next-line @typescript-eslint/no-unused-vars const _: never = this.socket; } } } /** * Close the WebSocket and never reconnect. * @returns A Promise that resolves when the WebSocket `onClose` callback is called. */ async stop(): Promise<void> { switch (this.socket.state) { case "stopped": return; case "connecting": case "ready": this.socket.ws.close(); this.socket = { state: "stopping", promisePair: promisePair(), }; await this.socket.promisePair.promise; return; case "closing": // We're already closing the WebSocket, so just upgrade the state // to "stopping" so we don't reconnect. this.socket = { state: "stopping", promisePair: promisePair(), }; await this.socket.promisePair.promise; return; case "disconnected": // We're disconnected so switch the state to "stopped" so the reconnect // timeout doesn't create a new WebSocket. this.socket = { state: "stopped" }; return; case "stopping": await this.socket.promisePair.promise; return; default: { // Enforce that the switch-case is exhaustive. const _: never = this.socket; } } } private nextBackoff(): number { const baseBackoff = this.initialBackoff * Math.pow(2, this.retries); this.retries += 1; const actualBackoff = Math.min(baseBackoff, this.maxBackoff); const jitter = actualBackoff * (Math.random() - 0.5); return actualBackoff + jitter; } }