import { JsonRpcRequest, JsonRpcResponse, JsonRpcRequestSchema, JsonRpcResponseSchema } from '../types.js';
export type MessageHandler = (message: JsonRpcRequest | JsonRpcResponse) => void;
export class WebSocketTransport {
private ws: WebSocket | null = null;
private url: string;
private handlers: Set<MessageHandler> = new Set();
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectDelay = 1000;
private heartbeatInterval: number | null = null;
private isIntentionallyClosed = false;
constructor(url: string) {
this.url = url;
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(this.url);
this.isIntentionallyClosed = false;
this.ws.onopen = () => {
console.log('WebSocket connected');
this.reconnectAttempts = 0;
this.startHeartbeat();
resolve();
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// Validate message structure
if (message.jsonrpc === '2.0') {
if (message.method) {
JsonRpcRequestSchema.parse(message);
} else {
JsonRpcResponseSchema.parse(message);
}
this.handlers.forEach(handler => handler(message));
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
reject(error);
};
this.ws.onclose = () => {
console.log('WebSocket closed');
this.stopHeartbeat();
if (!this.isIntentionallyClosed && this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})...`);
setTimeout(() => this.connect(), delay);
}
};
} catch (error) {
reject(error);
}
});
}
send(message: JsonRpcRequest | JsonRpcResponse): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
console.error('WebSocket not connected, cannot send message');
}
}
onMessage(handler: MessageHandler): () => void {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
disconnect(): void {
this.isIntentionallyClosed = true;
this.stopHeartbeat();
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
private startHeartbeat(): void {
this.heartbeatInterval = window.setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.send({
jsonrpc: '2.0',
id: 'heartbeat',
method: 'ping',
});
}
}, 30000); // 30 seconds
}
private stopHeartbeat(): void {
if (this.heartbeatInterval !== null) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
}