@bigdigital/kiosk-content-sdk
Version:
A Firebase-powered Content Management System SDK optimized for kiosks with offline support, template management, and real-time connection monitoring
205 lines (174 loc) • 5.73 kB
text/typescript
declare const WebSocket: {
new (url: string): WebSocket;
prototype: WebSocket;
readonly CONNECTING: 0;
readonly OPEN: 1;
readonly CLOSING: 2;
readonly CLOSED: 3;
};
export interface KioskConnectionOptions {
url: string;
kioskId: string;
onConnectionUpdate?: (isConnected: boolean) => void;
maxRetries?: number;
retryDelay?: number;
debug?: boolean;
}
type EventCallback = (...args: any[]) => void;
export class KioskConnection {
private ws: WebSocket | null = null;
private isConnected = false;
private eventHandlers: Map<string, Set<EventCallback>> = new Map();
private retryCount = 0;
private retryTimeout: NodeJS.Timeout | null = null;
private readonly maxRetries: number;
private readonly initialRetryDelay: number;
private readonly debug: boolean;
private isShuttingDown = false;
constructor(private options: KioskConnectionOptions) {
this.maxRetries = options.maxRetries ?? 5;
this.initialRetryDelay = options.retryDelay ?? 1000;
this.debug = options.debug ?? false;
}
private log(...args: any[]) {
if (this.debug && !this.isShuttingDown) {
console.log(...args);
}
}
private logError(...args: any[]) {
if (this.debug && !this.isShuttingDown) {
console.error(...args);
}
}
public connect() {
if (this.ws) {
this.disconnect();
}
try {
this.log('Attempting to connect to:', this.options.url);
const wsUrl = new URL(this.options.url);
wsUrl.searchParams.set('kioskId', this.options.kioskId);
this.ws = new WebSocket(wsUrl.toString());
this.ws.onopen = () => {
this.log('WebSocket connection established');
this.isConnected = true;
this.retryCount = 0;
this.options.onConnectionUpdate?.(true);
this.emit('connected');
};
this.ws.onclose = (event) => {
this.log(`WebSocket closed with code ${event.code}`);
this.handleDisconnect('Connection closed', event);
};
this.ws.onerror = (error) => {
this.logError('WebSocket error:', error);
this.handleDisconnect('Connection error', error);
};
this.ws.onmessage = (event) => {
if (event.data === 'ping') {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send('pong');
this.emit('ping');
}
} else {
try {
const data = JSON.parse(event.data.toString());
this.emit('message', data);
} catch (error) {
this.logError('Error parsing message:', error);
}
}
};
} catch (error) {
this.logError('Error creating WebSocket:', error);
this.handleDisconnect('Failed to create connection', error);
}
}
private handleDisconnect(reason: string, error: any) {
if (!this.isConnected && this.retryCount > 0) return;
this.isConnected = false;
if (!this.isShuttingDown) {
this.options.onConnectionUpdate?.(false);
this.emit('disconnected', { reason, error });
}
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
this.retryTimeout = null;
}
if (!this.isShuttingDown && this.retryCount < this.maxRetries) {
const delay = this.initialRetryDelay * Math.pow(2, this.retryCount);
this.log(`Attempting to reconnect in ${delay}ms (attempt ${this.retryCount + 1}/${this.maxRetries})`);
this.retryTimeout = setTimeout(() => {
this.retryCount++;
this.connect();
}, delay);
} else if (!this.isShuttingDown) {
this.logError('Max retry attempts reached');
this.emit('maxRetriesExceeded');
}
}
public disconnect(): Promise<void> {
return new Promise<void>((resolve) => {
this.isShuttingDown = true;
// Clear any pending retry attempts
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
this.retryTimeout = null;
}
// If there's no WebSocket or it's already closed, cleanup and resolve immediately
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
this.cleanup();
resolve();
return;
}
// Set up one-time close handler for cleanup and resolution
const handleClose = () => {
this.cleanup();
resolve();
};
const currentWs = this.ws;
currentWs.addEventListener('close', handleClose, { once: true });
// If the socket is open or connecting, close it
if (currentWs.readyState === WebSocket.OPEN || currentWs.readyState === WebSocket.CONNECTING) {
currentWs.close();
}
});
}
private cleanup() {
if (this.ws) {
// Remove all event listeners
this.ws.onopen = null;
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.onmessage = null;
}
this.ws = null;
this.isConnected = false;
this.retryCount = 0;
if (!this.isShuttingDown) {
this.options.onConnectionUpdate?.(false);
this.emit('disconnected', { reason: 'Manual disconnect' });
}
}
public on(event: string, callback: EventCallback) {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, new Set());
}
this.eventHandlers.get(event)?.add(callback);
return this;
}
private emit(event: string, ...args: any[]) {
const handlers = this.eventHandlers.get(event);
if (!handlers || this.isShuttingDown) return;
handlers.forEach(callback => {
try {
callback(...args);
} catch (error) {
this.logError('Error in event handler:', error);
}
});
}
public isConnectionOpen(): boolean {
return this.isConnected && this.ws?.readyState === WebSocket.OPEN;
}
}