UNPKG

convex

Version:

Client for the Convex Cloud

280 lines (279 loc) 8.32 kB
"use strict"; import { encodeClientMessage, parseServerMessage } from "./protocol.js"; const CLOSE_NORMAL = 1e3; const CLOSE_GOING_AWAY = 1001; const CLOSE_NO_STATUS = 1005; const CLOSE_NOT_FOUND = 4040; export class WebSocketManager { constructor(uri, onOpen, onMessage, webSocketConstructor, verbose) { this.webSocketConstructor = webSocketConstructor; this.socket = { state: "disconnected" }; this.connectionCount = 0; this.lastCloseReason = "InitialConnect"; this.initialBackoff = 100; this.maxBackoff = 16e3; this.retries = 0; this.serverInactivityThreshold = 3e4; this.reconnectDueToServerInactivityTimeout = null; this.uri = uri; this.onOpen = onOpen; this.onMessage = onMessage; this.verbose = verbose; void this.connect(); } connect() { if (this.socket.state === "stopped") { return; } if (this.socket.state !== "disconnected" && this.socket.state !== "paused") { throw new Error( "Didn't start connection from disconnected state: " + this.socket.state ); } const ws = new this.webSocketConstructor(this.uri); this._logVerbose("constructed WebSocket"); this.socket = { state: "connecting", ws }; this.resetServerInactivityTimeout(); ws.onopen = () => { this._logVerbose("begin ws.onopen"); if (this.socket.state !== "connecting") { throw new Error("onopen called with socket not in connecting state"); } this.socket = { state: "ready", ws }; this.resetServerInactivityTimeout(); this.onOpen({ connectionCount: this.connectionCount, lastCloseReason: this.lastCloseReason }); if (this.lastCloseReason !== "InitialConnect") { console.log("WebSocket reconnected"); } this.connectionCount += 1; this.lastCloseReason = null; }; ws.onerror = (error) => { const message = error.message; console.log(`WebSocket error: ${message}`); }; ws.onmessage = (message) => { this.resetServerInactivityTimeout(); const serverMessage = parseServerMessage(JSON.parse(message.data)); this._logVerbose(`received ws message with type ${serverMessage.type}`); const response = this.onMessage(serverMessage); if (response.hasSyncedPastLastReconnect) { this.retries = 0; } }; ws.onclose = (event) => { this._logVerbose("begin ws.onclose"); if (this.lastCloseReason === null) { this.lastCloseReason = event.reason ?? "OnCloseInvoked"; } if (event.code !== CLOSE_NORMAL && event.code !== CLOSE_GOING_AWAY && // This commonly gets fired on mobile apps when the app is backgrounded event.code !== CLOSE_NO_STATUS && event.code !== CLOSE_NOT_FOUND) { let msg = `WebSocket closed with code ${event.code}`; if (event.reason) { msg += `: ${event.reason}`; } console.log(msg); } this.scheduleReconnect(); return; }; } /** * @returns The state of the {@link Socket}. */ socketState() { return this.socket.state; } /** * @param message - A ClientMessage to send. * @returns Whether the message (might have been) sent. */ sendMessage(message) { this._logVerbose(`sending message with type ${message.type}`); if (this.socket.state === "ready") { const encodedMessage = encodeClientMessage(message); const request = JSON.stringify(encodedMessage); try { this.socket.ws.send(request); } catch (error) { console.log( `Failed to send message on WebSocket, reconnecting: ${error}` ); this.closeAndReconnect("FailedToSendMessage"); } return true; } return false; } resetServerInactivityTimeout() { if (this.socket.state === "stopped") { return; } if (this.reconnectDueToServerInactivityTimeout !== null) { clearTimeout(this.reconnectDueToServerInactivityTimeout); this.reconnectDueToServerInactivityTimeout = null; } this.reconnectDueToServerInactivityTimeout = setTimeout(() => { this.closeAndReconnect("InactiveServer"); }, this.serverInactivityThreshold); } scheduleReconnect() { this.socket = { state: "disconnected" }; const backoff = this.nextBackoff(); console.log(`Attempting reconnect in ${backoff}ms`); setTimeout(() => this.connect(), backoff); } /** * Close the WebSocket and schedule a reconnect. * * This should be used when we hit an error and would like to restart the session. */ closeAndReconnect(closeReason) { this._logVerbose(`begin closeAndReconnect with reason ${closeReason}`); switch (this.socket.state) { case "disconnected": case "stopped": case "paused": return; case "connecting": case "ready": { this.lastCloseReason = closeReason; void this.close(); this.scheduleReconnect(); return; } default: { const _ = this.socket; } } } /** * Close the WebSocket, being careful to clear the onclose handler to avoid re-entrant * calls. Use this instead of directly calling `ws.close()` * * It is the callers responsibility to update the state after this method is called so that the * closed socket is not accessible or used again after this method is called */ close() { switch (this.socket.state) { case "disconnected": case "stopped": case "paused": return Promise.resolve(); case "connecting": { const ws = this.socket.ws; return new Promise((r) => { ws.onclose = () => { this._logVerbose("Closed after connecting"); r(); }; ws.onopen = () => { this._logVerbose("Opened after connecting"); ws.close(); }; }); } case "ready": { this._logVerbose("ws.close called"); const ws = this.socket.ws; const result = new Promise((r) => { ws.onclose = () => { r(); }; }); ws.close(); return result; } default: { const _ = this.socket; return Promise.resolve(); } } } /** * Close the WebSocket and do not reconnect. * @returns A Promise that resolves when the WebSocket `onClose` callback is called. */ stop() { if (this.reconnectDueToServerInactivityTimeout) { clearTimeout(this.reconnectDueToServerInactivityTimeout); } switch (this.socket.state) { case "stopped": case "paused": case "disconnected": case "connecting": case "ready": { const result = this.close(); this.socket = { state: "stopped" }; return result; } default: { const _ = this.socket; throw new Error( `Invalid websocket state: ${this.socket.state}` ); } } } pause() { switch (this.socket.state) { case "stopped": return Promise.resolve(); case "connecting": case "paused": case "disconnected": case "ready": { const result = this.close(); this.socket = { state: "paused" }; return result; } default: { const _ = this.socket; return Promise.resolve(); } } } /** * Create a new WebSocket after a previous `pause()`, unless `stop()` was * called before. */ resume() { switch (this.socket.state) { case "paused": break; case "stopped": return; case "connecting": case "ready": case "disconnected": throw new Error("`resume()` is only valid after `pause()`"); default: { const _ = this.socket; } } this.connect(); } _logVerbose(message) { if (this.verbose) { console.debug(`${(/* @__PURE__ */ new Date()).toISOString()} ${message}`); } } nextBackoff() { 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; } } //# sourceMappingURL=web_socket_manager.js.map