useWebSocket.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. import { useQueryClient } from '@tanstack/react-query';
  2. import { useCallback, useEffect, useRef, useState } from 'react';
  3. import { useToast } from '../contexts/ToastContext';
  4. import { useTranslation } from 'react-i18next';
  5. import { api } from '../api/client';
  6. import { inventoryLocationsQueryKey } from '../utils/inventoryQueries';
  7. interface WebSocketMessage {
  8. type: string;
  9. printer_id?: number;
  10. data?: Record<string, unknown>;
  11. printer_name?: string;
  12. missing_slots?: Array<{ slot?: string }>;
  13. }
  14. export function useWebSocket() {
  15. const wsRef = useRef<WebSocket | null>(null);
  16. const reconnectTimeoutRef = useRef<number | null>(null);
  17. const queryClient = useQueryClient();
  18. const [isConnected, setIsConnected] = useState(false);
  19. const lastMissingSpoolWarningRef = useRef<Map<number, string>>(new Map());
  20. const { showToast } = useToast();
  21. const { t } = useTranslation();
  22. // Debounce invalidations to prevent rapid re-render cascades
  23. const pendingInvalidations = useRef<Set<string>>(new Set());
  24. const invalidationTimeoutRef = useRef<number | null>(null);
  25. // Throttle printer status updates to prevent freeze during rapid messages
  26. const pendingPrinterStatus = useRef<Map<number, Record<string, unknown>>>(new Map());
  27. const printerStatusTimeoutRef = useRef<number | null>(null);
  28. // Throttle message processing to prevent browser freeze
  29. const messageQueueRef = useRef<WebSocketMessage[]>([]);
  30. const processingRef = useRef(false);
  31. // Use ref for handleMessage to avoid stale closure in connect
  32. const handleMessageRef = useRef<(message: WebSocketMessage) => void>(() => {});
  33. // Process message queue with throttling to prevent UI freeze
  34. const processMessageQueue = useCallback(() => {
  35. if (processingRef.current || messageQueueRef.current.length === 0) {
  36. return;
  37. }
  38. processingRef.current = true;
  39. const processNext = () => {
  40. const message = messageQueueRef.current.shift();
  41. if (message) {
  42. // Use requestAnimationFrame to yield to the browser
  43. requestAnimationFrame(() => {
  44. handleMessageRef.current(message);
  45. // Small delay between messages to prevent overwhelming the browser
  46. if (messageQueueRef.current.length > 0) {
  47. setTimeout(processNext, 16); // ~60fps
  48. } else {
  49. processingRef.current = false;
  50. }
  51. });
  52. } else {
  53. processingRef.current = false;
  54. }
  55. };
  56. processNext();
  57. }, []);
  58. const connect = useCallback(async () => {
  59. if (wsRef.current?.readyState === WebSocket.OPEN) {
  60. return;
  61. }
  62. // GHSA-r2qv follow-up: when auth is enabled, /ws now requires a token
  63. // minted by POST /api/v1/auth/ws-token. We use the shared ``api.request``
  64. // helper (via ``api.getWebSocketToken``) so the JWT Authorization header
  65. // is attached — a raw ``fetch()`` with ``credentials: 'include'`` would
  66. // miss it (Bambuddy uses Bearer tokens, not cookies, for JWT auth).
  67. // Auth-disabled deployments accept connections without a token, so we
  68. // treat a missing/failed token mint as non-fatal here and let the
  69. // WebSocket close with code 4401 if the server actually rejects us.
  70. let token: string | undefined;
  71. try {
  72. const resp = await api.getWebSocketToken();
  73. token = resp.token;
  74. } catch {
  75. // Token mint failed — most likely auth is disabled (no JWT to attach,
  76. // 401 response) or the user isn't authenticated yet. Fall through and
  77. // try the WebSocket anyway. Auth-disabled deployments succeed;
  78. // auth-enabled deployments close with 4401 and the reconnect loop
  79. // kicks in once the user logs in.
  80. }
  81. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  82. const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
  83. const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
  84. const ws = new WebSocket(wsUrl);
  85. let pingInterval: number | null = null;
  86. ws.onopen = () => {
  87. if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Connected');
  88. setIsConnected(true);
  89. // Start ping interval
  90. pingInterval = window.setInterval(() => {
  91. if (ws.readyState === WebSocket.OPEN) {
  92. ws.send(JSON.stringify({ type: 'ping' }));
  93. }
  94. }, 30000);
  95. };
  96. ws.onmessage = (event) => {
  97. try {
  98. const message: WebSocketMessage = JSON.parse(event.data);
  99. // Handle printer_status directly (already throttled) to avoid queue delays
  100. // This prevents the "timelapse" effect where status updates are applied slowly
  101. if (message.type === 'printer_status' && message.printer_id !== undefined && message.data) {
  102. handleMessageRef.current(message);
  103. } else {
  104. // Queue other messages for throttled processing
  105. messageQueueRef.current.push(message);
  106. processMessageQueue();
  107. }
  108. } catch {
  109. // Ignore parse errors
  110. }
  111. };
  112. ws.onclose = (event) => {
  113. if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Closed', event.code, event.reason);
  114. if (pingInterval) {
  115. clearInterval(pingInterval);
  116. pingInterval = null;
  117. }
  118. setIsConnected(false);
  119. wsRef.current = null;
  120. // Reconnect after 3 seconds
  121. reconnectTimeoutRef.current = window.setTimeout(() => {
  122. connect();
  123. }, 3000);
  124. };
  125. ws.onerror = (error) => {
  126. if (import.meta.env.MODE !== 'test') console.error('[WebSocket] Error', error);
  127. ws.close();
  128. };
  129. wsRef.current = ws;
  130. }, [processMessageQueue]);
  131. // Throttled printer status update - coalesces rapid updates per printer
  132. const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
  133. // Merge with any pending data for this printer
  134. const existing = pendingPrinterStatus.current.get(printerId) || {};
  135. pendingPrinterStatus.current.set(printerId, { ...existing, ...data });
  136. // Schedule update if not already scheduled
  137. if (!printerStatusTimeoutRef.current) {
  138. printerStatusTimeoutRef.current = window.setTimeout(() => {
  139. const updates = new Map(pendingPrinterStatus.current);
  140. pendingPrinterStatus.current.clear();
  141. printerStatusTimeoutRef.current = null;
  142. // Apply all pending updates
  143. requestAnimationFrame(() => {
  144. updates.forEach((statusData, id) => {
  145. queryClient.setQueryData(
  146. ['printerStatus', id],
  147. (old: Record<string, unknown> | undefined) => {
  148. const merged = { ...old, ...statusData };
  149. if (merged.wifi_signal == null && old?.wifi_signal != null) {
  150. merged.wifi_signal = old.wifi_signal;
  151. }
  152. return merged;
  153. }
  154. );
  155. });
  156. });
  157. }, 100); // Update at most every 100ms
  158. }
  159. }, [queryClient]);
  160. // Debounced invalidation helper - coalesces multiple rapid invalidations
  161. const debouncedInvalidate = useCallback((queryKey: string) => {
  162. pendingInvalidations.current.add(queryKey);
  163. // Clear existing timeout
  164. if (invalidationTimeoutRef.current) {
  165. clearTimeout(invalidationTimeoutRef.current);
  166. }
  167. // Schedule invalidation after a delay (3s to prevent browser freeze on print completion)
  168. invalidationTimeoutRef.current = window.setTimeout(() => {
  169. const keys = Array.from(pendingInvalidations.current);
  170. pendingInvalidations.current.clear();
  171. invalidationTimeoutRef.current = null;
  172. // Invalidate queries one at a time with delays to prevent freeze
  173. let delay = 0;
  174. keys.forEach((key) => {
  175. setTimeout(() => {
  176. requestAnimationFrame(() => {
  177. queryClient.invalidateQueries({ queryKey: [key] });
  178. });
  179. }, delay);
  180. delay += 500; // 500ms between each invalidation
  181. });
  182. }, 3000);
  183. }, [queryClient]);
  184. const handleMessage = useCallback((message: WebSocketMessage) => {
  185. switch (message.type) {
  186. case 'printer_status':
  187. if (message.printer_id !== undefined && message.data) {
  188. throttledPrinterStatusUpdate(message.printer_id, message.data);
  189. }
  190. break;
  191. case 'print_start':
  192. // Refetch printer status immediately when print starts to get printable_objects_count
  193. if (message.printer_id !== undefined) {
  194. queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] });
  195. }
  196. break;
  197. case 'missing_spool_assignment': {
  198. if (message.printer_id === undefined || !Array.isArray(message.missing_slots)) {
  199. break;
  200. }
  201. const missingSlotLabels = message.missing_slots
  202. .map((slot) => (slot && typeof slot.slot === 'string' ? slot.slot : 'Unknown'))
  203. .filter((slot) => slot.length > 0);
  204. if (missingSlotLabels.length === 0) {
  205. lastMissingSpoolWarningRef.current.delete(message.printer_id);
  206. break;
  207. }
  208. const signature = missingSlotLabels.join('|');
  209. if (lastMissingSpoolWarningRef.current.get(message.printer_id) === signature) {
  210. break;
  211. }
  212. lastMissingSpoolWarningRef.current.set(message.printer_id, signature);
  213. const printerName = message.printer_name || `Printer ${message.printer_id}`;
  214. const toastMsg = t('printers.toast.missingSpoolAssignment', {
  215. printer: printerName,
  216. slots: missingSlotLabels.join(', '),
  217. });
  218. showToast(toastMsg, 'warning');
  219. break;
  220. }
  221. case 'print_complete':
  222. // Don't invalidate printerStatus here - it causes re-render cascade and browser freeze
  223. // The printer_status websocket messages will naturally update the status
  224. debouncedInvalidate('archives');
  225. debouncedInvalidate('archiveStats');
  226. break;
  227. case 'archive_created':
  228. debouncedInvalidate('archives');
  229. debouncedInvalidate('archiveStats');
  230. break;
  231. case 'archive_updated':
  232. debouncedInvalidate('archives');
  233. break;
  234. case 'pong':
  235. // Keepalive response, ignore
  236. break;
  237. case 'plate_not_empty':
  238. // Plate detection found objects - print was paused
  239. // Dispatch event for toast notification
  240. window.dispatchEvent(new CustomEvent('plate-not-empty', {
  241. detail: {
  242. printer_id: message.printer_id,
  243. printer_name: (message as unknown as { printer_name?: string }).printer_name,
  244. message: (message as unknown as { message?: string }).message,
  245. }
  246. }));
  247. break;
  248. case 'inventory_changed':
  249. // Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
  250. debouncedInvalidate('inventory-spools');
  251. debouncedInvalidate('spoolman-inventory-spools');
  252. debouncedInvalidate(inventoryLocationsQueryKey[0]);
  253. break;
  254. case 'spool_assignment_changed':
  255. // Spool assigned/unassigned - refresh assignment data across all tabs
  256. debouncedInvalidate('spool-assignments');
  257. debouncedInvalidate('slotPresets');
  258. break;
  259. case 'spool_auto_assigned':
  260. // RFID tag matched - refresh inventory and assignment data
  261. debouncedInvalidate('inventory-spools');
  262. debouncedInvalidate('spool-assignments');
  263. break;
  264. case 'spool_usage_logged':
  265. // Filament consumption recorded - refresh spool data
  266. debouncedInvalidate('inventory-spools');
  267. break;
  268. case 'unknown_tag':
  269. // Unknown RFID tag detected - dispatch event for UI
  270. window.dispatchEvent(new CustomEvent('unknown-tag', {
  271. detail: {
  272. printer_id: (message as unknown as { printer_id?: number }).printer_id,
  273. ams_id: (message as unknown as { ams_id?: number }).ams_id,
  274. tray_id: (message as unknown as { tray_id?: number }).tray_id,
  275. tag_uid: (message as unknown as { tag_uid?: string }).tag_uid,
  276. tray_uuid: (message as unknown as { tray_uuid?: string }).tray_uuid,
  277. }
  278. }));
  279. break;
  280. case 'background_dispatch':
  281. window.dispatchEvent(
  282. new CustomEvent('background-dispatch', {
  283. detail: (message as unknown as { data?: Record<string, unknown> }).data || {},
  284. })
  285. );
  286. break;
  287. case 'spoolbuddy_weight':
  288. window.dispatchEvent(new CustomEvent('spoolbuddy-weight', { detail: message }));
  289. break;
  290. case 'spoolbuddy_tag_matched':
  291. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-matched', { detail: message }));
  292. debouncedInvalidate('inventory-spools');
  293. break;
  294. case 'spoolbuddy_unknown_tag':
  295. window.dispatchEvent(new CustomEvent('spoolbuddy-unknown-tag', { detail: message }));
  296. break;
  297. case 'spoolbuddy_tag_removed':
  298. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', { detail: message }));
  299. break;
  300. case 'spoolbuddy_tag_written':
  301. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-written', { detail: message }));
  302. debouncedInvalidate('inventory-spools');
  303. break;
  304. case 'spoolbuddy_tag_write_failed':
  305. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-write-failed', { detail: message }));
  306. break;
  307. case 'spoolbuddy_online':
  308. window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
  309. debouncedInvalidate('spoolbuddy-devices');
  310. debouncedInvalidate('spoolbuddy-update-check');
  311. break;
  312. case 'spoolbuddy_offline':
  313. window.dispatchEvent(new CustomEvent('spoolbuddy-offline', { detail: message }));
  314. debouncedInvalidate('spoolbuddy-devices');
  315. break;
  316. case 'spoolbuddy_update':
  317. debouncedInvalidate('spoolbuddy-devices');
  318. debouncedInvalidate('spoolbuddy-update-check');
  319. break;
  320. }
  321. }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
  322. // Keep the ref updated with latest handleMessage
  323. useEffect(() => {
  324. handleMessageRef.current = handleMessage;
  325. }, [handleMessage]);
  326. useEffect(() => {
  327. // connect() is async after the GHSA-r2qv fix (mints a ws-token first).
  328. // Fire-and-forget at mount; the inner reconnect loop also calls
  329. // connect() in the ws.onclose handler.
  330. void connect();
  331. return () => {
  332. if (reconnectTimeoutRef.current) {
  333. clearTimeout(reconnectTimeoutRef.current);
  334. }
  335. if (invalidationTimeoutRef.current) {
  336. clearTimeout(invalidationTimeoutRef.current);
  337. }
  338. if (printerStatusTimeoutRef.current) {
  339. clearTimeout(printerStatusTimeoutRef.current);
  340. }
  341. if (wsRef.current) {
  342. wsRef.current.close();
  343. }
  344. };
  345. }, [connect]);
  346. const sendMessage = useCallback((message: Record<string, unknown>) => {
  347. if (wsRef.current?.readyState === WebSocket.OPEN) {
  348. wsRef.current.send(JSON.stringify(message));
  349. }
  350. }, []);
  351. return { isConnected, sendMessage };
  352. }