mcast-client
Version:
WebSocket client for Mcast real-time messaging
851 lines (746 loc) • 29.6 kB
text/typescript
import {
AccountResponse,
McastOptions,
ConnectionState,
// Message,
MessageCallback,
SerializedMessage,
StandardResponse,
TOPIC_ALL,
} from "./types";
// Import WebSocket for Node.js environments
import * as ws from "ws";
// Use the appropriate WebSocket implementation depending on the environment
const WebSocketImpl =
typeof WebSocket !== "undefined" ? WebSocket : ws.WebSocket;
/**
* Mcast WebSocket Client
*
* Manages WebSocket connections to a Mcast server for real-time messaging
*/
export class McastClient {
private baseUrl: string = "https://chanban-112482603531.us-east4.run.app";
private authToken: string;
private channel: string;
private headers: Record<string, string>;
private autoReconnect: boolean;
private maxReconnectAttempts: number;
private reconnectDelay: number;
private debug: boolean;
private rootTopics: Array<string>;
// WebSocket connections
private pubSocket: any | null = null;
private subSocket: any | null = null;
// Connection state tracking
private pubState: ConnectionState = ConnectionState.DISCONNECTED;
private subState: ConnectionState = ConnectionState.DISCONNECTED;
// Reconnection tracking
private pubReconnectAttempts: number = 0;
private subReconnectAttempts: number = 0;
// Topic subscriptions
private listeners: {
[key: string]: Array<MessageCallback>;
} = {};
// Connection state change listeners
private stateChangeListeners: Array<
(
state: ConnectionState,
connectionType: "publisher" | "subscriber"
) => void
> = [];
// Connection locks to prevent race conditions
private pubConnectPromise: Promise<void> | null = null;
private subConnectPromise: Promise<void> | null = null;
// Disconnect tracking
private isDisconnecting: boolean = false;
/**
* Creates a new instance of the McastClient
*
* @param options Configuration options for the client
*/
constructor(options: McastOptions) {
this.authToken = options.authToken;
this.channel = options.channel;
this.rootTopics = options.topics ?? [];
if (this.rootTopics.includes(TOPIC_ALL)) {
this.rootTopics = [TOPIC_ALL];
}
// Create headers with Authorization token
// The server expects 'Authorization: Bearer <token>' or just the raw token
this.headers = { ...options.headers };
// Only add Authorization if it's not already present
if (!this.headers["Authorization"]) {
// Add Bearer prefix if not already included
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 ?? 3000;
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
*/
private updateAuthToken(token: string): void {
this.authToken = token;
// Add Bearer prefix if not already included
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(): Promise<AccountResponse> {
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}`
);
}
// Update auth token/headers
const accountInfo = (await response.json()) as AccountResponse;
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
*/
private async connectPublisher(): Promise<void> {
// Return existing connection promise if one is in progress
if (this.pubConnectPromise) {
return this.pubConnectPromise;
}
// Return immediately if already connected
if (this.isSocketConnected(this.pubSocket)) {
return Promise.resolve();
}
// If we're disconnecting, don't try to connect
if (this.isDisconnecting) {
return Promise.reject(new Error("Client is disconnecting"));
}
this.updatePublisherState(ConnectionState.CONNECTING);
// Create a new connection promise
this.pubConnectPromise = new Promise<void>((resolve, reject) => {
try {
// Build the query parameters from headers
const queryParams = Object.entries(this.headers)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
value
)}`
)
.join("&");
// Create the WebSocket connection
// Note: The server expects a POST method for WebSocket upgrade
// Standard WebSocket creates a GET request, so we need to ensure our URL is precisely what the server expects
const url = `${this.getWebSocketBaseUrl()}/pub-ws?channel=${encodeURIComponent(
this.channel
)}&${queryParams}`;
// In both browser and Node.js environments, we need to use standard WebSocket initialization
// The server has been modified to accept both GET and POST for WebSocket upgrades
this.pubSocket = new WebSocketImpl(url);
// Log connection attempt
this.logDebug(`Attempting to connect publisher to ${url}`);
if (!this.pubSocket) {
this.updatePublisherState(ConnectionState.ERROR);
reject(new Error("Failed to create publisher WebSocket"));
return;
}
// Set up event handlers
this.pubSocket.onopen = () => {
this.updatePublisherState(ConnectionState.CONNECTED);
this.pubReconnectAttempts = 0;
this.logDebug("Publisher connected");
resolve();
// Clear connection promise after successful connection
this.pubConnectPromise = null;
};
this.pubSocket.onclose = (event: any) => {
const wasConnected =
this.pubState === ConnectionState.CONNECTED;
this.updatePublisherState(ConnectionState.DISCONNECTED);
this.logDebug(
`Publisher disconnected: ${event?.code} ${event?.reason}`
);
// Clear connection promise
this.pubConnectPromise = null;
// Handle reconnection only if we're not deliberately disconnecting
if (
wasConnected &&
this.autoReconnect &&
!this.isDisconnecting
) {
this.attemptReconnectPublisher();
}
if (!wasConnected) {
reject(
new Error(
`WebSocket error: ${event?.message || "Denied"}`
)
);
}
};
this.pubSocket.onerror = (event: any) => {
this.updatePublisherState(ConnectionState.ERROR);
this.logDebug(
`Publisher error: ${event?.message || "Unknown error"}`
);
// Don't reject if we've already connected (the error might be after connection)
if (this.pubState !== ConnectionState.CONNECTED) {
reject(
new Error(
`WebSocket error: ${
event?.message || "Unknown error"
}`
)
);
}
// Clear connection promise on terminal error
this.pubConnectPromise = null;
};
} catch (error) {
// Clear connection promise on 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
*/
private async connectSubscriber(): Promise<void> {
// Return existing connection promise if one is in progress
if (this.subConnectPromise) {
return this.subConnectPromise;
}
// Return immediately if already connected
if (this.isSocketConnected(this.subSocket)) {
return Promise.resolve();
}
// If we're disconnecting, don't try to connect
if (this.isDisconnecting) {
return Promise.reject(new Error("Client is disconnecting"));
}
this.updateSubscriberState(ConnectionState.CONNECTING);
// Create a new connection promise
this.subConnectPromise = new Promise<void>((resolve, reject) => {
try {
// Build the query parameters from headers
let queryParams = Object.entries(this.headers)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
value
)}`
)
.join("&");
// Optionally topics filtered on server
if (this.rootTopics.length > 0) {
queryParams += `&topics=${this.rootTopics
.map((topic) => encodeURIComponent(topic))
.join(",")}`;
}
// Create the WebSocket connection
// Note: The server expects a POST method for WebSocket upgrade
// Standard WebSocket creates a GET request, so we need to ensure our URL is precisely what the server expects
const url = `${this.getWebSocketBaseUrl()}/sub?channel=${encodeURIComponent(
this.channel
)}&${queryParams}`;
// In both browser and Node.js environments, we need to use standard WebSocket initialization
// The server has been modified to accept both GET and POST for WebSocket upgrades
this.subSocket = new WebSocketImpl(url);
// Log connection attempt
this.logDebug(`Attempting to connect subscriber to ${url}`);
if (!this.subSocket) {
this.updateSubscriberState(ConnectionState.ERROR);
reject(new Error("Failed to create subscriber WebSocket"));
return;
}
// Set up event handlers
this.subSocket.onopen = () => {
this.updateSubscriberState(ConnectionState.CONNECTED);
this.subReconnectAttempts = 0;
this.logDebug("Subscriber connected");
resolve();
// Clear connection promise after successful connection
this.subConnectPromise = null;
};
this.subSocket.onclose = (event: any) => {
const wasConnected =
this.subState === ConnectionState.CONNECTED;
this.updateSubscriberState(ConnectionState.DISCONNECTED);
this.logDebug(
`Subscriber disconnected: ${event?.code} ${event?.reason}`
);
// Clear connection promise
this.subConnectPromise = null;
// Handle reconnection only if we're not deliberately disconnecting
if (
wasConnected &&
this.autoReconnect &&
!this.isDisconnecting
) {
this.attemptReconnectSubscriber();
}
if (!wasConnected) {
reject(
new Error(
`WebSocket error: ${event?.message || "Denied"}`
)
);
}
};
this.subSocket.onerror = (event: any) => {
this.updateSubscriberState(ConnectionState.ERROR);
this.logDebug(
`Subscriber error: ${event?.message || "Unknown error"}`
);
// Don't reject if we've already connected (the error might be after connection)
if (this.subState !== ConnectionState.CONNECTED) {
reject(
new Error(
`WebSocket error: ${
event?.message || "Unknown error"
}`
)
);
}
// Clear connection promise on terminal error
this.subConnectPromise = null;
};
// Set up message handler
this.subSocket.onmessage = (event: any) => {
try {
const message: SerializedMessage = JSON.parse(
event.data.toString()
);
const listeners: Array<MessageCallback> = [
...(this.listeners[message.topic] ?? []),
...(this.listeners[TOPIC_ALL] ?? []),
];
if (listeners && listeners.length > 0) {
let parsedPayload: Record<string, any>;
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) {
// Clear connection promise on error
this.subConnectPromise = null;
reject(error);
}
});
return this.subConnectPromise;
}
/**
* Attempts to reconnect the publisher socket
*/
private attemptReconnectPublisher(): void {
if (
this.pubReconnectAttempts >= this.maxReconnectAttempts ||
this.isDisconnecting
) {
this.logDebug(
`Max publisher reconnect attempts (${this.maxReconnectAttempts}) reached, giving up`
);
return;
}
this.updatePublisherState(ConnectionState.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
*/
private attemptReconnectSubscriber(): void {
if (
this.subReconnectAttempts >= this.maxReconnectAttempts ||
this.isDisconnecting
) {
this.logDebug(
`Max subscriber reconnect attempts (${this.maxReconnectAttempts}) reached, giving up`
);
return;
}
this.updateSubscriberState(ConnectionState.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
*/
private updatePublisherState(state: ConnectionState): void {
this.pubState = state;
this.notifyStateChangeListeners(state, "publisher");
}
/**
* Updates the subscriber connection state and notifies listeners
*
* @param state New connection state
*/
private updateSubscriberState(state: ConnectionState): void {
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
*/
private notifyStateChangeListeners(
state: ConnectionState,
connectionType: "publisher" | "subscriber"
): void {
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: string, payload: Record<string, any>): Promise<void> {
// Ensure the publisher is connected
await this.connectPublisher();
// Create the message
const message: SerializedMessage = {
topic,
payload: JSON.stringify(payload),
};
// Send the message
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: string,
payload: Record<string, any>
): Promise<StandardResponse> {
const message: SerializedMessage = {
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: MessageCallback,
topics?: string | string[]
): Promise<void> {
topics = topics ?? TOPIC_ALL;
// Ensure array of topics
let topicArray = Array.isArray(topics) ? topics : [topics];
if (topicArray.includes(TOPIC_ALL)) {
topicArray = [TOPIC_ALL];
}
// Ensure the subscriber is connected
await this.connectSubscriber();
// Add the callback for each topic
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: string, callback?: MessageCallback): void {
if (!this.listeners[topic]) {
return;
}
if (callback) {
// Remove the specific callback
this.listeners[topic] = this.listeners[topic].filter(
(cb) => cb !== callback
);
// Clean up if no more callbacks
if (this.listeners[topic].length === 0) {
delete this.listeners[topic];
}
} else {
// Remove all callbacks for this topic
delete this.listeners[topic];
}
}
/**
* Disconnects from the server
*
* @returns Promise that resolves when disconnection is complete
*/
async disconnect(): Promise<void> {
// Mark client as disconnecting to prevent reconnection attempts
this.isDisconnecting = true;
// Close publisher socket with a proper close code
if (this.pubSocket) {
this.updatePublisherState(ConnectionState.DISCONNECTING);
const pubSocketClosed = new Promise<void>((resolve) => {
// Add one-time close event listener
if (this.pubSocket) {
const onClose = () => {
this.pubSocket?.removeEventListener("close", onClose);
resolve();
};
this.pubSocket.addEventListener("close", onClose);
// Force close after timeout
setTimeout(() => {
if (this.pubSocket) {
this.logDebug(
"Publisher socket didn't close properly, forcing null"
);
this.pubSocket = null;
}
resolve();
}, 1000);
} else {
resolve();
}
// Attempt clean close
try {
this.pubSocket?.close(1000, "Client disconnected");
} catch (err) {
this.logDebug(`Error closing publisher socket: ${err}`);
this.pubSocket = null;
resolve();
}
});
await pubSocketClosed;
this.pubSocket = null;
}
// Close subscriber socket with a proper close code
if (this.subSocket) {
this.updateSubscriberState(ConnectionState.DISCONNECTING);
const subSocketClosed = new Promise<void>((resolve) => {
// Add one-time close event listener
if (this.subSocket) {
const onClose = () => {
this.subSocket?.removeEventListener("close", onClose);
resolve();
};
this.subSocket.addEventListener("close", onClose);
// Force close after timeout
setTimeout(() => {
if (this.subSocket) {
this.logDebug(
"Subscriber socket didn't close properly, forcing null"
);
this.subSocket = null;
}
resolve();
}, 1000);
} else {
resolve();
}
// Attempt clean close
try {
this.subSocket?.close(1000, "Client disconnected");
} catch (err) {
this.logDebug(`Error closing subscriber socket: ${err}`);
this.subSocket = null;
resolve();
}
});
await subSocketClosed;
this.subSocket = null;
}
// Clear all connection promises
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: (
state: ConnectionState,
connectionType: "publisher" | "subscriber"
) => void
): () => void {
this.stateChangeListeners.push(listener);
// Return a function to remove the listener
return () => {
this.stateChangeListeners = this.stateChangeListeners.filter(
(l) => l !== listener
);
};
}
/**
* Gets the current connection state for the publisher
*/
getPublisherState(): ConnectionState {
return this.pubState;
}
/**
* Gets the current connection state for the subscriber
*/
getSubscriberState(): ConnectionState {
return this.subState;
}
/**
* Checks if a socket is connected
*
* @param socket WebSocket to check
* @returns True if the socket is connected
*/
private isSocketConnected(socket: any): boolean {
if (!socket) return false;
// WebSocket.OPEN is 1 in both browser and Node.js WebSocket implementations
return socket.readyState === 1;
}
/**
* Gets the WebSocket base URL (ws:// or wss://) from the HTTP base URL
*
* @returns WebSocket base URL
*/
private getWebSocketBaseUrl(): string {
if (this.baseUrl.startsWith("https://")) {
return this.baseUrl.replace("https://", "wss://");
} else if (this.baseUrl.startsWith("http://")) {
return this.baseUrl.replace("http://", "ws://");
} else {
// Assume HTTP if no protocol specified
return `ws://${this.baseUrl}`;
}
}
/**
* Logs a debug message if debug mode is enabled
*
* @param message Message to log
* @param args Additional arguments to log
*/
private logDebug(message: string, ...args: any[]): void {
if (this.debug) {
if (args.length > 0) {
console.debug(`[McastClient] ${message}`, ...args);
} else {
console.debug(`[McastClient] ${message}`);
}
}
}
}