useWebSocket.ts 29 KB

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