Files

190 lines
5.9 KiB
TypeScript

import { useEffect, useRef, useCallback } from 'react';
import type { Channel, HealthStatus, Contact, Message, MessagePath, RawPacket } from './types';
import { parseWsEvent } from './wsEvents';
interface ErrorEvent {
message: string;
details?: string;
}
interface SuccessEvent {
message: string;
details?: string;
}
export interface UseWebSocketOptions {
onHealth?: (health: HealthStatus) => void;
onMessage?: (message: Message) => void;
onContact?: (contact: Contact) => void;
onContactResolved?: (previousPublicKey: string, contact: Contact) => void;
onContactDeleted?: (publicKey: string) => void;
onChannel?: (channel: Channel) => void;
onChannelDeleted?: (key: string) => void;
onRawPacket?: (packet: RawPacket) => void;
onMessageAcked?: (
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
) => void;
onError?: (error: ErrorEvent) => void;
onSuccess?: (success: SuccessEvent) => void;
onReconnect?: () => void;
}
export function useWebSocket(options: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const shouldReconnectRef = useRef(true);
const hasConnectedRef = useRef(false);
// Store options in ref to avoid stale closures in WebSocket handlers.
// The onmessage callback captures this ref, and we keep the ref updated
// with the latest handlers. This way, even though the WebSocket connection
// is only created once, it always calls the current handlers.
const optionsRef = useRef<UseWebSocketOptions>(options);
// Keep the ref updated with latest options
useEffect(() => {
optionsRef.current = options;
}, [options]);
// Connect function - uses ref for handlers to avoid stale closures
// No dependencies needed since we access handlers through ref
const connect = useCallback(() => {
// Determine WebSocket URL based on current location
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
// Connection established (or re-established after disconnect)
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (hasConnectedRef.current) {
optionsRef.current.onReconnect?.();
}
hasConnectedRef.current = true;
};
ws.onclose = () => {
// Connection lost — will auto-reconnect after delay
wsRef.current = null;
if (!shouldReconnectRef.current) {
return;
}
// Reconnect after 3 seconds
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
reconnectTimeoutRef.current = window.setTimeout(() => {
// Reconnect attempt after disconnect
connect();
}, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onmessage = (event) => {
try {
const msg = parseWsEvent(event.data);
// Access handlers through ref to always use current versions
const handlers = optionsRef.current;
switch (msg.type) {
case 'health':
handlers.onHealth?.(msg.data as HealthStatus);
break;
case 'message':
handlers.onMessage?.(msg.data as Message);
break;
case 'contact':
handlers.onContact?.(msg.data as Contact);
break;
case 'contact_resolved': {
const resolved = msg.data as {
previous_public_key: string;
contact: Contact;
};
handlers.onContactResolved?.(resolved.previous_public_key, resolved.contact);
break;
}
case 'channel':
handlers.onChannel?.(msg.data as Channel);
break;
case 'contact_deleted':
handlers.onContactDeleted?.((msg.data as { public_key: string }).public_key);
break;
case 'channel_deleted':
handlers.onChannelDeleted?.((msg.data as { key: string }).key);
break;
case 'raw_packet':
handlers.onRawPacket?.(msg.data as RawPacket);
break;
case 'message_acked': {
const ackData = msg.data as {
message_id: number;
ack_count: number;
paths?: MessagePath[];
packet_id?: number | null;
};
handlers.onMessageAcked?.(
ackData.message_id,
ackData.ack_count,
ackData.paths,
ackData.packet_id
);
break;
}
case 'error':
handlers.onError?.(msg.data as ErrorEvent);
break;
case 'success':
handlers.onSuccess?.(msg.data as SuccessEvent);
break;
case 'pong':
// Heartbeat response, ignore
break;
case 'unknown':
console.warn('Unknown WebSocket message type:', msg.rawType);
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
wsRef.current = ws;
}, []); // No dependencies - handlers accessed through ref
useEffect(() => {
shouldReconnectRef.current = true;
connect();
// Ping every 30 seconds to keep connection alive
const pingInterval = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send('ping');
}
}, 30000);
return () => {
shouldReconnectRef.current = false;
clearInterval(pingInterval);
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connect]);
}