| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569 |
- import { useQueryClient } from '@tanstack/react-query';
- import { useCallback, useEffect, useRef, useState } from 'react';
- import { useToast } from '../contexts/ToastContext';
- import { useTranslation } from 'react-i18next';
- import { api, ApiError } from '../api/client';
- import { inventoryLocationsQueryKey } from '../utils/inventoryQueries';
- // The only auth-failure close code /api/v1/ws emits (websocket.py
- // _WS_CLOSE_UNAUTHORIZED). A 4401 means the ws-token was missing / invalid /
- // expired, or the caller lacks WEBSOCKET_CONNECT — none of which a reconnect can
- // fix without a fresh login (which remounts this provider anyway). Treat it as
- // terminal so we don't respawn the /auth/ws-token loop.
- const WS_CLOSE_UNAUTHORIZED = 4401;
- interface WebSocketMessage {
- type: string;
- printer_id?: number;
- data?: Record<string, unknown>;
- printer_name?: string;
- filename?: string;
- missing_slots?: Array<{ slot?: string }>;
- // Spool-assignment read-back verification (#2582).
- slot?: string;
- verified?: boolean;
- kprofile_applied?: boolean;
- saw_tray?: boolean;
- // Slicer Pipeline run events (#1425 PR C). ``run`` carries the full
- // PipelineRunResponse payload — typed loosely here so the WebSocket hook
- // doesn't pull the full client.ts types in.
- run?: { pipeline_id?: number | null };
- }
- export function useWebSocket() {
- const wsRef = useRef<WebSocket | null>(null);
- const reconnectTimeoutRef = useRef<number | null>(null);
- // Set true by the effect cleanup so a close event fired *during* unmount
- // can't schedule a reconnect after the provider is gone (the old code cleared
- // reconnectTimeoutRef, then .close() ran ws.onclose which set a *fresh*
- // timeout — a leaked reconnect that kept minting ws-tokens post-logout).
- const disposedRef = useRef(false);
- const queryClient = useQueryClient();
- const [isConnected, setIsConnected] = useState(false);
- const lastMissingSpoolWarningRef = useRef<Map<number, string>>(new Map());
- const { showToast } = useToast();
- const { t } = useTranslation();
- // Debounce invalidations to prevent rapid re-render cascades
- const pendingInvalidations = useRef<Set<string>>(new Set());
- const invalidationTimeoutRef = useRef<number | null>(null);
- // Throttle printer status updates to prevent freeze during rapid messages
- const pendingPrinterStatus = useRef<Map<number, Record<string, unknown>>>(new Map());
- const printerStatusTimeoutRef = useRef<number | null>(null);
- // Throttle message processing to prevent browser freeze
- const messageQueueRef = useRef<WebSocketMessage[]>([]);
- const processingRef = useRef(false);
- // Use ref for handleMessage to avoid stale closure in connect
- const handleMessageRef = useRef<(message: WebSocketMessage) => void>(() => {});
- // Process message queue with throttling to prevent UI freeze
- const processMessageQueue = useCallback(() => {
- if (processingRef.current || messageQueueRef.current.length === 0) {
- return;
- }
- processingRef.current = true;
- const processNext = () => {
- const message = messageQueueRef.current.shift();
- if (message) {
- handleMessageRef.current(message);
- // Small delay between messages to prevent overwhelming the browser.
- // This setTimeout is the yield; a requestAnimationFrame around the
- // handler used to sit here too, which stalled the whole queue in a
- // hidden tab (see the note on the rAF removal below).
- if (messageQueueRef.current.length > 0) {
- setTimeout(processNext, 16); // ~60fps
- } else {
- processingRef.current = false;
- }
- } else {
- processingRef.current = false;
- }
- };
- processNext();
- }, []);
- const connect = useCallback(async () => {
- if (disposedRef.current || wsRef.current?.readyState === WebSocket.OPEN) {
- return;
- }
- // GHSA-r2qv follow-up: when auth is enabled, /ws now requires a token
- // minted by POST /api/v1/auth/ws-token. We use the shared ``api.request``
- // helper (via ``api.getWebSocketToken``) so the JWT Authorization header
- // is attached — a raw ``fetch()`` with ``credentials: 'include'`` would
- // miss it (Bambuddy uses Bearer tokens, not cookies, for JWT auth).
- // Auth-disabled deployments accept connections without a token.
- let token: string | undefined;
- try {
- const resp = await api.getWebSocketToken();
- token = resp.token;
- } catch (err) {
- // A 401/403 from the token mint is an AUTH decision, not a transient
- // blip, so retrying is pointless and hammers /auth/ws-token every 3s:
- // 401 — the JWT expired. ``request()`` already cleared it and
- // dispatched ``auth:expired``, so the route guard is redirecting
- // to /login and this provider is about to unmount.
- // 403 — the user is validly logged in but their group lacks
- // WEBSOCKET_CONNECT. They stay logged in; live updates simply
- // degrade to the REST polling the query cache already does.
- // Either way: do NOT open a tokenless socket (the server just closes it
- // 4401) and do NOT reconnect. The old catch-all fell through to a
- // tokenless socket whose 4401 close rescheduled connect() forever. A
- // network/5xx error is not auth — fall through and let the socket + its
- // reconnect loop handle it (auth-disabled deployments also land here with
- // no token and connect fine).
- const status = err instanceof ApiError ? err.status : 0;
- if (status === 401 || status === 403) {
- return;
- }
- }
- if (disposedRef.current) {
- return;
- }
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
- const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
- const ws = new WebSocket(wsUrl);
- let pingInterval: number | null = null;
- ws.onopen = () => {
- if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Connected');
- setIsConnected(true);
- // Start ping interval
- pingInterval = window.setInterval(() => {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: 'ping' }));
- }
- }, 30000);
- };
- ws.onmessage = (event) => {
- try {
- const message: WebSocketMessage = JSON.parse(event.data);
- // Handle printer_status directly (already throttled) to avoid queue delays
- // This prevents the "timelapse" effect where status updates are applied slowly
- if (message.type === 'printer_status' && message.printer_id !== undefined && message.data) {
- handleMessageRef.current(message);
- } else {
- // Queue other messages for throttled processing
- messageQueueRef.current.push(message);
- processMessageQueue();
- }
- } catch {
- // Ignore parse errors
- }
- };
- ws.onclose = (event) => {
- if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Closed', event.code, event.reason);
- if (pingInterval) {
- clearInterval(pingInterval);
- pingInterval = null;
- }
- setIsConnected(false);
- wsRef.current = null;
- // Don't reconnect after an auth rejection (4401) or once the provider has
- // unmounted — both would just respawn the /auth/ws-token loop. A 4401 is
- // terminal (needs a fresh login, which remounts us); every other close
- // code is treated as a network drop and gets the 3s reconnect.
- if (disposedRef.current || event.code === WS_CLOSE_UNAUTHORIZED) {
- return;
- }
- // Reconnect after 3 seconds
- reconnectTimeoutRef.current = window.setTimeout(() => {
- connect();
- }, 3000);
- };
- ws.onerror = (error) => {
- if (import.meta.env.MODE !== 'test') console.error('[WebSocket] Error', error);
- ws.close();
- };
- wsRef.current = ws;
- }, [processMessageQueue]);
- // Write every pending printer status into the query cache.
- //
- // Extracted so the hidden-tab path below can run it inline: both paths share
- // this one body, so the merge semantics cannot drift apart. Cancels any
- // scheduled coalescing timer, since everything it was going to write has
- // just been written and re-running it would re-apply stale data over newer.
- const flushPrinterStatus = useCallback(() => {
- if (printerStatusTimeoutRef.current) {
- clearTimeout(printerStatusTimeoutRef.current);
- printerStatusTimeoutRef.current = null;
- }
- const updates = new Map(pendingPrinterStatus.current);
- pendingPrinterStatus.current.clear();
- updates.forEach((statusData, id) => {
- queryClient.setQueryData(['printerStatus', id], (old: Record<string, unknown> | undefined) => {
- const merged = { ...old, ...statusData };
- if (merged.wifi_signal == null && old?.wifi_signal != null) {
- merged.wifi_signal = old.wifi_signal;
- }
- return merged;
- });
- });
- }, [queryClient]);
- // Printer status update — coalesced while the tab is visible, written
- // straight through while it is not.
- //
- // #2754 (reporter @mic4rd), in two stages. First, these writes ran inside a
- // requestAnimationFrame: a hidden tab gets no rendering opportunities, so
- // the browser *holds* queued frame callbacks rather than throttling them,
- // and nothing reached the cache until the tab was shown again. Removing the
- // frame callback fixed that total stall but not the report, because a second
- // timer-shaped dependency was left behind — this 100ms coalescing window.
- //
- // Browsers clamp timers in a hidden page to at best once a second, and drop
- // pages hidden for more than five minutes to roughly one wake-up a minute.
- // The reporter saw a tab title stuck at 2% beside a page at 40%.
- //
- // The coalescing exists to stop rapid messages triggering a render cascade.
- // A hidden tab is not painting, so there is no cascade to prevent there —
- // the timer is pure cost, and it is exactly the thing being throttled. So
- // when hidden, skip it and write immediately.
- //
- // Note "hidden", not "unfocused": on Windows a fully-occluded window reports
- // visibilityState 'hidden' too, which is why the reporter saw this from
- // merely clicking away rather than only from switching tabs.
- const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
- // Merge with any pending data for this printer
- const existing = pendingPrinterStatus.current.get(printerId) || {};
- pendingPrinterStatus.current.set(printerId, { ...existing, ...data });
- if (document.hidden) {
- flushPrinterStatus();
- return;
- }
- // Schedule update if not already scheduled
- if (!printerStatusTimeoutRef.current) {
- printerStatusTimeoutRef.current = window.setTimeout(flushPrinterStatus, 100);
- }
- }, [flushPrinterStatus]);
- // Debounced invalidation helper - coalesces multiple rapid invalidations
- const debouncedInvalidate = useCallback((queryKey: string) => {
- pendingInvalidations.current.add(queryKey);
- // Clear existing timeout
- if (invalidationTimeoutRef.current) {
- clearTimeout(invalidationTimeoutRef.current);
- }
- // Schedule invalidation after a delay (3s to prevent browser freeze on print completion)
- invalidationTimeoutRef.current = window.setTimeout(() => {
- const keys = Array.from(pendingInvalidations.current);
- pendingInvalidations.current.clear();
- invalidationTimeoutRef.current = null;
- // Invalidate queries one at a time with delays to prevent freeze.
- // The 500ms stagger is the anti-cascade measure; a frame callback around
- // each invalidation used to sit inside it and stalled these refreshes in
- // a hidden tab for the same reason as the status writes above (#2754).
- let delay = 0;
- keys.forEach((key) => {
- setTimeout(() => {
- queryClient.invalidateQueries({ queryKey: [key] });
- }, delay);
- delay += 500; // 500ms between each invalidation
- });
- }, 3000);
- }, [queryClient]);
- const handleMessage = useCallback((message: WebSocketMessage) => {
- switch (message.type) {
- case 'printer_status':
- if (message.printer_id !== undefined && message.data) {
- throttledPrinterStatusUpdate(message.printer_id, message.data);
- }
- break;
- case 'print_start':
- // Refetch printer status immediately when print starts to get printable_objects_count
- if (message.printer_id !== undefined) {
- queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] });
- }
- break;
- case 'missing_spool_assignment': {
- if (message.printer_id === undefined || !Array.isArray(message.missing_slots)) {
- break;
- }
- const missingSlotLabels = message.missing_slots
- .map((slot) => (slot && typeof slot.slot === 'string' ? slot.slot : 'Unknown'))
- .filter((slot) => slot.length > 0);
- if (missingSlotLabels.length === 0) {
- lastMissingSpoolWarningRef.current.delete(message.printer_id);
- break;
- }
- const signature = missingSlotLabels.join('|');
- if (lastMissingSpoolWarningRef.current.get(message.printer_id) === signature) {
- break;
- }
- lastMissingSpoolWarningRef.current.set(message.printer_id, signature);
- const printerName = message.printer_name || `Printer ${message.printer_id}`;
- const toastMsg = t('printers.toast.missingSpoolAssignment', {
- printer: printerName,
- slots: missingSlotLabels.join(', '),
- });
- showToast(toastMsg, 'warning');
- break;
- }
- case 'print_complete':
- // Don't invalidate printerStatus here - it causes re-render cascade and browser freeze
- // The printer_status websocket messages will naturally update the status
- debouncedInvalidate('archives');
- debouncedInvalidate('archiveStats');
- break;
- case 'kill_switch_triggered': {
- const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
- const filename = message.filename || t('common.unknown');
- showToast(t('printers.toast.killSwitchTriggered', { printer, filename }), 'error');
- break;
- }
- case 'billing_charge_failed': {
- const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
- const filename = message.filename || t('common.unknown');
- showToast(t('printers.toast.billingChargeFailed', { printer, filename }), 'error');
- break;
- }
- case 'archive_created':
- debouncedInvalidate('archives');
- debouncedInvalidate('archiveStats');
- break;
- case 'archive_updated':
- debouncedInvalidate('archives');
- break;
- case 'pong':
- // Keepalive response, ignore
- break;
- case 'plate_not_empty':
- // Plate detection found objects - print was paused
- // Dispatch event for toast notification
- window.dispatchEvent(new CustomEvent('plate-not-empty', {
- detail: {
- printer_id: message.printer_id,
- printer_name: (message as unknown as { printer_name?: string }).printer_name,
- message: (message as unknown as { message?: string }).message,
- }
- }));
- break;
- case 'inventory_changed':
- // Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
- debouncedInvalidate('inventory-spools');
- debouncedInvalidate('spoolman-inventory-spools');
- debouncedInvalidate(inventoryLocationsQueryKey[0]);
- break;
- case 'spool_assignment_changed':
- // Spool assigned/unassigned - refresh assignment data across all tabs
- debouncedInvalidate('spool-assignments');
- debouncedInvalidate('slotPresets');
- break;
- case 'spool_assignment_verified': {
- // #2582: the backend read the AMS telemetry back after an assignment
- // and either confirmed the tray accepted it or timed out. Toast the
- // outcome so the AMS→Studio hand-off is no longer silent.
- // Backend always supplies printer_name (falls back to "Printer <id>"),
- // so the '||' here only guards a malformed payload.
- const printer = message.printer_name || 'Printer';
- const slot = message.slot || '?';
- if (message.verified) {
- if (message.kprofile_applied === false) {
- // Filament id landed but the K-profile (cali_idx) did not — the
- // exact "loaded but no flow profile" case the reporter chased.
- showToast(
- t('printers.toast.assignmentVerifiedNoKprofile', { slot, printer }),
- 'warning'
- );
- } else {
- showToast(t('printers.toast.assignmentVerified', { slot, printer }), 'success');
- }
- } else {
- showToast(t('printers.toast.assignmentNotConfirmed', { slot, printer }), 'warning');
- }
- break;
- }
- case 'spool_auto_assigned':
- // RFID tag matched - refresh inventory and assignment data
- debouncedInvalidate('inventory-spools');
- debouncedInvalidate('spool-assignments');
- break;
- case 'spool_usage_logged':
- // Filament consumption recorded - refresh spool data
- debouncedInvalidate('inventory-spools');
- break;
- case 'unknown_tag': {
- // Unknown RFID tag detected — dispatch event for UI. The backend
- // ships the slot's current tray data alongside the event so
- // consumers don't have to look it up from the (frequently stale)
- // cached printerStatus query.
- const m = message as unknown as {
- printer_id?: number;
- ams_id?: number;
- tray_id?: number;
- tag_uid?: string;
- tray_uuid?: string;
- tray_type?: string | null;
- tray_color?: string | null;
- tray_sub_brands?: string | null;
- tray_count?: number | null;
- };
- window.dispatchEvent(new CustomEvent('unknown-tag', {
- detail: {
- printer_id: m.printer_id,
- ams_id: m.ams_id,
- tray_id: m.tray_id,
- tag_uid: m.tag_uid,
- tray_uuid: m.tray_uuid,
- tray_type: m.tray_type,
- tray_color: m.tray_color,
- tray_sub_brands: m.tray_sub_brands,
- tray_count: m.tray_count,
- }
- }));
- break;
- }
- case 'spoolbuddy_weight':
- window.dispatchEvent(new CustomEvent('spoolbuddy-weight', { detail: message }));
- break;
- case 'spoolbuddy_tag_matched':
- window.dispatchEvent(new CustomEvent('spoolbuddy-tag-matched', { detail: message }));
- debouncedInvalidate('inventory-spools');
- break;
- case 'spoolbuddy_unknown_tag':
- window.dispatchEvent(new CustomEvent('spoolbuddy-unknown-tag', { detail: message }));
- break;
- case 'spoolbuddy_tag_removed':
- window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', { detail: message }));
- break;
- case 'spoolbuddy_tag_written':
- window.dispatchEvent(new CustomEvent('spoolbuddy-tag-written', { detail: message }));
- debouncedInvalidate('inventory-spools');
- break;
- case 'spoolbuddy_tag_write_failed':
- window.dispatchEvent(new CustomEvent('spoolbuddy-tag-write-failed', { detail: message }));
- break;
- case 'spoolbuddy_online':
- window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
- debouncedInvalidate('spoolbuddy-devices');
- debouncedInvalidate('spoolbuddy-update-check');
- break;
- case 'spoolbuddy_offline':
- window.dispatchEvent(new CustomEvent('spoolbuddy-offline', { detail: message }));
- debouncedInvalidate('spoolbuddy-devices');
- break;
- case 'spoolbuddy_update':
- debouncedInvalidate('spoolbuddy-devices');
- debouncedInvalidate('spoolbuddy-update-check');
- break;
- // Dispatch toast lifecycle (#1625 follow-up — restored the upload
- // progress UI that the scheduler unification removed). Four backend
- // event types collapse to one frontend channel. No
- // `queue_item_queued` (the toast must wait for the upload to
- // actually start) and no `queue_item_dispatched` (the legacy
- // background-dispatch flow kept status='processing' from upload
- // start until printer ack — the "Awaiting printer…" subtitle is
- // derived from upload_progress_pct >= 99.9, not from a separate
- // event).
- case 'queue_item_uploading':
- case 'queue_item_upload_progress':
- case 'queue_item_acked':
- case 'queue_item_failed':
- window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail: message }));
- break;
- // Slicer Pipeline runs (#1425 PR C). State transitions on the run
- // refresh both the dashboard list AND the per-pipeline "Last run"
- // chip in Settings → Pipelines.
- case 'pipeline_run_updated':
- queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
- if (message.run?.pipeline_id) {
- queryClient.invalidateQueries({ queryKey: ['pipeline-runs', message.run.pipeline_id] });
- }
- break;
- }
- }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
- // Keep the ref updated with latest handleMessage
- useEffect(() => {
- handleMessageRef.current = handleMessage;
- }, [handleMessage]);
- useEffect(() => {
- // connect() is async after the GHSA-r2qv fix (mints a ws-token first).
- // Fire-and-forget at mount; the inner reconnect loop also calls
- // connect() in the ws.onclose handler.
- disposedRef.current = false;
- void connect();
- return () => {
- // Mark disposed BEFORE closing so the ws.onclose triggered by close()
- // sees it and won't schedule a post-unmount reconnect.
- disposedRef.current = true;
- if (reconnectTimeoutRef.current) {
- clearTimeout(reconnectTimeoutRef.current);
- }
- if (invalidationTimeoutRef.current) {
- clearTimeout(invalidationTimeoutRef.current);
- }
- if (printerStatusTimeoutRef.current) {
- clearTimeout(printerStatusTimeoutRef.current);
- }
- if (wsRef.current) {
- wsRef.current.close();
- }
- };
- }, [connect]);
- const sendMessage = useCallback((message: Record<string, unknown>) => {
- if (wsRef.current?.readyState === WebSocket.OPEN) {
- wsRef.current.send(JSON.stringify(message));
- }
- }, []);
- return { isConnected, sendMessage };
- }
|