useWebSocket.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  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, ApiError } from '../api/client';
  6. import { inventoryLocationsQueryKey } from '../utils/inventoryQueries';
  7. // The only auth-failure close code /api/v1/ws emits (websocket.py
  8. // _WS_CLOSE_UNAUTHORIZED). A 4401 means the ws-token was missing / invalid /
  9. // expired, or the caller lacks WEBSOCKET_CONNECT — none of which a reconnect can
  10. // fix without a fresh login (which remounts this provider anyway). Treat it as
  11. // terminal so we don't respawn the /auth/ws-token loop.
  12. const WS_CLOSE_UNAUTHORIZED = 4401;
  13. interface WebSocketMessage {
  14. type: string;
  15. printer_id?: number;
  16. data?: Record<string, unknown>;
  17. printer_name?: string;
  18. missing_slots?: Array<{ slot?: string }>;
  19. // Slicer Pipeline run events (#1425 PR C). ``run`` carries the full
  20. // PipelineRunResponse payload — typed loosely here so the WebSocket hook
  21. // doesn't pull the full client.ts types in.
  22. run?: { pipeline_id?: number | null };
  23. }
  24. export function useWebSocket() {
  25. const wsRef = useRef<WebSocket | null>(null);
  26. const reconnectTimeoutRef = useRef<number | null>(null);
  27. // Set true by the effect cleanup so a close event fired *during* unmount
  28. // can't schedule a reconnect after the provider is gone (the old code cleared
  29. // reconnectTimeoutRef, then .close() ran ws.onclose which set a *fresh*
  30. // timeout — a leaked reconnect that kept minting ws-tokens post-logout).
  31. const disposedRef = useRef(false);
  32. const queryClient = useQueryClient();
  33. const [isConnected, setIsConnected] = useState(false);
  34. const lastMissingSpoolWarningRef = useRef<Map<number, string>>(new Map());
  35. const { showToast } = useToast();
  36. const { t } = useTranslation();
  37. // Debounce invalidations to prevent rapid re-render cascades
  38. const pendingInvalidations = useRef<Set<string>>(new Set());
  39. const invalidationTimeoutRef = useRef<number | null>(null);
  40. // Throttle printer status updates to prevent freeze during rapid messages
  41. const pendingPrinterStatus = useRef<Map<number, Record<string, unknown>>>(new Map());
  42. const printerStatusTimeoutRef = useRef<number | null>(null);
  43. // Throttle message processing to prevent browser freeze
  44. const messageQueueRef = useRef<WebSocketMessage[]>([]);
  45. const processingRef = useRef(false);
  46. // Use ref for handleMessage to avoid stale closure in connect
  47. const handleMessageRef = useRef<(message: WebSocketMessage) => void>(() => {});
  48. // Process message queue with throttling to prevent UI freeze
  49. const processMessageQueue = useCallback(() => {
  50. if (processingRef.current || messageQueueRef.current.length === 0) {
  51. return;
  52. }
  53. processingRef.current = true;
  54. const processNext = () => {
  55. const message = messageQueueRef.current.shift();
  56. if (message) {
  57. // Use requestAnimationFrame to yield to the browser
  58. requestAnimationFrame(() => {
  59. handleMessageRef.current(message);
  60. // Small delay between messages to prevent overwhelming the browser
  61. if (messageQueueRef.current.length > 0) {
  62. setTimeout(processNext, 16); // ~60fps
  63. } else {
  64. processingRef.current = false;
  65. }
  66. });
  67. } else {
  68. processingRef.current = false;
  69. }
  70. };
  71. processNext();
  72. }, []);
  73. const connect = useCallback(async () => {
  74. if (disposedRef.current || wsRef.current?.readyState === WebSocket.OPEN) {
  75. return;
  76. }
  77. // GHSA-r2qv follow-up: when auth is enabled, /ws now requires a token
  78. // minted by POST /api/v1/auth/ws-token. We use the shared ``api.request``
  79. // helper (via ``api.getWebSocketToken``) so the JWT Authorization header
  80. // is attached — a raw ``fetch()`` with ``credentials: 'include'`` would
  81. // miss it (Bambuddy uses Bearer tokens, not cookies, for JWT auth).
  82. // Auth-disabled deployments accept connections without a token.
  83. let token: string | undefined;
  84. try {
  85. const resp = await api.getWebSocketToken();
  86. token = resp.token;
  87. } catch (err) {
  88. // A 401/403 from the token mint is an AUTH decision, not a transient
  89. // blip, so retrying is pointless and hammers /auth/ws-token every 3s:
  90. // 401 — the JWT expired. ``request()`` already cleared it and
  91. // dispatched ``auth:expired``, so the route guard is redirecting
  92. // to /login and this provider is about to unmount.
  93. // 403 — the user is validly logged in but their group lacks
  94. // WEBSOCKET_CONNECT. They stay logged in; live updates simply
  95. // degrade to the REST polling the query cache already does.
  96. // Either way: do NOT open a tokenless socket (the server just closes it
  97. // 4401) and do NOT reconnect. The old catch-all fell through to a
  98. // tokenless socket whose 4401 close rescheduled connect() forever. A
  99. // network/5xx error is not auth — fall through and let the socket + its
  100. // reconnect loop handle it (auth-disabled deployments also land here with
  101. // no token and connect fine).
  102. const status = err instanceof ApiError ? err.status : 0;
  103. if (status === 401 || status === 403) {
  104. return;
  105. }
  106. }
  107. if (disposedRef.current) {
  108. return;
  109. }
  110. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  111. const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
  112. const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
  113. const ws = new WebSocket(wsUrl);
  114. let pingInterval: number | null = null;
  115. ws.onopen = () => {
  116. if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Connected');
  117. setIsConnected(true);
  118. // Start ping interval
  119. pingInterval = window.setInterval(() => {
  120. if (ws.readyState === WebSocket.OPEN) {
  121. ws.send(JSON.stringify({ type: 'ping' }));
  122. }
  123. }, 30000);
  124. };
  125. ws.onmessage = (event) => {
  126. try {
  127. const message: WebSocketMessage = JSON.parse(event.data);
  128. // Handle printer_status directly (already throttled) to avoid queue delays
  129. // This prevents the "timelapse" effect where status updates are applied slowly
  130. if (message.type === 'printer_status' && message.printer_id !== undefined && message.data) {
  131. handleMessageRef.current(message);
  132. } else {
  133. // Queue other messages for throttled processing
  134. messageQueueRef.current.push(message);
  135. processMessageQueue();
  136. }
  137. } catch {
  138. // Ignore parse errors
  139. }
  140. };
  141. ws.onclose = (event) => {
  142. if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Closed', event.code, event.reason);
  143. if (pingInterval) {
  144. clearInterval(pingInterval);
  145. pingInterval = null;
  146. }
  147. setIsConnected(false);
  148. wsRef.current = null;
  149. // Don't reconnect after an auth rejection (4401) or once the provider has
  150. // unmounted — both would just respawn the /auth/ws-token loop. A 4401 is
  151. // terminal (needs a fresh login, which remounts us); every other close
  152. // code is treated as a network drop and gets the 3s reconnect.
  153. if (disposedRef.current || event.code === WS_CLOSE_UNAUTHORIZED) {
  154. return;
  155. }
  156. // Reconnect after 3 seconds
  157. reconnectTimeoutRef.current = window.setTimeout(() => {
  158. connect();
  159. }, 3000);
  160. };
  161. ws.onerror = (error) => {
  162. if (import.meta.env.MODE !== 'test') console.error('[WebSocket] Error', error);
  163. ws.close();
  164. };
  165. wsRef.current = ws;
  166. }, [processMessageQueue]);
  167. // Throttled printer status update - coalesces rapid updates per printer
  168. const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
  169. // Merge with any pending data for this printer
  170. const existing = pendingPrinterStatus.current.get(printerId) || {};
  171. pendingPrinterStatus.current.set(printerId, { ...existing, ...data });
  172. // Schedule update if not already scheduled
  173. if (!printerStatusTimeoutRef.current) {
  174. printerStatusTimeoutRef.current = window.setTimeout(() => {
  175. const updates = new Map(pendingPrinterStatus.current);
  176. pendingPrinterStatus.current.clear();
  177. printerStatusTimeoutRef.current = null;
  178. // Apply all pending updates
  179. requestAnimationFrame(() => {
  180. updates.forEach((statusData, id) => {
  181. queryClient.setQueryData(
  182. ['printerStatus', id],
  183. (old: Record<string, unknown> | undefined) => {
  184. const merged = { ...old, ...statusData };
  185. if (merged.wifi_signal == null && old?.wifi_signal != null) {
  186. merged.wifi_signal = old.wifi_signal;
  187. }
  188. return merged;
  189. }
  190. );
  191. });
  192. });
  193. }, 100); // Update at most every 100ms
  194. }
  195. }, [queryClient]);
  196. // Debounced invalidation helper - coalesces multiple rapid invalidations
  197. const debouncedInvalidate = useCallback((queryKey: string) => {
  198. pendingInvalidations.current.add(queryKey);
  199. // Clear existing timeout
  200. if (invalidationTimeoutRef.current) {
  201. clearTimeout(invalidationTimeoutRef.current);
  202. }
  203. // Schedule invalidation after a delay (3s to prevent browser freeze on print completion)
  204. invalidationTimeoutRef.current = window.setTimeout(() => {
  205. const keys = Array.from(pendingInvalidations.current);
  206. pendingInvalidations.current.clear();
  207. invalidationTimeoutRef.current = null;
  208. // Invalidate queries one at a time with delays to prevent freeze
  209. let delay = 0;
  210. keys.forEach((key) => {
  211. setTimeout(() => {
  212. requestAnimationFrame(() => {
  213. queryClient.invalidateQueries({ queryKey: [key] });
  214. });
  215. }, delay);
  216. delay += 500; // 500ms between each invalidation
  217. });
  218. }, 3000);
  219. }, [queryClient]);
  220. const handleMessage = useCallback((message: WebSocketMessage) => {
  221. switch (message.type) {
  222. case 'printer_status':
  223. if (message.printer_id !== undefined && message.data) {
  224. throttledPrinterStatusUpdate(message.printer_id, message.data);
  225. }
  226. break;
  227. case 'print_start':
  228. // Refetch printer status immediately when print starts to get printable_objects_count
  229. if (message.printer_id !== undefined) {
  230. queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] });
  231. }
  232. break;
  233. case 'missing_spool_assignment': {
  234. if (message.printer_id === undefined || !Array.isArray(message.missing_slots)) {
  235. break;
  236. }
  237. const missingSlotLabels = message.missing_slots
  238. .map((slot) => (slot && typeof slot.slot === 'string' ? slot.slot : 'Unknown'))
  239. .filter((slot) => slot.length > 0);
  240. if (missingSlotLabels.length === 0) {
  241. lastMissingSpoolWarningRef.current.delete(message.printer_id);
  242. break;
  243. }
  244. const signature = missingSlotLabels.join('|');
  245. if (lastMissingSpoolWarningRef.current.get(message.printer_id) === signature) {
  246. break;
  247. }
  248. lastMissingSpoolWarningRef.current.set(message.printer_id, signature);
  249. const printerName = message.printer_name || `Printer ${message.printer_id}`;
  250. const toastMsg = t('printers.toast.missingSpoolAssignment', {
  251. printer: printerName,
  252. slots: missingSlotLabels.join(', '),
  253. });
  254. showToast(toastMsg, 'warning');
  255. break;
  256. }
  257. case 'print_complete':
  258. // Don't invalidate printerStatus here - it causes re-render cascade and browser freeze
  259. // The printer_status websocket messages will naturally update the status
  260. debouncedInvalidate('archives');
  261. debouncedInvalidate('archiveStats');
  262. break;
  263. case 'archive_created':
  264. debouncedInvalidate('archives');
  265. debouncedInvalidate('archiveStats');
  266. break;
  267. case 'archive_updated':
  268. debouncedInvalidate('archives');
  269. break;
  270. case 'pong':
  271. // Keepalive response, ignore
  272. break;
  273. case 'plate_not_empty':
  274. // Plate detection found objects - print was paused
  275. // Dispatch event for toast notification
  276. window.dispatchEvent(new CustomEvent('plate-not-empty', {
  277. detail: {
  278. printer_id: message.printer_id,
  279. printer_name: (message as unknown as { printer_name?: string }).printer_name,
  280. message: (message as unknown as { message?: string }).message,
  281. }
  282. }));
  283. break;
  284. case 'inventory_changed':
  285. // Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
  286. debouncedInvalidate('inventory-spools');
  287. debouncedInvalidate('spoolman-inventory-spools');
  288. debouncedInvalidate(inventoryLocationsQueryKey[0]);
  289. break;
  290. case 'spool_assignment_changed':
  291. // Spool assigned/unassigned - refresh assignment data across all tabs
  292. debouncedInvalidate('spool-assignments');
  293. debouncedInvalidate('slotPresets');
  294. break;
  295. case 'spool_auto_assigned':
  296. // RFID tag matched - refresh inventory and assignment data
  297. debouncedInvalidate('inventory-spools');
  298. debouncedInvalidate('spool-assignments');
  299. break;
  300. case 'spool_usage_logged':
  301. // Filament consumption recorded - refresh spool data
  302. debouncedInvalidate('inventory-spools');
  303. break;
  304. case 'unknown_tag': {
  305. // Unknown RFID tag detected — dispatch event for UI. The backend
  306. // ships the slot's current tray data alongside the event so
  307. // consumers don't have to look it up from the (frequently stale)
  308. // cached printerStatus query.
  309. const m = message as unknown as {
  310. printer_id?: number;
  311. ams_id?: number;
  312. tray_id?: number;
  313. tag_uid?: string;
  314. tray_uuid?: string;
  315. tray_type?: string | null;
  316. tray_color?: string | null;
  317. tray_sub_brands?: string | null;
  318. tray_count?: number | null;
  319. };
  320. window.dispatchEvent(new CustomEvent('unknown-tag', {
  321. detail: {
  322. printer_id: m.printer_id,
  323. ams_id: m.ams_id,
  324. tray_id: m.tray_id,
  325. tag_uid: m.tag_uid,
  326. tray_uuid: m.tray_uuid,
  327. tray_type: m.tray_type,
  328. tray_color: m.tray_color,
  329. tray_sub_brands: m.tray_sub_brands,
  330. tray_count: m.tray_count,
  331. }
  332. }));
  333. break;
  334. }
  335. case 'spoolbuddy_weight':
  336. window.dispatchEvent(new CustomEvent('spoolbuddy-weight', { detail: message }));
  337. break;
  338. case 'spoolbuddy_tag_matched':
  339. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-matched', { detail: message }));
  340. debouncedInvalidate('inventory-spools');
  341. break;
  342. case 'spoolbuddy_unknown_tag':
  343. window.dispatchEvent(new CustomEvent('spoolbuddy-unknown-tag', { detail: message }));
  344. break;
  345. case 'spoolbuddy_tag_removed':
  346. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', { detail: message }));
  347. break;
  348. case 'spoolbuddy_tag_written':
  349. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-written', { detail: message }));
  350. debouncedInvalidate('inventory-spools');
  351. break;
  352. case 'spoolbuddy_tag_write_failed':
  353. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-write-failed', { detail: message }));
  354. break;
  355. case 'spoolbuddy_online':
  356. window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
  357. debouncedInvalidate('spoolbuddy-devices');
  358. debouncedInvalidate('spoolbuddy-update-check');
  359. break;
  360. case 'spoolbuddy_offline':
  361. window.dispatchEvent(new CustomEvent('spoolbuddy-offline', { detail: message }));
  362. debouncedInvalidate('spoolbuddy-devices');
  363. break;
  364. case 'spoolbuddy_update':
  365. debouncedInvalidate('spoolbuddy-devices');
  366. debouncedInvalidate('spoolbuddy-update-check');
  367. break;
  368. // Dispatch toast lifecycle (#1625 follow-up — restored the upload
  369. // progress UI that the scheduler unification removed). Four backend
  370. // event types collapse to one frontend channel. No
  371. // `queue_item_queued` (the toast must wait for the upload to
  372. // actually start) and no `queue_item_dispatched` (the legacy
  373. // background-dispatch flow kept status='processing' from upload
  374. // start until printer ack — the "Awaiting printer…" subtitle is
  375. // derived from upload_progress_pct >= 99.9, not from a separate
  376. // event).
  377. case 'queue_item_uploading':
  378. case 'queue_item_upload_progress':
  379. case 'queue_item_acked':
  380. case 'queue_item_failed':
  381. window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail: message }));
  382. break;
  383. // Slicer Pipeline runs (#1425 PR C). State transitions on the run
  384. // refresh both the dashboard list AND the per-pipeline "Last run"
  385. // chip in Settings → Pipelines.
  386. case 'pipeline_run_updated':
  387. queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
  388. if (message.run?.pipeline_id) {
  389. queryClient.invalidateQueries({ queryKey: ['pipeline-runs', message.run.pipeline_id] });
  390. }
  391. break;
  392. }
  393. }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
  394. // Keep the ref updated with latest handleMessage
  395. useEffect(() => {
  396. handleMessageRef.current = handleMessage;
  397. }, [handleMessage]);
  398. useEffect(() => {
  399. // connect() is async after the GHSA-r2qv fix (mints a ws-token first).
  400. // Fire-and-forget at mount; the inner reconnect loop also calls
  401. // connect() in the ws.onclose handler.
  402. disposedRef.current = false;
  403. void connect();
  404. return () => {
  405. // Mark disposed BEFORE closing so the ws.onclose triggered by close()
  406. // sees it and won't schedule a post-unmount reconnect.
  407. disposedRef.current = true;
  408. if (reconnectTimeoutRef.current) {
  409. clearTimeout(reconnectTimeoutRef.current);
  410. }
  411. if (invalidationTimeoutRef.current) {
  412. clearTimeout(invalidationTimeoutRef.current);
  413. }
  414. if (printerStatusTimeoutRef.current) {
  415. clearTimeout(printerStatusTimeoutRef.current);
  416. }
  417. if (wsRef.current) {
  418. wsRef.current.close();
  419. }
  420. };
  421. }, [connect]);
  422. const sendMessage = useCallback((message: Record<string, unknown>) => {
  423. if (wsRef.current?.readyState === WebSocket.OPEN) {
  424. wsRef.current.send(JSON.stringify(message));
  425. }
  426. }, []);
  427. return { isConnected, sendMessage };
  428. }