useWebSocket.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  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. 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. const handleMessage = useCallback((message: WebSocketMessage) => {
  257. switch (message.type) {
  258. case 'printer_status':
  259. if (message.printer_id !== undefined && message.data) {
  260. throttledPrinterStatusUpdate(message.printer_id, message.data);
  261. }
  262. break;
  263. case 'print_start':
  264. // Refetch printer status immediately when print starts to get printable_objects_count
  265. if (message.printer_id !== undefined) {
  266. queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] });
  267. }
  268. break;
  269. case 'missing_spool_assignment': {
  270. if (message.printer_id === undefined || !Array.isArray(message.missing_slots)) {
  271. break;
  272. }
  273. const missingSlotLabels = message.missing_slots
  274. .map((slot) => (slot && typeof slot.slot === 'string' ? slot.slot : 'Unknown'))
  275. .filter((slot) => slot.length > 0);
  276. if (missingSlotLabels.length === 0) {
  277. lastMissingSpoolWarningRef.current.delete(message.printer_id);
  278. break;
  279. }
  280. const signature = missingSlotLabels.join('|');
  281. if (lastMissingSpoolWarningRef.current.get(message.printer_id) === signature) {
  282. break;
  283. }
  284. lastMissingSpoolWarningRef.current.set(message.printer_id, signature);
  285. const printerName = message.printer_name || `Printer ${message.printer_id}`;
  286. const toastMsg = t('printers.toast.missingSpoolAssignment', {
  287. printer: printerName,
  288. slots: missingSlotLabels.join(', '),
  289. });
  290. showToast(toastMsg, 'warning');
  291. break;
  292. }
  293. case 'print_complete':
  294. // Don't invalidate printerStatus here - it causes re-render cascade and browser freeze
  295. // The printer_status websocket messages will naturally update the status
  296. debouncedInvalidate('archives');
  297. debouncedInvalidate('archiveStats');
  298. break;
  299. case 'kill_switch_triggered': {
  300. const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
  301. const filename = message.filename || t('common.unknown');
  302. showToast(t('printers.toast.killSwitchTriggered', { printer, filename }), 'error');
  303. break;
  304. }
  305. case 'billing_charge_failed': {
  306. const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
  307. const filename = message.filename || t('common.unknown');
  308. showToast(t('printers.toast.billingChargeFailed', { printer, filename }), 'error');
  309. break;
  310. }
  311. case 'archive_created':
  312. debouncedInvalidate('archives');
  313. debouncedInvalidate('archiveStats');
  314. break;
  315. case 'archive_updated':
  316. debouncedInvalidate('archives');
  317. break;
  318. case 'pong':
  319. // Keepalive response, ignore
  320. break;
  321. case 'plate_not_empty':
  322. // Plate detection found objects - print was paused
  323. // Dispatch event for toast notification
  324. window.dispatchEvent(new CustomEvent('plate-not-empty', {
  325. detail: {
  326. printer_id: message.printer_id,
  327. printer_name: (message as unknown as { printer_name?: string }).printer_name,
  328. message: (message as unknown as { message?: string }).message,
  329. }
  330. }));
  331. break;
  332. case 'inventory_changed':
  333. // Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
  334. debouncedInvalidate('inventory-spools');
  335. debouncedInvalidate('spoolman-inventory-spools');
  336. debouncedInvalidate(inventoryLocationsQueryKey[0]);
  337. break;
  338. case 'spool_assignment_changed':
  339. // Spool assigned/unassigned - refresh assignment data across all tabs
  340. debouncedInvalidate('spool-assignments');
  341. debouncedInvalidate('slotPresets');
  342. break;
  343. case 'spool_assignment_verified': {
  344. // #2582: the backend read the AMS telemetry back after an assignment
  345. // and either confirmed the tray accepted it or timed out. Toast the
  346. // outcome so the AMS→Studio hand-off is no longer silent.
  347. // Backend always supplies printer_name (falls back to "Printer <id>"),
  348. // so the '||' here only guards a malformed payload.
  349. const printer = message.printer_name || 'Printer';
  350. const slot = message.slot || '?';
  351. if (message.verified) {
  352. if (message.kprofile_applied === false) {
  353. // Filament id landed but the K-profile (cali_idx) did not — the
  354. // exact "loaded but no flow profile" case the reporter chased.
  355. showToast(
  356. t('printers.toast.assignmentVerifiedNoKprofile', { slot, printer }),
  357. 'warning'
  358. );
  359. } else {
  360. showToast(t('printers.toast.assignmentVerified', { slot, printer }), 'success');
  361. }
  362. } else {
  363. showToast(t('printers.toast.assignmentNotConfirmed', { slot, printer }), 'warning');
  364. }
  365. break;
  366. }
  367. case 'spool_auto_assigned':
  368. // RFID tag matched - refresh inventory and assignment data
  369. debouncedInvalidate('inventory-spools');
  370. debouncedInvalidate('spool-assignments');
  371. break;
  372. case 'spool_usage_logged':
  373. // Filament consumption recorded - refresh spool data
  374. debouncedInvalidate('inventory-spools');
  375. break;
  376. case 'unknown_tag': {
  377. // Unknown RFID tag detected — dispatch event for UI. The backend
  378. // ships the slot's current tray data alongside the event so
  379. // consumers don't have to look it up from the (frequently stale)
  380. // cached printerStatus query.
  381. const m = message as unknown as {
  382. printer_id?: number;
  383. ams_id?: number;
  384. tray_id?: number;
  385. tag_uid?: string;
  386. tray_uuid?: string;
  387. tray_type?: string | null;
  388. tray_color?: string | null;
  389. tray_sub_brands?: string | null;
  390. tray_count?: number | null;
  391. };
  392. window.dispatchEvent(new CustomEvent('unknown-tag', {
  393. detail: {
  394. printer_id: m.printer_id,
  395. ams_id: m.ams_id,
  396. tray_id: m.tray_id,
  397. tag_uid: m.tag_uid,
  398. tray_uuid: m.tray_uuid,
  399. tray_type: m.tray_type,
  400. tray_color: m.tray_color,
  401. tray_sub_brands: m.tray_sub_brands,
  402. tray_count: m.tray_count,
  403. }
  404. }));
  405. break;
  406. }
  407. case 'spoolbuddy_weight':
  408. window.dispatchEvent(new CustomEvent('spoolbuddy-weight', { detail: message }));
  409. break;
  410. case 'spoolbuddy_tag_matched':
  411. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-matched', { detail: message }));
  412. debouncedInvalidate('inventory-spools');
  413. break;
  414. case 'spoolbuddy_unknown_tag':
  415. window.dispatchEvent(new CustomEvent('spoolbuddy-unknown-tag', { detail: message }));
  416. break;
  417. case 'spoolbuddy_tag_removed':
  418. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', { detail: message }));
  419. break;
  420. case 'spoolbuddy_tag_written':
  421. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-written', { detail: message }));
  422. debouncedInvalidate('inventory-spools');
  423. break;
  424. case 'spoolbuddy_tag_write_failed':
  425. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-write-failed', { detail: message }));
  426. break;
  427. case 'spoolbuddy_online':
  428. window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
  429. debouncedInvalidate('spoolbuddy-devices');
  430. debouncedInvalidate('spoolbuddy-update-check');
  431. break;
  432. case 'spoolbuddy_offline':
  433. window.dispatchEvent(new CustomEvent('spoolbuddy-offline', { detail: message }));
  434. debouncedInvalidate('spoolbuddy-devices');
  435. break;
  436. case 'spoolbuddy_update':
  437. debouncedInvalidate('spoolbuddy-devices');
  438. debouncedInvalidate('spoolbuddy-update-check');
  439. break;
  440. // Dispatch toast lifecycle (#1625 follow-up — restored the upload
  441. // progress UI that the scheduler unification removed). Four backend
  442. // event types collapse to one frontend channel. No
  443. // `queue_item_queued` (the toast must wait for the upload to
  444. // actually start) and no `queue_item_dispatched` (the legacy
  445. // background-dispatch flow kept status='processing' from upload
  446. // start until printer ack — the "Awaiting printer…" subtitle is
  447. // derived from upload_progress_pct >= 99.9, not from a separate
  448. // event).
  449. case 'queue_item_uploading':
  450. case 'queue_item_upload_progress':
  451. case 'queue_item_acked':
  452. case 'queue_item_failed':
  453. window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail: message }));
  454. break;
  455. // Slicer Pipeline runs (#1425 PR C). State transitions on the run
  456. // refresh both the dashboard list AND the per-pipeline "Last run"
  457. // chip in Settings → Pipelines.
  458. case 'pipeline_run_updated':
  459. queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
  460. if (message.run?.pipeline_id) {
  461. queryClient.invalidateQueries({ queryKey: ['pipeline-runs', message.run.pipeline_id] });
  462. }
  463. break;
  464. }
  465. }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
  466. // Keep the ref updated with latest handleMessage
  467. useEffect(() => {
  468. handleMessageRef.current = handleMessage;
  469. }, [handleMessage]);
  470. useEffect(() => {
  471. // connect() is async after the GHSA-r2qv fix (mints a ws-token first).
  472. // Fire-and-forget at mount; the inner reconnect loop also calls
  473. // connect() in the ws.onclose handler.
  474. disposedRef.current = false;
  475. void connect();
  476. return () => {
  477. // Mark disposed BEFORE closing so the ws.onclose triggered by close()
  478. // sees it and won't schedule a post-unmount reconnect.
  479. disposedRef.current = true;
  480. if (reconnectTimeoutRef.current) {
  481. clearTimeout(reconnectTimeoutRef.current);
  482. }
  483. if (invalidationTimeoutRef.current) {
  484. clearTimeout(invalidationTimeoutRef.current);
  485. }
  486. if (printerStatusTimeoutRef.current) {
  487. clearTimeout(printerStatusTimeoutRef.current);
  488. }
  489. if (wsRef.current) {
  490. wsRef.current.close();
  491. }
  492. };
  493. }, [connect]);
  494. const sendMessage = useCallback((message: Record<string, unknown>) => {
  495. if (wsRef.current?.readyState === WebSocket.OPEN) {
  496. wsRef.current.send(JSON.stringify(message));
  497. }
  498. }, []);
  499. return { isConnected, sendMessage };
  500. }