Move all hand-synced cross-app wire contracts into one built workspace package, @boocode/contracts, consumed by server/web/coder/coder-web via workspace:* + a per-subpath exports map. The ws-frames and provider-config Zod schemas are schema-first (z.infer); MessageMetadata, ErrorReason, AgentSessionConfig, the provider snapshot types, and WorktreeRiskReport are each single-sourced. Deletes the byte-identical copies and their parity tests, fixes a live AgentSessionConfig drift (coder dead copy removed, unified to the web required/nullable shape), removes the dead pending_change WS arms in the fallback SPA, and inverts the build order (contracts builds first) across root build, Dockerfile, and the coder deploy docs. Reverses the shared-package decision declined in v2.5.12. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
96 lines
3.2 KiB
TypeScript
96 lines
3.2 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { WsFrameSchema } from '@boocode/contracts/ws-frames';
|
|
import { sessionEvents } from './sessionEvents';
|
|
import { createWsReconnectToast } from './wsReconnectToast';
|
|
|
|
const RECONNECT_INITIAL_MS = 1000;
|
|
const RECONNECT_MAX_MS = 30000;
|
|
|
|
export function useUserEvents(): void {
|
|
useEffect(() => {
|
|
let ws: WebSocket | null = null;
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let reconnectDelay = RECONNECT_INITIAL_MS;
|
|
let unmounted = false;
|
|
|
|
// v1.8.1: silent on the first disconnect; gray "reconnecting…" after 3
|
|
// fails / 15 s; red "connection lost" with a Retry Now action after 60 s.
|
|
const reconnectToast = createWsReconnectToast({
|
|
label: 'Live updates',
|
|
onRetryNow: () => {
|
|
if (reconnectTimer) {
|
|
clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
reconnectDelay = RECONNECT_INITIAL_MS;
|
|
connect();
|
|
}
|
|
},
|
|
});
|
|
|
|
const connect = () => {
|
|
if (unmounted) return;
|
|
const url = new URL('/api/ws/user', window.location.href);
|
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
ws = new WebSocket(url.toString());
|
|
|
|
ws.onopen = () => {
|
|
reconnectDelay = RECONNECT_INITIAL_MS;
|
|
reconnectToast.onConnected();
|
|
};
|
|
|
|
ws.onmessage = (ev) => {
|
|
// v1.13.11-a: Zod-validate every inbound frame. Fail-closed — invalid
|
|
// frames are logged and dropped instead of dispatched onto the
|
|
// sessionEvents bus where a stale or wrong shape would silently
|
|
// corrupt sidebar / chat state.
|
|
let raw: unknown;
|
|
try {
|
|
raw = JSON.parse(ev.data);
|
|
} catch (err) {
|
|
console.warn('useUserEvents: failed to parse frame', err);
|
|
return;
|
|
}
|
|
const validated = WsFrameSchema.safeParse(raw);
|
|
if (!validated.success) {
|
|
console.error('ws-frame-validation-failed (user channel)', {
|
|
frame_type: (raw as { type?: unknown })?.type,
|
|
errors: validated.error.flatten(),
|
|
});
|
|
return;
|
|
}
|
|
// Bridge cast: Zod's union is broader than SessionEvent (it includes
|
|
// per-session-channel frames too, which never arrive on the user
|
|
// channel). sessionEvents.emit only dispatches frames whose type
|
|
// appears in SessionEvent; the narrowing happens via the existing
|
|
// useSidebar.ts applyEvent switch.
|
|
sessionEvents.emit(
|
|
validated.data as unknown as import('./sessionEvents').SessionEvent,
|
|
);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
if (unmounted) return;
|
|
reconnectToast.onFailure();
|
|
const delay = reconnectDelay;
|
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
reconnectTimer = setTimeout(connect, delay);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
// close handler will trigger reconnect; best-effort, ignore failure
|
|
// because the socket may already be closing
|
|
try { ws?.close(); } catch {}
|
|
};
|
|
};
|
|
|
|
connect();
|
|
|
|
return () => {
|
|
unmounted = true;
|
|
reconnectToast.dispose();
|
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
if (ws) try { ws.close(); } catch {}
|
|
};
|
|
}, []);
|
|
}
|