UNPKG

@mochabug/adapt-web

Version:

The client library to execute automations, without effort, in a browser environment

566 lines 21.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PubsubClient = exports.ConnectionState = void 0; const protobuf_1 = require("@bufbuild/protobuf"); const wkt_1 = require("@bufbuild/protobuf/wkt"); const isomorphic_ws_1 = __importDefault(require("isomorphic-ws")); const uuid_1 = require("uuid"); const automations_pb_js_1 = require("./genproto/mochabugapis/adapt/automations/v1/automations_pb.js"); const index_js_1 = require("./index.js"); // Connection states var ConnectionState; (function (ConnectionState) { ConnectionState["DISCONNECTED"] = "disconnected"; ConnectionState["CONNECTING"] = "connecting"; ConnectionState["CONNECTED"] = "connected"; ConnectionState["CLOSING"] = "closing"; })(ConnectionState || (exports.ConnectionState = ConnectionState = {})); // WebSocket close codes (following RFC 6455) var CloseCode; (function (CloseCode) { CloseCode[CloseCode["NORMAL"] = 1000] = "NORMAL"; CloseCode[CloseCode["GOING_AWAY"] = 1001] = "GOING_AWAY"; CloseCode[CloseCode["PROTOCOL_ERROR"] = 1002] = "PROTOCOL_ERROR"; CloseCode[CloseCode["UNSUPPORTED_DATA"] = 1003] = "UNSUPPORTED_DATA"; CloseCode[CloseCode["ABNORMAL"] = 1006] = "ABNORMAL"; CloseCode[CloseCode["POLICY_VIOLATION"] = 1008] = "POLICY_VIOLATION"; CloseCode[CloseCode["SERVER_RESTART"] = 1012] = "SERVER_RESTART"; CloseCode[CloseCode["TRY_AGAIN_LATER"] = 1013] = "TRY_AGAIN_LATER"; CloseCode[CloseCode["BAD_GATEWAY"] = 1014] = "BAD_GATEWAY"; CloseCode[CloseCode["TLS_HANDSHAKE"] = 1015] = "TLS_HANDSHAKE"; })(CloseCode || (CloseCode = {})); // Logger class for consistent logging class Logger { constructor(debug) { this.debug = debug; } log(level, message, data) { if (!this.debug && level === "debug") return; const timestamp = new Date().toISOString(); const prefix = `[PubSub ${timestamp}]`; const fullMessage = `${prefix} ${message}`; if (data !== undefined) { console[level](fullMessage, data); } else { console[level](fullMessage); } } } // ACK tracker to prevent duplicate message processing class AckTracker { constructor(logger) { this.logger = logger; this.ackedMessages = new Map(); this.MESSAGE_TTL = 60000; // 60 seconds this.cleanupTimer = null; } start() { // Cleanup old messages every 30 seconds this.cleanupTimer = setInterval(() => this.cleanup(), 30000); } stop() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } this.ackedMessages.clear(); } isProcessed(messageId) { return this.ackedMessages.has(messageId); } markProcessed(messageId) { this.ackedMessages.set(messageId, Date.now()); } cleanup() { const now = Date.now(); const deadline = now - this.MESSAGE_TTL; let removed = 0; for (const [messageId, timestamp] of this.ackedMessages.entries()) { if (timestamp < deadline) { this.ackedMessages.delete(messageId); removed++; } } if (removed > 0) { this.logger.log("debug", `Cleaned up ${removed} old ACKed messages`); } } } // Reconnection manager with exponential backoff class ReconnectionManager { constructor(logger) { this.logger = logger; this.attempts = 0; this.timer = null; this.FAST_INITIAL_DELAY = 50; // 50ms for first 10 attempts this.FAST_ATTEMPTS_THRESHOLD = 10; // First 10 attempts are fast this.SLOW_INITIAL_DELAY = 1000; // 1 second after fast attempts this.MAX_DELAY = 30000; // 30 seconds this.MAX_ATTEMPTS = -1; // -1 = infinite } reset() { this.attempts = 0; this.cancel(); } cancel() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } } scheduleReconnect(callback, immediate = false) { if (this.MAX_ATTEMPTS !== -1 && this.attempts >= this.MAX_ATTEMPTS) { this.logger.log("error", "Max reconnection attempts reached"); return false; } this.attempts++; // Calculate delay with fast exponential backoff for first 10 attempts let delay; if (immediate) { // Immediate reconnection for server restart delay = 0; this.logger.log("info", "Scheduling immediate reconnect (server restart)"); } else if (this.attempts <= this.FAST_ATTEMPTS_THRESHOLD) { // Fast reconnection: 50 => 100 => 200 => 400 => 800 => 1600 => 3200 => 6400 => 12800 => 25600 delay = this.FAST_INITIAL_DELAY * Math.pow(2, this.attempts - 1); } else { // After fast attempts, use slower backoff starting from SLOW_INITIAL_DELAY const slowAttempt = this.attempts - this.FAST_ATTEMPTS_THRESHOLD; delay = this.SLOW_INITIAL_DELAY * Math.pow(2, slowAttempt - 1); } // Cap at MAX_DELAY (except for immediate reconnects) if (!immediate) { delay = Math.min(delay, this.MAX_DELAY); } if (!immediate) { this.logger.log("info", `Scheduling reconnect attempt ${this.attempts} in ${delay}ms`); } this.timer = setTimeout(() => { this.timer = null; callback(); }, delay); return true; } } // WebSocket connection manager class WebSocketManager { constructor(logger) { this.logger = logger; this.ws = null; this.closePromise = null; this.closeResolve = null; } connect(url, onOpen, onMessage, onClose, onError) { if (this.ws) { throw new Error("WebSocket already exists"); } this.logger.log("info", `Connecting to: ${url}`); // Create a promise that resolves when the WebSocket closes this.closePromise = new Promise((resolve) => { this.closeResolve = resolve; }); this.ws = new isomorphic_ws_1.default(url); this.ws.binaryType = "arraybuffer"; this.ws.onopen = () => { this.logger.log("info", "✓ WebSocket connected"); onOpen(); }; this.ws.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { onMessage(event.data); } else if (event.data instanceof Blob) { // Handle Blob data (some browsers may return Blob instead of ArrayBuffer) event.data .arrayBuffer() .then(onMessage) .catch((error) => { this.logger.log("error", "Failed to convert Blob to ArrayBuffer:", error); }); } else { this.logger.log("warn", "Received non-binary message, ignoring"); } }; this.ws.onerror = (error) => { this.logger.log("error", "WebSocket error:", error); onError(error); }; this.ws.onclose = (event) => { this.logger.log("info", `WebSocket closed: ${event.code} - ${event.reason}`); onClose(event.code, event.reason); this.ws = null; // Resolve the close promise this.closeResolve(); this.closeResolve = null; this.closePromise = null; }; } sendAck(messageId) { if (!this.isConnected()) { this.logger.log("warn", `Cannot send ACK (not connected): ${messageId}`); return false; } try { // Parse UUID string to bytes (16 bytes) const uuid = new Uint8Array((0, uuid_1.parse)(messageId)); this.ws.send(uuid); this.logger.log("debug", `Sent ACK: ${messageId}`); return true; } catch (error) { this.logger.log("error", `Failed to send ACK for ${messageId}:`, error); return false; } } async close(code = CloseCode.NORMAL, reason = "Client disconnecting") { if (!this.ws) { return; } if (this.ws.readyState === isomorphic_ws_1.default.OPEN || this.ws.readyState === isomorphic_ws_1.default.CONNECTING) { this.ws.close(code, reason); } await this.closePromise; } isConnected() { return this.ws !== null && this.ws.readyState === isomorphic_ws_1.default.OPEN; } } // Message processor for handling incoming WebSocket messages class MessageProcessor { constructor(logger, handlers) { this.logger = logger; this.handlers = handlers; this.sessionEnded = false; } process(data) { try { const message = (0, protobuf_1.fromBinary)(automations_pb_js_1.WebsocketMessageSchema, new Uint8Array(data)); this.logger.log("debug", `Processing message: ${message.id}`); // Process based on message type switch (message.message.case) { case "output": this.processOutput(message.message.value); break; case "session": this.processSession(message.message.value); break; case "url": this.processUrl(message.message.value); break; default: this.logger.log("warn", `Unknown message type: ${message.message.case}`); } return { messageId: message.id, sessionEnded: this.sessionEnded }; } catch (error) { this.logger.log("error", "Failed to process message:", error); return { messageId: null, sessionEnded: this.sessionEnded }; } } hasSessionEnded() { return this.sessionEnded; } processOutput(output) { if (!this.handlers.onOutput) return; try { const result = { vertex: output.vertex, fork: output.fork, data: output.data, }; if (output.created) { result.created = (0, wkt_1.timestampDate)(output.created); } this.handlers.onOutput(result); } catch (error) { this.logger.log("error", "Error in output handler:", error); } } processSession(session) { // Check for terminal status switch (session.status) { case automations_pb_js_1.Status.STOPPED: case automations_pb_js_1.Status.COMPLETED: case automations_pb_js_1.Status.ERRORED: case automations_pb_js_1.Status.EXPIRED: this.sessionEnded = true; this.logger.log("info", `Session ended with status: ${automations_pb_js_1.Status[session.status]}`); break; } if (!this.handlers.onSession) return; try { this.handlers.onSession((0, protobuf_1.toJson)(automations_pb_js_1.SessionSchema, session)); } catch (error) { this.logger.log("error", "Error in session handler:", error); } } processUrl(url) { if (!this.handlers.onUrl) return; try { this.handlers.onUrl((0, protobuf_1.toJson)(automations_pb_js_1.UrlSchema, url)); } catch (error) { this.logger.log("error", "Error in URL handler:", error); } } } // Main PubsubClient class class PubsubClient { constructor(debug = false) { this.state = ConnectionState.DISCONNECTED; this.sessionInfo = null; this.messageProcessor = null; this.shouldReconnect = true; this.sessionComplete = false; this.logger = new Logger(debug); this.ackTracker = new AckTracker(this.logger); this.reconnectManager = new ReconnectionManager(this.logger); this.wsManager = new WebSocketManager(this.logger); } async subscribe(opts) { this.logger.log("debug", "Subscribe called", { automation: opts.id, hasToken: !!opts.sessionToken, }); // Validate input if (!opts.sessionToken || !opts.id) { throw new Error("Session token and automation are required"); } // Check if already connected to same session if (this.isSameSession(opts)) { this.logger.log("debug", "Already connected to same session, updating handlers"); this.updateHandlers(opts); return; } // Check state if (this.state === ConnectionState.CONNECTING) { throw new Error("Already connecting, please wait"); } // Disconnect if connected to different session if (this.state === ConnectionState.CONNECTED) { await this.unsubscribe(); } // Store configuration this.sessionInfo = { token: opts.sessionToken, id: opts.id, }; // Create message processor with handlers const handlers = {}; if (opts.onOutput) handlers.onOutput = opts.onOutput; if (opts.onSession) handlers.onSession = opts.onSession; if (opts.onUrl) handlers.onUrl = opts.onUrl; this.messageProcessor = new MessageProcessor(this.logger, handlers); // Reset state this.shouldReconnect = true; this.sessionComplete = false; this.reconnectManager.reset(); // Start ACK tracker this.ackTracker.start(); // Connect await this.connect(); } async unsubscribe() { if (this.state === ConnectionState.DISCONNECTED || this.state === ConnectionState.CLOSING) { return; } this.logger.log("info", "Unsubscribing..."); // Update state this.state = ConnectionState.CLOSING; this.shouldReconnect = false; // Cancel any pending reconnects this.reconnectManager.cancel(); // Close WebSocket and wait for it to fully close await this.wsManager.close(); // Stop ACK tracker this.ackTracker.stop(); // Clear state this.sessionInfo = null; this.messageProcessor = null; this.state = ConnectionState.DISCONNECTED; this.logger.log("info", "Unsubscribed successfully"); } isConnected() { return this.state === ConnectionState.CONNECTED; } getConnectionState() { return this.state; } getSessionInfo() { if (!this.sessionInfo) { throw new Error("No active session"); } return this.sessionInfo; } async connect() { if (!this.sessionInfo) { throw new Error("No session configuration"); } this.state = ConnectionState.CONNECTING; const config = (0, index_js_1.getConfig)(); const wsUrl = `${config.wsBaseUrl}/${this.sessionInfo.id}/ws?token=${encodeURIComponent(this.sessionInfo.token)}`; try { this.wsManager.connect(wsUrl, () => this.handleOpen(), (data) => this.handleMessage(data), (code, reason) => this.handleClose(code, reason), (error) => this.handleError(error)); } catch (error) { this.state = ConnectionState.DISCONNECTED; throw error; } } handleOpen() { this.state = ConnectionState.CONNECTED; this.reconnectManager.reset(); } handleMessage(data) { if (!this.messageProcessor) { this.logger.log("error", "No message processor configured"); return; } // Process the message const result = this.messageProcessor.process(data); if (!result.messageId) { return; // Failed to parse } // Update session complete flag if session ended if (result.sessionEnded && !this.sessionComplete) { this.logger.log("info", "Session has ended (detected from message)"); this.sessionComplete = true; } // Check for duplicate if (this.ackTracker.isProcessed(result.messageId)) { this.logger.log("debug", `Duplicate message: ${result.messageId}`); } else { this.ackTracker.markProcessed(result.messageId); } // ACK the message (skip for session-ending messages as connection will close) if (!result.sessionEnded) { this.wsManager.sendAck(result.messageId); } } handleClose(code, reason) { this.state = ConnectionState.DISCONNECTED; // Analyze close code const reconnectStrategy = this.analyzeCloseCode(code, reason); // Schedule reconnect if appropriate if (reconnectStrategy.shouldReconnect && this.shouldReconnect && !this.sessionComplete) { this.scheduleReconnect(reconnectStrategy.immediate); } else { // Clean shutdown (fire and forget - cleanup is async but we don't need to wait) this.cleanup().catch((error) => { this.logger.log("error", "Error during cleanup:", error); }); } } handleError(error) { this.logger.log("error", "WebSocket error:", error); } analyzeCloseCode(code, reason) { switch (code) { case CloseCode.NORMAL: // Normal closure - session complete or client requested this.logger.log("info", "Normal closure"); this.sessionComplete = true; return { shouldReconnect: false, immediate: false }; case CloseCode.GOING_AWAY: // Server shutting down this.logger.log("warn", "Server going away"); return { shouldReconnect: true, immediate: false }; case CloseCode.SERVER_RESTART: // Server restart - reconnect immediately (no backoff) this.logger.log("warn", "Server restarting - will reconnect immediately"); return { shouldReconnect: true, immediate: true }; case CloseCode.TRY_AGAIN_LATER: // Service temporarily unavailable - reconnect with backoff this.logger.log("warn", "Service temporarily unavailable, will retry"); return { shouldReconnect: true, immediate: false }; case CloseCode.PROTOCOL_ERROR: case CloseCode.UNSUPPORTED_DATA: case CloseCode.POLICY_VIOLATION: // Critical errors - don't reconnect this.logger.log("error", `Critical error (${code}): ${reason}`); return { shouldReconnect: false, immediate: false }; case CloseCode.ABNORMAL: // Connection lost - try to reconnect this.logger.log("warn", "Connection lost (abnormal closure)"); return { shouldReconnect: true, immediate: false }; default: // Unknown code - try to reconnect this.logger.log("warn", `Unknown close code ${code}: ${reason}`); return { shouldReconnect: true, immediate: false }; } } scheduleReconnect(immediate = false) { const scheduled = this.reconnectManager.scheduleReconnect(() => { // Double-check state to avoid race conditions if (this.shouldReconnect && this.state === ConnectionState.DISCONNECTED && !this.sessionComplete) { this.connect().catch((error) => { this.logger.log("error", "Reconnection failed:", error); // Try again with normal backoff (not immediate) this.scheduleReconnect(false); }); } }, immediate); if (!scheduled) { // Max attempts reached (fire and forget cleanup) this.cleanup().catch((error) => { this.logger.log("error", "Error during cleanup:", error); }); } } async cleanup() { this.shouldReconnect = false; this.reconnectManager.cancel(); this.ackTracker.stop(); await this.wsManager.close(); this.sessionInfo = null; this.messageProcessor = null; this.state = ConnectionState.DISCONNECTED; } isSameSession(opts) { if (!this.sessionInfo || this.state !== ConnectionState.CONNECTED) { return false; } return (this.sessionInfo.token === opts.sessionToken && this.sessionInfo.id === opts.id); } updateHandlers(opts) { if (!this.messageProcessor) { return; } // Update handlers in message processor const handlers = {}; if (opts.onOutput) handlers.onOutput = opts.onOutput; if (opts.onSession) handlers.onSession = opts.onSession; if (opts.onUrl) handlers.onUrl = opts.onUrl; this.messageProcessor = new MessageProcessor(this.logger, handlers); } } exports.PubsubClient = PubsubClient; //# sourceMappingURL=pubsub-client.js.map