fix: Vite HMR WebSocket for reverse proxy + WS StrictMode guard

When running behind a reverse proxy (e.g. Caddy), the live-events
WebSocket would fail to connect because it constructed the URL from
window.location without accounting for proxy routing.

Also fixes React StrictMode double-invoke of WebSocket connections
by deferring the connect call via a cleanup guard.

- Replace deprecated apple-mobile-web-app-capable meta tag
- Guard WS connect with mounted flag to prevent StrictMode double-open
- Use protocol-relative WebSocket URL derivation for proxy compatibility
This commit is contained in:
Genie 2026-03-15 20:13:09 -03:00
parent eb113bff3d
commit 59b1d1551a
2 changed files with 75 additions and 23 deletions

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#18181b" /> <meta name="theme-color" content="#18181b" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Paperclip" /> <meta name="apple-mobile-web-app-title" content="Paperclip" />
<title>Paperclip</title> <title>Paperclip</title>

View file

@ -511,24 +511,39 @@ function handleLiveEvent(
} }
export function LiveUpdatesProvider({ children }: { children: ReactNode }) { export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { pushToast } = useToast(); const { pushToast } = useToast();
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 }); const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
const { data: session } = useQuery({ const { data: session, status: sessionStatus } = useQuery({
queryKey: queryKeys.auth.session, queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(), queryFn: () => authApi.getSession(),
retry: false, retry: false,
}); });
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const socketAuthKey = session?.session?.id ?? currentUserId ?? "signed_out";
const liveCompanyId = selectedCompany?.id === selectedCompanyId ? selectedCompanyId : null;
const canConnectSocket = sessionStatus === "success" && session !== null && liveCompanyId !== null;
const currentActorRef = useRef<{ userId: string | null; agentId: string | null }>({
userId: currentUserId,
agentId: null,
});
useEffect(() => { useEffect(() => {
if (!selectedCompanyId) return; currentActorRef.current = {
userId: currentUserId,
agentId: null,
};
}, [currentUserId]);
useEffect(() => {
if (!canConnectSocket || !liveCompanyId) return;
let closed = false; let closed = false;
let reconnectAttempt = 0; let reconnectAttempt = 0;
let reconnectTimer: number | null = null; let reconnectTimer: number | null = null;
let socket: WebSocket | null = null; let socket: WebSocket | null = null;
const noop = () => undefined;
const clearReconnect = () => { const clearReconnect = () => {
if (reconnectTimer !== null) { if (reconnectTimer !== null) {
@ -537,6 +552,35 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
} }
}; };
const closeSocketQuietly = (target: WebSocket | null, reason: string) => {
if (!target) return;
if (target.readyState === WebSocket.CONNECTING) {
// Let the handshake complete and then close. Calling close() while the
// socket is still CONNECTING is what triggers the noisy browser error.
target.onopen = () => {
target.onopen = null;
target.onmessage = null;
target.onerror = null;
target.onclose = null;
target.close(1000, reason);
};
target.onmessage = null;
target.onerror = noop;
target.onclose = null;
return;
}
target.onopen = null;
target.onmessage = null;
target.onerror = null;
target.onclose = null;
if (target.readyState === WebSocket.OPEN) {
target.close(1000, reason);
}
};
const scheduleReconnect = () => { const scheduleReconnect = () => {
if (closed) return; if (closed) return;
reconnectAttempt += 1; reconnectAttempt += 1;
@ -550,55 +594,63 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const connect = () => { const connect = () => {
if (closed) return; if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(selectedCompanyId)}/events/ws`; const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`;
socket = new WebSocket(url); const nextSocket = new WebSocket(url);
socket = nextSocket;
socket.onopen = () => { nextSocket.onopen = () => {
if (closed || socket !== nextSocket) {
closeSocketQuietly(nextSocket, "stale_connection");
return;
}
if (reconnectAttempt > 0) { if (reconnectAttempt > 0) {
gateRef.current.suppressUntil = Date.now() + RECONNECT_SUPPRESS_MS; gateRef.current.suppressUntil = Date.now() + RECONNECT_SUPPRESS_MS;
} }
reconnectAttempt = 0; reconnectAttempt = 0;
}; };
socket.onmessage = (message) => { nextSocket.onmessage = (message) => {
const raw = typeof message.data === "string" ? message.data : ""; const raw = typeof message.data === "string" ? message.data : "";
if (!raw) return; if (!raw) return;
try { try {
const parsed = JSON.parse(raw) as LiveEvent; const parsed = JSON.parse(raw) as LiveEvent;
handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current, { handleLiveEvent(queryClient, liveCompanyId, parsed, pushToast, gateRef.current, {
userId: currentUserId, userId: currentActorRef.current.userId,
agentId: null, agentId: currentActorRef.current.agentId,
}); });
} catch { } catch {
// Ignore non-JSON payloads. // Ignore non-JSON payloads.
} }
}; };
socket.onerror = () => { nextSocket.onerror = () => {
socket?.close(); // Wait for onclose to drive the reconnect. Self-closing here is what
// produces the "closed before connection established" browser noise.
}; };
socket.onclose = () => { nextSocket.onclose = () => {
if (socket !== nextSocket) return;
socket = null;
if (closed) return; if (closed) return;
scheduleReconnect(); scheduleReconnect();
}; };
}; };
connect(); // Delay initial connect slightly so React StrictMode's double-invoke
// cleanup fires before the WebSocket is created, avoiding the
// "WebSocket closed before connection established" dev-mode error.
const connectTimer = window.setTimeout(connect, 0);
return () => { return () => {
closed = true; closed = true;
window.clearTimeout(connectTimer);
clearReconnect(); clearReconnect();
if (socket) { const activeSocket = socket;
socket.onopen = null; socket = null;
socket.onmessage = null; closeSocketQuietly(activeSocket, "provider_unmount");
socket.onerror = null;
socket.onclose = null;
socket.close(1000, "provider_unmount");
}
}; };
}, [queryClient, selectedCompanyId, pushToast, currentUserId]); }, [queryClient, liveCompanyId, pushToast, canConnectSocket, socketAuthKey]);
return <>{children}</>; return <>{children}</>;
} }