useWebSocket.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { useEffect, useRef, useCallback, useState } from 'react';
  2. import { useQueryClient } from '@tanstack/react-query';
  3. interface WebSocketMessage {
  4. type: string;
  5. printer_id?: number;
  6. data?: Record<string, unknown>;
  7. }
  8. export function useWebSocket() {
  9. const wsRef = useRef<WebSocket | null>(null);
  10. const reconnectTimeoutRef = useRef<number | null>(null);
  11. const queryClient = useQueryClient();
  12. const [isConnected, setIsConnected] = useState(false);
  13. // Debounce invalidations to prevent rapid re-render cascades
  14. const pendingInvalidations = useRef<Set<string>>(new Set());
  15. const invalidationTimeoutRef = useRef<number | null>(null);
  16. // Throttle printer status updates to prevent freeze during rapid messages
  17. const pendingPrinterStatus = useRef<Map<number, Record<string, unknown>>>(new Map());
  18. const printerStatusTimeoutRef = useRef<number | null>(null);
  19. // Throttle message processing to prevent browser freeze
  20. const messageQueueRef = useRef<WebSocketMessage[]>([]);
  21. const processingRef = useRef(false);
  22. // Use ref for handleMessage to avoid stale closure in connect
  23. const handleMessageRef = useRef<(message: WebSocketMessage) => void>(() => {});
  24. // Process message queue with throttling to prevent UI freeze
  25. const processMessageQueue = useCallback(() => {
  26. if (processingRef.current || messageQueueRef.current.length === 0) {
  27. return;
  28. }
  29. processingRef.current = true;
  30. const processNext = () => {
  31. const message = messageQueueRef.current.shift();
  32. if (message) {
  33. // Use requestAnimationFrame to yield to the browser
  34. requestAnimationFrame(() => {
  35. handleMessageRef.current(message);
  36. // Small delay between messages to prevent overwhelming the browser
  37. if (messageQueueRef.current.length > 0) {
  38. setTimeout(processNext, 16); // ~60fps
  39. } else {
  40. processingRef.current = false;
  41. }
  42. });
  43. } else {
  44. processingRef.current = false;
  45. }
  46. };
  47. processNext();
  48. }, []);
  49. const connect = useCallback(() => {
  50. if (wsRef.current?.readyState === WebSocket.OPEN) {
  51. return;
  52. }
  53. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  54. const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
  55. const ws = new WebSocket(wsUrl);
  56. let pingInterval: number | null = null;
  57. ws.onopen = () => {
  58. console.log('[WebSocket] Connected');
  59. setIsConnected(true);
  60. // Start ping interval
  61. pingInterval = window.setInterval(() => {
  62. if (ws.readyState === WebSocket.OPEN) {
  63. ws.send(JSON.stringify({ type: 'ping' }));
  64. }
  65. }, 30000);
  66. };
  67. ws.onmessage = (event) => {
  68. try {
  69. const message: WebSocketMessage = JSON.parse(event.data);
  70. // Handle printer_status directly (already throttled) to avoid queue delays
  71. // This prevents the "timelapse" effect where status updates are applied slowly
  72. if (message.type === 'printer_status' && message.printer_id !== undefined && message.data) {
  73. handleMessageRef.current(message);
  74. } else {
  75. // Queue other messages for throttled processing
  76. messageQueueRef.current.push(message);
  77. processMessageQueue();
  78. }
  79. } catch {
  80. // Ignore parse errors
  81. }
  82. };
  83. ws.onclose = (event) => {
  84. console.log('[WebSocket] Closed', event.code, event.reason);
  85. if (pingInterval) {
  86. clearInterval(pingInterval);
  87. pingInterval = null;
  88. }
  89. setIsConnected(false);
  90. wsRef.current = null;
  91. // Reconnect after 3 seconds
  92. reconnectTimeoutRef.current = window.setTimeout(() => {
  93. connect();
  94. }, 3000);
  95. };
  96. ws.onerror = (error) => {
  97. console.error('[WebSocket] Error', error);
  98. ws.close();
  99. };
  100. wsRef.current = ws;
  101. }, []);
  102. // Throttled printer status update - coalesces rapid updates per printer
  103. const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
  104. // Merge with any pending data for this printer
  105. const existing = pendingPrinterStatus.current.get(printerId) || {};
  106. pendingPrinterStatus.current.set(printerId, { ...existing, ...data });
  107. // Schedule update if not already scheduled
  108. if (!printerStatusTimeoutRef.current) {
  109. printerStatusTimeoutRef.current = window.setTimeout(() => {
  110. const updates = new Map(pendingPrinterStatus.current);
  111. pendingPrinterStatus.current.clear();
  112. printerStatusTimeoutRef.current = null;
  113. // Apply all pending updates
  114. requestAnimationFrame(() => {
  115. updates.forEach((statusData, id) => {
  116. queryClient.setQueryData(
  117. ['printerStatus', id],
  118. (old: Record<string, unknown> | undefined) => {
  119. const merged = { ...old, ...statusData };
  120. if (merged.wifi_signal == null && old?.wifi_signal != null) {
  121. merged.wifi_signal = old.wifi_signal;
  122. }
  123. return merged;
  124. }
  125. );
  126. });
  127. });
  128. }, 100); // Update at most every 100ms
  129. }
  130. }, [queryClient]);
  131. // Debounced invalidation helper - coalesces multiple rapid invalidations
  132. const debouncedInvalidate = useCallback((queryKey: string) => {
  133. pendingInvalidations.current.add(queryKey);
  134. // Clear existing timeout
  135. if (invalidationTimeoutRef.current) {
  136. clearTimeout(invalidationTimeoutRef.current);
  137. }
  138. // Schedule invalidation after a delay (3s to prevent browser freeze on print completion)
  139. invalidationTimeoutRef.current = window.setTimeout(() => {
  140. const keys = Array.from(pendingInvalidations.current);
  141. pendingInvalidations.current.clear();
  142. invalidationTimeoutRef.current = null;
  143. // Invalidate queries one at a time with delays to prevent freeze
  144. let delay = 0;
  145. keys.forEach((key) => {
  146. setTimeout(() => {
  147. requestAnimationFrame(() => {
  148. queryClient.invalidateQueries({ queryKey: [key] });
  149. });
  150. }, delay);
  151. delay += 500; // 500ms between each invalidation
  152. });
  153. }, 3000);
  154. }, [queryClient]);
  155. const handleMessage = useCallback((message: WebSocketMessage) => {
  156. switch (message.type) {
  157. case 'printer_status':
  158. if (message.printer_id !== undefined && message.data) {
  159. throttledPrinterStatusUpdate(message.printer_id, message.data);
  160. }
  161. break;
  162. case 'print_complete':
  163. debouncedInvalidate('archives');
  164. debouncedInvalidate('archiveStats');
  165. break;
  166. case 'archive_created':
  167. debouncedInvalidate('archives');
  168. debouncedInvalidate('archiveStats');
  169. break;
  170. case 'archive_updated':
  171. debouncedInvalidate('archives');
  172. break;
  173. case 'pong':
  174. // Keepalive response, ignore
  175. break;
  176. }
  177. }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
  178. // Keep the ref updated with latest handleMessage
  179. useEffect(() => {
  180. handleMessageRef.current = handleMessage;
  181. }, [handleMessage]);
  182. useEffect(() => {
  183. connect();
  184. return () => {
  185. if (reconnectTimeoutRef.current) {
  186. clearTimeout(reconnectTimeoutRef.current);
  187. }
  188. if (invalidationTimeoutRef.current) {
  189. clearTimeout(invalidationTimeoutRef.current);
  190. }
  191. if (printerStatusTimeoutRef.current) {
  192. clearTimeout(printerStatusTimeoutRef.current);
  193. }
  194. if (wsRef.current) {
  195. wsRef.current.close();
  196. }
  197. };
  198. }, [connect]);
  199. const sendMessage = useCallback((message: Record<string, unknown>) => {
  200. if (wsRef.current?.readyState === WebSocket.OPEN) {
  201. wsRef.current.send(JSON.stringify(message));
  202. }
  203. }, []);
  204. return { isConnected, sendMessage };
  205. }