UNPKG

convex

Version:

Client for the Convex Cloud

244 lines (243 loc) 7 kB
"use strict"; import { parseServerMessage } from "./protocol.js"; const CLOSE_NORMAL = 1e3; const CLOSE_NO_STATUS = 1005; function promisePair() { let resolvePromise; const promise = new Promise((resolve) => { resolvePromise = resolve; }); return { promise, resolve: resolvePromise }; } export class WebSocketManager { constructor(uri, onOpen, onMessage, webSocketConstructor) { 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; void this.connect(); } async connect() { if (this.socket.state === "closing" || this.socket.state === "stopping" || this.socket.state === "stopped") { return; } if (this.socket.state !== "disconnected" && this.socket.state !== "paused") { 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 }); 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}`); this.closeAndReconnect("WebSocketError"); }; ws.onmessage = (message) => { 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; } if (this.socket.state === "pausing") { this.socket.promisePair.resolve(null); this.socket = { state: "paused" }; return; } this.socket = { state: "disconnected" }; const backoff = this.nextBackoff(); console.log(`Attempting reconnect in ${backoff}ms`); setTimeout(() => this.connect(), backoff); }; } socketState() { return this.socket.state; } sendMessage(message) { if (this.socket.state === "ready") { const request = JSON.stringify(message); try { this.socket.ws.send(request); } catch (error) { console.log( `Failed to send message on WebSocket, reconnecting: ${error}` ); this.closeAndReconnect("FailedToSendMessage"); } } } onServerActivity() { if (this.reconnectDueToServerInactivityTimeout !== null) { clearTimeout(this.reconnectDueToServerInactivityTimeout); this.reconnectDueToServerInactivityTimeout = null; } this.reconnectDueToServerInactivityTimeout = setTimeout(() => { this.closeAndReconnect("InactiveServer"); }, this.serverInactivityThreshold); } closeAndReconnect(closeReason) { switch (this.socket.state) { case "disconnected": case "closing": case "stopping": case "stopped": case "pausing": case "paused": return; case "connecting": case "ready": this.lastCloseReason = closeReason; this.socket.ws.close(); this.socket = { state: "closing" }; return; default: { const _ = this.socket; } } } async stop() { if (this.reconnectDueToServerInactivityTimeout) { clearTimeout(this.reconnectDueToServerInactivityTimeout); } 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 "pausing": case "closing": this.socket = { state: "stopping", promisePair: promisePair() }; await this.socket.promisePair.promise; return; case "paused": case "disconnected": this.socket = { state: "stopped" }; return; case "stopping": await this.socket.promisePair.promise; return; default: { const _ = this.socket; } } } async pause() { switch (this.socket.state) { case "stopping": case "stopped": return; case "paused": return; case "connecting": case "ready": this.socket.ws.close(); this.socket = { state: "pausing", promisePair: promisePair() }; await this.socket.promisePair.promise; return; case "closing": this.socket = { state: "pausing", promisePair: promisePair() }; await this.socket.promisePair.promise; return; case "disconnected": this.socket = { state: "paused" }; return; case "pausing": await this.socket.promisePair.promise; return; default: { const _ = this.socket; } } } async resume() { switch (this.socket.state) { case "pausing": case "paused": break; case "stopping": case "stopped": return; case "connecting": case "ready": case "closing": case "disconnected": throw new Error("`resume()` is only valid after `pause()`"); default: { const _ = this.socket; } } if (this.socket.state === "pausing") { await this.socket.promisePair.promise; } await this.connect(); } 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