|
|
@@ -2,9 +2,16 @@ 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 } from '../api/client';
|
|
|
+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;
|
|
|
@@ -20,6 +27,11 @@ interface WebSocketMessage {
|
|
|
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());
|
|
|
@@ -71,7 +83,7 @@ export function useWebSocket() {
|
|
|
}, []);
|
|
|
|
|
|
const connect = useCallback(async () => {
|
|
|
- if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
|
+ if (disposedRef.current || wsRef.current?.readyState === WebSocket.OPEN) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
@@ -80,19 +92,34 @@ export function useWebSocket() {
|
|
|
// 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, so we
|
|
|
- // treat a missing/failed token mint as non-fatal here and let the
|
|
|
- // WebSocket close with code 4401 if the server actually rejects us.
|
|
|
+ // Auth-disabled deployments accept connections without a token.
|
|
|
let token: string | undefined;
|
|
|
try {
|
|
|
const resp = await api.getWebSocketToken();
|
|
|
token = resp.token;
|
|
|
- } catch {
|
|
|
- // Token mint failed — most likely auth is disabled (no JWT to attach,
|
|
|
- // 401 response) or the user isn't authenticated yet. Fall through and
|
|
|
- // try the WebSocket anyway. Auth-disabled deployments succeed;
|
|
|
- // auth-enabled deployments close with 4401 and the reconnect loop
|
|
|
- // kicks in once the user logs in.
|
|
|
+ } 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:';
|
|
|
@@ -140,6 +167,14 @@ export function useWebSocket() {
|
|
|
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();
|
|
|
@@ -424,9 +459,13 @@ export function useWebSocket() {
|
|
|
// 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);
|
|
|
}
|