UNPKG

mcast-client

Version:

WebSocket client for Mcast real-time messaging

695 lines (690 loc) 22.1 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { ConnectionState: () => ConnectionState, McastClient: () => McastClient, TOPIC_ALL: () => TOPIC_ALL, loadFromEnv: () => loadFromEnv }); module.exports = __toCommonJS(index_exports); // src/types.ts var ConnectionState = /* @__PURE__ */ ((ConnectionState2) => { ConnectionState2["DISCONNECTING"] = "disconnecting"; ConnectionState2["DISCONNECTED"] = "disconnected"; ConnectionState2["CONNECTING"] = "connecting"; ConnectionState2["CONNECTED"] = "connected"; ConnectionState2["RECONNECTING"] = "reconnecting"; ConnectionState2["ERROR"] = "error"; return ConnectionState2; })(ConnectionState || {}); var TOPIC_ALL = "_ALL_"; // src/client.ts var ws = __toESM(require("ws"), 1); var WebSocketImpl = typeof WebSocket !== "undefined" ? WebSocket : ws.WebSocket; var McastClient = class { /** * Creates a new instance of the McastClient * * @param options Configuration options for the client */ constructor(options) { this.baseUrl = "https://chanban-112482603531.us-east4.run.app"; // WebSocket connections this.pubSocket = null; this.subSocket = null; // Connection state tracking this.pubState = "disconnected" /* DISCONNECTED */; this.subState = "disconnected" /* DISCONNECTED */; // Reconnection tracking this.pubReconnectAttempts = 0; this.subReconnectAttempts = 0; // Topic subscriptions this.listeners = {}; // Connection state change listeners this.stateChangeListeners = []; // Connection locks to prevent race conditions this.pubConnectPromise = null; this.subConnectPromise = null; // Disconnect tracking this.isDisconnecting = false; this.authToken = options.authToken; this.channel = options.channel; this.rootTopics = options.topics ?? []; if (this.rootTopics.includes(TOPIC_ALL)) { this.rootTopics = [TOPIC_ALL]; } this.headers = { ...options.headers }; if (!this.headers["Authorization"]) { if (!this.authToken.startsWith("Bearer ")) { this.headers["Authorization"] = `Bearer ${this.authToken}`; } else { this.headers["Authorization"] = this.authToken; } } this.autoReconnect = options.autoReconnect ?? true; this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5; this.reconnectDelay = options.reconnectDelay ?? 3e3; this.debug = options.debug ?? false; this.logDebug( "Initialized with options:", JSON.stringify({ baseUrl: this.baseUrl, channel: this.channel, autoReconnect: this.autoReconnect, maxReconnectAttempts: this.maxReconnectAttempts, reconnectDelay: this.reconnectDelay, debug: this.debug }) ); } /** * Handling storing the new auth token and updating headers * * @param token The new auth token */ updateAuthToken(token) { this.authToken = token; if (this.authToken.startsWith("Bearer ")) { this.headers["Authorization"] = this.authToken; } else { this.headers["Authorization"] = `Bearer ${this.authToken}`; } } /** * Rotates the account token via HTTP POST * * @returns Promise that resolves with the server response */ async rotateToken() { const response = await fetch(`${this.baseUrl}/rotate-token`, { method: "POST", headers: { "Content-Type": "application/json", ...this.headers }, body: `{}` }); if (!response.ok) { throw new Error( `HTTP error: ${response.status} ${response.statusText}` ); } const accountInfo = await response.json(); if (accountInfo.token) { this.updateAuthToken(accountInfo.token); } return accountInfo; } /** * Sets up a WebSocket connection for publishing messages * * @returns Promise that resolves when the connection is established */ async connectPublisher() { if (this.pubConnectPromise) { return this.pubConnectPromise; } if (this.isSocketConnected(this.pubSocket)) { return Promise.resolve(); } if (this.isDisconnecting) { return Promise.reject(new Error("Client is disconnecting")); } this.updatePublisherState("connecting" /* CONNECTING */); this.pubConnectPromise = new Promise((resolve, reject) => { try { const queryParams = Object.entries(this.headers).map( ([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent( value )}` ).join("&"); const url = `${this.getWebSocketBaseUrl()}/pub-ws?channel=${encodeURIComponent( this.channel )}&${queryParams}`; this.pubSocket = new WebSocketImpl(url); this.logDebug(`Attempting to connect publisher to ${url}`); if (!this.pubSocket) { this.updatePublisherState("error" /* ERROR */); reject(new Error("Failed to create publisher WebSocket")); return; } this.pubSocket.onopen = () => { this.updatePublisherState("connected" /* CONNECTED */); this.pubReconnectAttempts = 0; this.logDebug("Publisher connected"); resolve(); this.pubConnectPromise = null; }; this.pubSocket.onclose = (event) => { const wasConnected = this.pubState === "connected" /* CONNECTED */; this.updatePublisherState("disconnected" /* DISCONNECTED */); this.logDebug( `Publisher disconnected: ${event?.code} ${event?.reason}` ); this.pubConnectPromise = null; if (wasConnected && this.autoReconnect && !this.isDisconnecting) { this.attemptReconnectPublisher(); } if (!wasConnected) { reject( new Error( `WebSocket error: ${event?.message || "Denied"}` ) ); } }; this.pubSocket.onerror = (event) => { this.updatePublisherState("error" /* ERROR */); this.logDebug( `Publisher error: ${event?.message || "Unknown error"}` ); if (this.pubState !== "connected" /* CONNECTED */) { reject( new Error( `WebSocket error: ${event?.message || "Unknown error"}` ) ); } this.pubConnectPromise = null; }; } catch (error) { this.pubConnectPromise = null; reject(error); } }); return this.pubConnectPromise; } /** * Sets up a WebSocket connection for subscribing to messages * * @returns Promise that resolves when the connection is established */ async connectSubscriber() { if (this.subConnectPromise) { return this.subConnectPromise; } if (this.isSocketConnected(this.subSocket)) { return Promise.resolve(); } if (this.isDisconnecting) { return Promise.reject(new Error("Client is disconnecting")); } this.updateSubscriberState("connecting" /* CONNECTING */); this.subConnectPromise = new Promise((resolve, reject) => { try { let queryParams = Object.entries(this.headers).map( ([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent( value )}` ).join("&"); if (this.rootTopics.length > 0) { queryParams += `&topics=${this.rootTopics.map((topic) => encodeURIComponent(topic)).join(",")}`; } const url = `${this.getWebSocketBaseUrl()}/sub?channel=${encodeURIComponent( this.channel )}&${queryParams}`; this.subSocket = new WebSocketImpl(url); this.logDebug(`Attempting to connect subscriber to ${url}`); if (!this.subSocket) { this.updateSubscriberState("error" /* ERROR */); reject(new Error("Failed to create subscriber WebSocket")); return; } this.subSocket.onopen = () => { this.updateSubscriberState("connected" /* CONNECTED */); this.subReconnectAttempts = 0; this.logDebug("Subscriber connected"); resolve(); this.subConnectPromise = null; }; this.subSocket.onclose = (event) => { const wasConnected = this.subState === "connected" /* CONNECTED */; this.updateSubscriberState("disconnected" /* DISCONNECTED */); this.logDebug( `Subscriber disconnected: ${event?.code} ${event?.reason}` ); this.subConnectPromise = null; if (wasConnected && this.autoReconnect && !this.isDisconnecting) { this.attemptReconnectSubscriber(); } if (!wasConnected) { reject( new Error( `WebSocket error: ${event?.message || "Denied"}` ) ); } }; this.subSocket.onerror = (event) => { this.updateSubscriberState("error" /* ERROR */); this.logDebug( `Subscriber error: ${event?.message || "Unknown error"}` ); if (this.subState !== "connected" /* CONNECTED */) { reject( new Error( `WebSocket error: ${event?.message || "Unknown error"}` ) ); } this.subConnectPromise = null; }; this.subSocket.onmessage = (event) => { try { const message = JSON.parse( event.data.toString() ); const listeners = [ ...this.listeners[message.topic] ?? [], ...this.listeners[TOPIC_ALL] ?? [] ]; if (listeners && listeners.length > 0) { let parsedPayload; try { parsedPayload = JSON.parse(message.payload); } catch (error) { this.logDebug( `Failed to parse message payload: ${error}` ); return; } listeners.forEach((callback) => { try { callback(message.topic, parsedPayload); } catch (error) { this.logDebug( `Error in message callback: ${error}` ); } }); } } catch (error) { this.logDebug( `Error processing WebSocket message: ${error}` ); } }; } catch (error) { this.subConnectPromise = null; reject(error); } }); return this.subConnectPromise; } /** * Attempts to reconnect the publisher socket */ attemptReconnectPublisher() { if (this.pubReconnectAttempts >= this.maxReconnectAttempts || this.isDisconnecting) { this.logDebug( `Max publisher reconnect attempts (${this.maxReconnectAttempts}) reached, giving up` ); return; } this.updatePublisherState("reconnecting" /* RECONNECTING */); this.pubReconnectAttempts++; this.logDebug( `Attempting to reconnect publisher (attempt ${this.pubReconnectAttempts}/${this.maxReconnectAttempts})...` ); setTimeout(() => { if (!this.isDisconnecting) { this.connectPublisher().catch((err) => { this.logDebug( `Failed to reconnect publisher: ${err.message}` ); this.attemptReconnectPublisher(); }); } }, this.reconnectDelay); } /** * Attempts to reconnect the subscriber socket */ attemptReconnectSubscriber() { if (this.subReconnectAttempts >= this.maxReconnectAttempts || this.isDisconnecting) { this.logDebug( `Max subscriber reconnect attempts (${this.maxReconnectAttempts}) reached, giving up` ); return; } this.updateSubscriberState("reconnecting" /* RECONNECTING */); this.subReconnectAttempts++; this.logDebug( `Attempting to reconnect subscriber (attempt ${this.subReconnectAttempts}/${this.maxReconnectAttempts})...` ); setTimeout(() => { if (!this.isDisconnecting) { this.connectSubscriber().catch((err) => { this.logDebug( `Failed to reconnect subscriber: ${err.message}` ); this.attemptReconnectSubscriber(); }); } }, this.reconnectDelay); } /** * Updates the publisher connection state and notifies listeners * * @param state New connection state */ updatePublisherState(state) { this.pubState = state; this.notifyStateChangeListeners(state, "publisher"); } /** * Updates the subscriber connection state and notifies listeners * * @param state New connection state */ updateSubscriberState(state) { this.subState = state; this.notifyStateChangeListeners(state, "subscriber"); } /** * Notifies all state change listeners of a state change * * @param state New connection state * @param connectionType Type of connection that changed state */ notifyStateChangeListeners(state, connectionType) { this.stateChangeListeners.forEach((listener) => { try { listener(state, connectionType); } catch (error) { this.logDebug(`Error in state change listener: ${error}`); } }); } /** * Publishes a message to a topic * * @param topic Topic to publish to * @param payload Message payload to publish * @returns Promise that resolves when the message is published */ async publish(topic, payload) { await this.connectPublisher(); const message = { topic, payload: JSON.stringify(payload) }; this.pubSocket?.send(JSON.stringify(message)); } /** * Publishes a message via HTTP POST instead of WebSocket * * @param topic Topic to publish to * @param payload Message payload to publish * @returns Promise that resolves with the server response */ async publishHttp(topic, payload) { const message = { topic, payload: JSON.stringify(payload) }; const response = await fetch( `${this.baseUrl}/pub?channel=${encodeURIComponent(this.channel)}`, { method: "POST", headers: { "Content-Type": "application/json", ...this.headers }, body: JSON.stringify(message) } ); if (!response.ok) { throw new Error( `HTTP error: ${response.status} ${response.statusText}` ); } return await response.json(); } /** * Subscribes to one or more topics * * @param callback Callback function to call when a message is received * @param topics Optional topic or topics to subscribe to filtered on client * @returns Promise that resolves when the subscription is established */ async subscribe(callback, topics) { topics = topics ?? TOPIC_ALL; let topicArray = Array.isArray(topics) ? topics : [topics]; if (topicArray.includes(TOPIC_ALL)) { topicArray = [TOPIC_ALL]; } await this.connectSubscriber(); topicArray.forEach((topic) => { if (!this.listeners[topic]) { this.listeners[topic] = []; } this.listeners[topic].push(callback); }); } /** * Unsubscribes from a topic * * @param topic Topic to unsubscribe from * @param callback Optional callback function to remove. If not provided, all callbacks for the topic will be removed. */ unsubscribe(topic, callback) { if (!this.listeners[topic]) { return; } if (callback) { this.listeners[topic] = this.listeners[topic].filter( (cb) => cb !== callback ); if (this.listeners[topic].length === 0) { delete this.listeners[topic]; } } else { delete this.listeners[topic]; } } /** * Disconnects from the server * * @returns Promise that resolves when disconnection is complete */ async disconnect() { this.isDisconnecting = true; if (this.pubSocket) { this.updatePublisherState("disconnecting" /* DISCONNECTING */); const pubSocketClosed = new Promise((resolve) => { if (this.pubSocket) { const onClose = () => { this.pubSocket?.removeEventListener("close", onClose); resolve(); }; this.pubSocket.addEventListener("close", onClose); setTimeout(() => { if (this.pubSocket) { this.logDebug( "Publisher socket didn't close properly, forcing null" ); this.pubSocket = null; } resolve(); }, 1e3); } else { resolve(); } try { this.pubSocket?.close(1e3, "Client disconnected"); } catch (err) { this.logDebug(`Error closing publisher socket: ${err}`); this.pubSocket = null; resolve(); } }); await pubSocketClosed; this.pubSocket = null; } if (this.subSocket) { this.updateSubscriberState("disconnecting" /* DISCONNECTING */); const subSocketClosed = new Promise((resolve) => { if (this.subSocket) { const onClose = () => { this.subSocket?.removeEventListener("close", onClose); resolve(); }; this.subSocket.addEventListener("close", onClose); setTimeout(() => { if (this.subSocket) { this.logDebug( "Subscriber socket didn't close properly, forcing null" ); this.subSocket = null; } resolve(); }, 1e3); } else { resolve(); } try { this.subSocket?.close(1e3, "Client disconnected"); } catch (err) { this.logDebug(`Error closing subscriber socket: ${err}`); this.subSocket = null; resolve(); } }); await subSocketClosed; this.subSocket = null; } this.pubConnectPromise = null; this.subConnectPromise = null; this.logDebug("Disconnected from server"); } /** * Adds a listener for connection state changes * * @param listener Function to call when the connection state changes * @returns Function to remove the listener */ onStateChange(listener) { this.stateChangeListeners.push(listener); return () => { this.stateChangeListeners = this.stateChangeListeners.filter( (l) => l !== listener ); }; } /** * Gets the current connection state for the publisher */ getPublisherState() { return this.pubState; } /** * Gets the current connection state for the subscriber */ getSubscriberState() { return this.subState; } /** * Checks if a socket is connected * * @param socket WebSocket to check * @returns True if the socket is connected */ isSocketConnected(socket) { if (!socket) return false; return socket.readyState === 1; } /** * Gets the WebSocket base URL (ws:// or wss://) from the HTTP base URL * * @returns WebSocket base URL */ getWebSocketBaseUrl() { if (this.baseUrl.startsWith("https://")) { return this.baseUrl.replace("https://", "wss://"); } else if (this.baseUrl.startsWith("http://")) { return this.baseUrl.replace("http://", "ws://"); } else { return `ws://${this.baseUrl}`; } } /** * Logs a debug message if debug mode is enabled * * @param message Message to log * @param args Additional arguments to log */ logDebug(message, ...args) { if (this.debug) { if (args.length > 0) { console.debug(`[McastClient] ${message}`, ...args); } else { console.debug(`[McastClient] ${message}`); } } } }; // src/env.ts function loadFromEnv() { const authToken = process.env.MCAST_AUTH_TOKEN; const channel = process.env.MCAST_CHANNEL; if (!authToken) { throw new Error("MCAST_AUTH_TOKEN environment variable is required"); } if (!channel) { throw new Error("MCAST_CHANNEL environment variable is required"); } const debug = process.env.MCAST_DEBUG === "true"; const autoReconnect = process.env.MCAST_AUTO_RECONNECT !== "false"; const maxReconnectAttempts = parseInt( process.env.MCAST_MAX_RECONNECT_ATTEMPTS || "5", 10 ); const reconnectDelay = parseInt( process.env.MCAST_RECONNECT_DELAY || "3000", 10 ); return { authToken, channel, debug, autoReconnect, maxReconnectAttempts, reconnectDelay }; } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { ConnectionState, McastClient, TOPIC_ALL, loadFromEnv });