useWebSocket.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  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, inventorySuppliersQueryKey } 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. filename?: string;
  19. missing_slots?: Array<{ slot?: string }>;
  20. // Spool-assignment read-back verification (#2582).
  21. slot?: string;
  22. verified?: boolean;
  23. kprofile_applied?: boolean;
  24. saw_tray?: boolean;
  25. // Slicer Pipeline run events (#1425 PR C). ``run`` carries the full
  26. // PipelineRunResponse payload — typed loosely here so the WebSocket hook
  27. // doesn't pull the full client.ts types in.
  28. run?: { pipeline_id?: number | null };
  29. }
  30. export function useWebSocket() {
  31. const wsRef = useRef<WebSocket | null>(null);
  32. const reconnectTimeoutRef = useRef<number | null>(null);
  33. // Set true by the effect cleanup so a close event fired *during* unmount
  34. // can't schedule a reconnect after the provider is gone (the old code cleared
  35. // reconnectTimeoutRef, then .close() ran ws.onclose which set a *fresh*
  36. // timeout — a leaked reconnect that kept minting ws-tokens post-logout).
  37. const disposedRef = useRef(false);
  38. const queryClient = useQueryClient();
  39. const [isConnected, setIsConnected] = useState(false);
  40. const lastMissingSpoolWarningRef = useRef<Map<number, string>>(new Map());
  41. const { showToast } = useToast();
  42. const { t } = useTranslation();
  43. // Debounce invalidations to prevent rapid re-render cascades
  44. const pendingInvalidations = useRef<Set<string>>(new Set());
  45. const invalidationTimeoutRef = useRef<number | null>(null);
  46. // Throttle printer status updates to prevent freeze during rapid messages
  47. const pendingPrinterStatus = useRef<Map<number, Record<string, unknown>>>(new Map());
  48. const printerStatusTimeoutRef = useRef<number | null>(null);
  49. // Throttle message processing to prevent browser freeze
  50. const messageQueueRef = useRef<WebSocketMessage[]>([]);
  51. const processingRef = useRef(false);
  52. // Use ref for handleMessage to avoid stale closure in connect
  53. const handleMessageRef = useRef<(message: WebSocketMessage) => void>(() => {});
  54. // Process message queue with throttling to prevent UI freeze
  55. const processMessageQueue = useCallback(() => {
  56. if (processingRef.current || messageQueueRef.current.length === 0) {
  57. return;
  58. }
  59. processingRef.current = true;
  60. const processNext = () => {
  61. const message = messageQueueRef.current.shift();
  62. if (message) {
  63. handleMessageRef.current(message);
  64. // Small delay between messages to prevent overwhelming the browser.
  65. // This setTimeout is the yield; a requestAnimationFrame around the
  66. // handler used to sit here too, which stalled the whole queue in a
  67. // hidden tab (see the note on the rAF removal below).
  68. if (messageQueueRef.current.length > 0) {
  69. setTimeout(processNext, 16); // ~60fps
  70. } else {
  71. processingRef.current = false;
  72. }
  73. } else {
  74. processingRef.current = false;
  75. }
  76. };
  77. processNext();
  78. }, []);
  79. const connect = useCallback(async () => {
  80. if (disposedRef.current || wsRef.current?.readyState === WebSocket.OPEN) {
  81. return;
  82. }
  83. // GHSA-r2qv follow-up: when auth is enabled, /ws now requires a token
  84. // minted by POST /api/v1/auth/ws-token. We use the shared ``api.request``
  85. // helper (via ``api.getWebSocketToken``) so the JWT Authorization header
  86. // is attached — a raw ``fetch()`` with ``credentials: 'include'`` would
  87. // miss it (Bambuddy uses Bearer tokens, not cookies, for JWT auth).
  88. // Auth-disabled deployments accept connections without a token.
  89. let token: string | undefined;
  90. try {
  91. const resp = await api.getWebSocketToken();
  92. token = resp.token;
  93. } catch (err) {
  94. // A 401/403 from the token mint is an AUTH decision, not a transient
  95. // blip, so retrying is pointless and hammers /auth/ws-token every 3s:
  96. // 401 — the JWT expired. ``request()`` already cleared it and
  97. // dispatched ``auth:expired``, so the route guard is redirecting
  98. // to /login and this provider is about to unmount.
  99. // 403 — the user is validly logged in but their group lacks
  100. // WEBSOCKET_CONNECT. They stay logged in; live updates simply
  101. // degrade to the REST polling the query cache already does.
  102. // Either way: do NOT open a tokenless socket (the server just closes it
  103. // 4401) and do NOT reconnect. The old catch-all fell through to a
  104. // tokenless socket whose 4401 close rescheduled connect() forever. A
  105. // network/5xx error is not auth — fall through and let the socket + its
  106. // reconnect loop handle it (auth-disabled deployments also land here with
  107. // no token and connect fine).
  108. const status = err instanceof ApiError ? err.status : 0;
  109. if (status === 401 || status === 403) {
  110. return;
  111. }
  112. }
  113. if (disposedRef.current) {
  114. return;
  115. }
  116. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  117. const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
  118. const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
  119. const ws = new WebSocket(wsUrl);
  120. let pingInterval: number | null = null;
  121. ws.onopen = () => {
  122. if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Connected');
  123. setIsConnected(true);
  124. // Start ping interval
  125. pingInterval = window.setInterval(() => {
  126. if (ws.readyState === WebSocket.OPEN) {
  127. ws.send(JSON.stringify({ type: 'ping' }));
  128. }
  129. }, 30000);
  130. };
  131. ws.onmessage = (event) => {
  132. try {
  133. const message: WebSocketMessage = JSON.parse(event.data);
  134. // Handle printer_status directly (already throttled) to avoid queue delays
  135. // This prevents the "timelapse" effect where status updates are applied slowly
  136. if (message.type === 'printer_status' && message.printer_id !== undefined && message.data) {
  137. handleMessageRef.current(message);
  138. } else {
  139. // Queue other messages for throttled processing
  140. messageQueueRef.current.push(message);
  141. processMessageQueue();
  142. }
  143. } catch {
  144. // Ignore parse errors
  145. }
  146. };
  147. ws.onclose = (event) => {
  148. if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Closed', event.code, event.reason);
  149. if (pingInterval) {
  150. clearInterval(pingInterval);
  151. pingInterval = null;
  152. }
  153. setIsConnected(false);
  154. wsRef.current = null;
  155. // Don't reconnect after an auth rejection (4401) or once the provider has
  156. // unmounted — both would just respawn the /auth/ws-token loop. A 4401 is
  157. // terminal (needs a fresh login, which remounts us); every other close
  158. // code is treated as a network drop and gets the 3s reconnect.
  159. if (disposedRef.current || event.code === WS_CLOSE_UNAUTHORIZED) {
  160. return;
  161. }
  162. // Reconnect after 3 seconds
  163. reconnectTimeoutRef.current = window.setTimeout(() => {
  164. connect();
  165. }, 3000);
  166. };
  167. ws.onerror = (error) => {
  168. if (import.meta.env.MODE !== 'test') console.error('[WebSocket] Error', error);
  169. ws.close();
  170. };
  171. wsRef.current = ws;
  172. }, [processMessageQueue]);
  173. // Write every pending printer status into the query cache.
  174. //
  175. // Extracted so the hidden-tab path below can run it inline: both paths share
  176. // this one body, so the merge semantics cannot drift apart. Cancels any
  177. // scheduled coalescing timer, since everything it was going to write has
  178. // just been written and re-running it would re-apply stale data over newer.
  179. const flushPrinterStatus = useCallback(() => {
  180. if (printerStatusTimeoutRef.current) {
  181. clearTimeout(printerStatusTimeoutRef.current);
  182. printerStatusTimeoutRef.current = null;
  183. }
  184. const updates = new Map(pendingPrinterStatus.current);
  185. pendingPrinterStatus.current.clear();
  186. updates.forEach((statusData, id) => {
  187. queryClient.setQueryData(['printerStatus', id], (old: Record<string, unknown> | undefined) => {
  188. const merged = { ...old, ...statusData };
  189. if (merged.wifi_signal == null && old?.wifi_signal != null) {
  190. merged.wifi_signal = old.wifi_signal;
  191. }
  192. return merged;
  193. });
  194. });
  195. }, [queryClient]);
  196. // Printer status update — coalesced while the tab is visible, written
  197. // straight through while it is not.
  198. //
  199. // #2754 (reporter @mic4rd), in two stages. First, these writes ran inside a
  200. // requestAnimationFrame: a hidden tab gets no rendering opportunities, so
  201. // the browser *holds* queued frame callbacks rather than throttling them,
  202. // and nothing reached the cache until the tab was shown again. Removing the
  203. // frame callback fixed that total stall but not the report, because a second
  204. // timer-shaped dependency was left behind — this 100ms coalescing window.
  205. //
  206. // Browsers clamp timers in a hidden page to at best once a second, and drop
  207. // pages hidden for more than five minutes to roughly one wake-up a minute.
  208. // The reporter saw a tab title stuck at 2% beside a page at 40%.
  209. //
  210. // The coalescing exists to stop rapid messages triggering a render cascade.
  211. // A hidden tab is not painting, so there is no cascade to prevent there —
  212. // the timer is pure cost, and it is exactly the thing being throttled. So
  213. // when hidden, skip it and write immediately.
  214. //
  215. // Note "hidden", not "unfocused": on Windows a fully-occluded window reports
  216. // visibilityState 'hidden' too, which is why the reporter saw this from
  217. // merely clicking away rather than only from switching tabs.
  218. const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
  219. // Merge with any pending data for this printer
  220. const existing = pendingPrinterStatus.current.get(printerId) || {};
  221. pendingPrinterStatus.current.set(printerId, { ...existing, ...data });
  222. if (document.hidden) {
  223. flushPrinterStatus();
  224. return;
  225. }
  226. // Schedule update if not already scheduled
  227. if (!printerStatusTimeoutRef.current) {
  228. printerStatusTimeoutRef.current = window.setTimeout(flushPrinterStatus, 100);
  229. }
  230. }, [flushPrinterStatus]);
  231. // Debounced invalidation helper - coalesces multiple rapid invalidations
  232. const debouncedInvalidate = useCallback((queryKey: string) => {
  233. pendingInvalidations.current.add(queryKey);
  234. // Clear existing timeout
  235. if (invalidationTimeoutRef.current) {
  236. clearTimeout(invalidationTimeoutRef.current);
  237. }
  238. // Schedule invalidation after a delay (3s to prevent browser freeze on print completion)
  239. invalidationTimeoutRef.current = window.setTimeout(() => {
  240. const keys = Array.from(pendingInvalidations.current);
  241. pendingInvalidations.current.clear();
  242. invalidationTimeoutRef.current = null;
  243. // Invalidate queries one at a time with delays to prevent freeze.
  244. // The 500ms stagger is the anti-cascade measure; a frame callback around
  245. // each invalidation used to sit inside it and stalled these refreshes in
  246. // a hidden tab for the same reason as the status writes above (#2754).
  247. let delay = 0;
  248. keys.forEach((key) => {
  249. setTimeout(() => {
  250. queryClient.invalidateQueries({ queryKey: [key] });
  251. }, delay);
  252. delay += 500; // 500ms between each invalidation
  253. });
  254. }, 3000);
  255. }, [queryClient]);
  256. // Slot changes skip the cascade debounce above. That debounce exists for
  257. // print completion, where one event fans out across half the app; a spool
  258. // swap touches one slot and the user is standing at the printer looking at
  259. // the card. Waiting 3s of quiet and then staggering the keys 500ms apart put
  260. // several seconds of visibly wrong data on screen for no benefit, and the
  261. // timer restarts on every further event, so a busy moment could defer it
  262. // indefinitely. React Query coalesces repeat invalidations of one key, so a
  263. // Spoolman sync reporting a dozen slots at once still costs three refetches.
  264. const invalidateSlotQueries = useCallback(() => {
  265. queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
  266. queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
  267. queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
  268. }, [queryClient]);
  269. const handleMessage = useCallback((message: WebSocketMessage) => {
  270. switch (message.type) {
  271. case 'printer_status':
  272. if (message.printer_id !== undefined && message.data) {
  273. throttledPrinterStatusUpdate(message.printer_id, message.data);
  274. }
  275. break;
  276. case 'print_start':
  277. // Refetch printer status immediately when print starts to get printable_objects_count
  278. if (message.printer_id !== undefined) {
  279. queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] });
  280. }
  281. break;
  282. case 'missing_spool_assignment': {
  283. if (message.printer_id === undefined || !Array.isArray(message.missing_slots)) {
  284. break;
  285. }
  286. const missingSlotLabels = message.missing_slots
  287. .map((slot) => (slot && typeof slot.slot === 'string' ? slot.slot : 'Unknown'))
  288. .filter((slot) => slot.length > 0);
  289. if (missingSlotLabels.length === 0) {
  290. lastMissingSpoolWarningRef.current.delete(message.printer_id);
  291. break;
  292. }
  293. const signature = missingSlotLabels.join('|');
  294. if (lastMissingSpoolWarningRef.current.get(message.printer_id) === signature) {
  295. break;
  296. }
  297. lastMissingSpoolWarningRef.current.set(message.printer_id, signature);
  298. const printerName = message.printer_name || `Printer ${message.printer_id}`;
  299. const toastMsg = t('printers.toast.missingSpoolAssignment', {
  300. printer: printerName,
  301. slots: missingSlotLabels.join(', '),
  302. });
  303. showToast(toastMsg, 'warning');
  304. break;
  305. }
  306. case 'print_complete':
  307. // Don't invalidate printerStatus here - it causes re-render cascade and browser freeze
  308. // The printer_status websocket messages will naturally update the status
  309. debouncedInvalidate('archives');
  310. debouncedInvalidate('archiveStats');
  311. break;
  312. case 'print_confirm_request': {
  313. // #1898: a completed print asked for its outcome verdict. Handed to
  314. // the globally mounted ConfirmOutcomeDialog in Layout via the same
  315. // CustomEvent relay plate_not_empty uses.
  316. const detail = (message as unknown as { data?: { archive_id?: number; print_name?: string } }).data;
  317. if (detail?.archive_id != null) {
  318. window.dispatchEvent(new CustomEvent('print-confirm-request', { detail }));
  319. }
  320. break;
  321. }
  322. case 'kill_switch_triggered': {
  323. const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
  324. const filename = message.filename || t('common.unknown');
  325. showToast(t('printers.toast.killSwitchTriggered', { printer, filename }), 'error');
  326. break;
  327. }
  328. case 'billing_charge_failed': {
  329. const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
  330. const filename = message.filename || t('common.unknown');
  331. showToast(t('printers.toast.billingChargeFailed', { printer, filename }), 'error');
  332. break;
  333. }
  334. case 'archive_created':
  335. debouncedInvalidate('archives');
  336. debouncedInvalidate('archiveStats');
  337. break;
  338. case 'archive_updated':
  339. debouncedInvalidate('archives');
  340. break;
  341. case 'pong':
  342. // Keepalive response, ignore
  343. break;
  344. case 'plate_not_empty':
  345. // Plate detection found objects - print was paused
  346. // Dispatch event for toast notification
  347. window.dispatchEvent(new CustomEvent('plate-not-empty', {
  348. detail: {
  349. printer_id: message.printer_id,
  350. printer_name: (message as unknown as { printer_name?: string }).printer_name,
  351. message: (message as unknown as { message?: string }).message,
  352. }
  353. }));
  354. break;
  355. case 'inventory_changed':
  356. // Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
  357. debouncedInvalidate('inventory-spools');
  358. debouncedInvalidate('spoolman-inventory-spools');
  359. debouncedInvalidate(inventoryLocationsQueryKey[0]);
  360. debouncedInvalidate(inventorySuppliersQueryKey[0]);
  361. break;
  362. case 'spool_assignment_changed':
  363. // Spool assigned/unassigned - refresh assignment data across all tabs.
  364. // Both inventory modes: the Spoolman AMS sync raises this event too, and
  365. // its slot rows live under their own query keys.
  366. invalidateSlotQueries();
  367. break;
  368. case 'spool_assignment_verified': {
  369. // #2582: the backend read the AMS telemetry back after an assignment
  370. // and either confirmed the tray accepted it or timed out. Toast the
  371. // outcome so the AMS→Studio hand-off is no longer silent.
  372. // Backend always supplies printer_name (falls back to "Printer <id>"),
  373. // so the '||' here only guards a malformed payload.
  374. const printer = message.printer_name || 'Printer';
  375. const slot = message.slot || '?';
  376. if (message.verified) {
  377. if (message.kprofile_applied === false) {
  378. // Filament id landed but the K-profile (cali_idx) did not — the
  379. // exact "loaded but no flow profile" case the reporter chased.
  380. showToast(
  381. t('printers.toast.assignmentVerifiedNoKprofile', { slot, printer }),
  382. 'warning'
  383. );
  384. } else {
  385. showToast(t('printers.toast.assignmentVerified', { slot, printer }), 'success');
  386. }
  387. } else {
  388. showToast(t('printers.toast.assignmentNotConfirmed', { slot, printer }), 'warning');
  389. }
  390. break;
  391. }
  392. case 'spool_auto_assigned':
  393. // RFID tag matched - refresh inventory and assignment data.
  394. // slotPresets is not optional here: auto-assigning rewrites the slot's
  395. // slot_preset_mappings row, and the AMS slot card reads that row ahead
  396. // of the live tray_info_idx. Leave it cached and swapping a spool keeps
  397. // the *previous* spool's preset name on the card -- the rest of the
  398. // card updates off the status push, so it reads as one wrong line
  399. // rather than a stale card. Only the manual assign path invalidated it.
  400. debouncedInvalidate('inventory-spools');
  401. invalidateSlotQueries();
  402. break;
  403. case 'spool_usage_logged':
  404. // Filament consumption recorded - refresh spool data
  405. debouncedInvalidate('inventory-spools');
  406. break;
  407. case 'unknown_tag': {
  408. // Unknown RFID tag detected — dispatch event for UI. The backend
  409. // ships the slot's current tray data alongside the event so
  410. // consumers don't have to look it up from the (frequently stale)
  411. // cached printerStatus query.
  412. const m = message as unknown as {
  413. printer_id?: number;
  414. ams_id?: number;
  415. tray_id?: number;
  416. tag_uid?: string;
  417. tray_uuid?: string;
  418. tray_type?: string | null;
  419. tray_color?: string | null;
  420. tray_sub_brands?: string | null;
  421. tray_count?: number | null;
  422. };
  423. window.dispatchEvent(new CustomEvent('unknown-tag', {
  424. detail: {
  425. printer_id: m.printer_id,
  426. ams_id: m.ams_id,
  427. tray_id: m.tray_id,
  428. tag_uid: m.tag_uid,
  429. tray_uuid: m.tray_uuid,
  430. tray_type: m.tray_type,
  431. tray_color: m.tray_color,
  432. tray_sub_brands: m.tray_sub_brands,
  433. tray_count: m.tray_count,
  434. }
  435. }));
  436. break;
  437. }
  438. case 'spoolbuddy_weight':
  439. window.dispatchEvent(new CustomEvent('spoolbuddy-weight', { detail: message }));
  440. break;
  441. case 'spoolbuddy_tag_matched':
  442. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-matched', { detail: message }));
  443. debouncedInvalidate('inventory-spools');
  444. break;
  445. case 'spoolbuddy_unknown_tag':
  446. window.dispatchEvent(new CustomEvent('spoolbuddy-unknown-tag', { detail: message }));
  447. break;
  448. case 'spoolbuddy_tag_removed':
  449. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', { detail: message }));
  450. break;
  451. case 'spoolbuddy_tag_written':
  452. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-written', { detail: message }));
  453. debouncedInvalidate('inventory-spools');
  454. break;
  455. case 'spoolbuddy_tag_write_failed':
  456. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-write-failed', { detail: message }));
  457. break;
  458. case 'spoolbuddy_online':
  459. window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
  460. debouncedInvalidate('spoolbuddy-devices');
  461. debouncedInvalidate('spoolbuddy-update-check');
  462. break;
  463. case 'spoolbuddy_offline':
  464. window.dispatchEvent(new CustomEvent('spoolbuddy-offline', { detail: message }));
  465. debouncedInvalidate('spoolbuddy-devices');
  466. break;
  467. case 'spoolbuddy_update':
  468. debouncedInvalidate('spoolbuddy-devices');
  469. debouncedInvalidate('spoolbuddy-update-check');
  470. break;
  471. // Dispatch toast lifecycle (#1625 follow-up — restored the upload
  472. // progress UI that the scheduler unification removed). Four backend
  473. // event types collapse to one frontend channel. No
  474. // `queue_item_queued` (the toast must wait for the upload to
  475. // actually start) and no `queue_item_dispatched` (the legacy
  476. // background-dispatch flow kept status='processing' from upload
  477. // start until printer ack — the "Awaiting printer…" subtitle is
  478. // derived from upload_progress_pct >= 99.9, not from a separate
  479. // event).
  480. case 'queue_item_uploading':
  481. case 'queue_item_upload_progress':
  482. case 'queue_item_acked':
  483. case 'queue_item_failed':
  484. window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail: message }));
  485. break;
  486. // Slicer Pipeline runs (#1425 PR C). State transitions on the run
  487. // refresh both the dashboard list AND the per-pipeline "Last run"
  488. // chip in Settings → Pipelines.
  489. case 'pipeline_run_updated':
  490. queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
  491. if (message.run?.pipeline_id) {
  492. queryClient.invalidateQueries({ queryKey: ['pipeline-runs', message.run.pipeline_id] });
  493. }
  494. break;
  495. }
  496. }, [queryClient, debouncedInvalidate, invalidateSlotQueries, throttledPrinterStatusUpdate, showToast, t]);
  497. // Keep the ref updated with latest handleMessage
  498. useEffect(() => {
  499. handleMessageRef.current = handleMessage;
  500. }, [handleMessage]);
  501. useEffect(() => {
  502. // connect() is async after the GHSA-r2qv fix (mints a ws-token first).
  503. // Fire-and-forget at mount; the inner reconnect loop also calls
  504. // connect() in the ws.onclose handler.
  505. disposedRef.current = false;
  506. void connect();
  507. return () => {
  508. // Mark disposed BEFORE closing so the ws.onclose triggered by close()
  509. // sees it and won't schedule a post-unmount reconnect.
  510. disposedRef.current = true;
  511. if (reconnectTimeoutRef.current) {
  512. clearTimeout(reconnectTimeoutRef.current);
  513. }
  514. if (invalidationTimeoutRef.current) {
  515. clearTimeout(invalidationTimeoutRef.current);
  516. }
  517. if (printerStatusTimeoutRef.current) {
  518. clearTimeout(printerStatusTimeoutRef.current);
  519. }
  520. if (wsRef.current) {
  521. wsRef.current.close();
  522. }
  523. };
  524. }, [connect]);
  525. const sendMessage = useCallback((message: Record<string, unknown>) => {
  526. if (wsRef.current?.readyState === WebSocket.OPEN) {
  527. wsRef.current.send(JSON.stringify(message));
  528. }
  529. }, []);
  530. return { isConnected, sendMessage };
  531. }