Files
AgentHub/client/src/hooks/useWebSocket.ts
T
nntrivi2001 ca821008e2 perf: v2.5.10 — ready for 10+ concurrent users, leaner locally
Measured on a copy of a real 1.2 GB / 253K-event database, 10 simulated
browsers + 10 hooks/s for 90 s: event-loop delay p99 1239 ms → 32 ms (max
2.0 s → 94 ms), 266 failed requests → 0, 74 → 111 requests/s served, server
RSS 633 → 346 MB. Entry bundle 975 KB raw → 394 KB (106 KB brotli).

Server
- /api/analytics: the events-table scans run in the read worker
  (scoped-stats eventAggregates, read-pool job analyticsEvents); runRead takes
  maxAgeMs — analytics 10 s, /api/workflows 30 s — keyed by the args
- /api/workflows/skill-usage: +event_type and a tool_name range instead of
  LIKE, so it uses idx_events_tool (784 ms → a few ms)
- idx_events_session_type_tool (session_id, event_type, tool_name,
  created_at) replaces idx_events_session_type: covering reads for session
  stats, the hook dedupes and the import resume point (1.2 s → 20 ms); 64 MB
  cache_size and 256 MB mmap_size on both connections
- WAL: the server sets wal_autocheckpoint = 0 and checkpoints PASSIVE every
  DASHBOARD_WAL_CHECKPOINT_MS (5 s) from the read worker's own writable
  connection; a failed checkpoint restores the automatic one (hook COMMIT p95
  109 ms → 15 ms at 100 hooks/s)
- tmux: one list-sessions answers a whole GET /api/lanes and the heal beat
  (liveSessions), shared for 1 s and cleared by this process's new/kill —
  GET /api/run's sync spawns had blocked 60 s of a 90 s run; the heal beat
  skips tmux when nothing runs; attach options chained into 2 spawns
- hooks: TurnDuration/APIError deduped against one read per hook instead of a
  query per transcript entry (23 ms/hook on a 763-turn session); one lane
  scan per hook; hourly upserts skip rows that did not grow; frames are sent
  after the transaction commits (and after the pane binding); new_event
  carries its row id; a split pane's (slot 1–9) hooks no longer rebind the
  lane's session_id
- watchdog liveness probe runs ps asynchronously; transcript newline scan via
  indexOf; startup subagent backfill fingerprints the files it already checked
- pty attach: backpressure (pause past 1 MB unsent, resume under 256 KB)
- static: hashed assets served from their pre-compressed .br/.gz sibling

Client
- hidden tabs neither refetch on WS events nor poll (useLiveRefresh, panes,
  lane cards, git header, changes panel, settings); no per-tab hourly
  /updates/check (the server already checks)
- refetch storms throttled (workspace tree/session list 3 s, /api/run only on
  a run-key change, notification pane lookup only when shown); WS reconnect
  jitter; terminal retry backoff 1.5 → 30 s, stops on 1008
- heavier throttles for aggregates (15 s) and detail views; Monitor keepIfSame;
  GET in-flight dedupe in request()
- lazy Dashboard/History, xterm (with the first terminal), lane modals, Sagi
  (only while enabled); only the active locale loads; Workspace chunk
  prefetched on a /workspace deep link
- Sagi: no work while disabled, pupil via CSS variables, memo avatar
- build: .br/.gz siblings, sourcemap hidden; service worker no longer caches
  /assets/ (cache v3 drops the old one)

Tests: scripts/test-server.js drops the GIT_DIR-family variables a hook
exports, so a suite run from a linked worktree's pre-commit can no longer
commit into the real repository or set core.bare.

Docs: ARCHITECTURE, DATABASE, API, DEPLOYMENT, client README,
docs/plans/perf/README.md (audit, checklist, results).
2026-10-07 13:24:15 +07:00

198 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file useWebSocket.ts
* @description Defines a custom React hook for managing WebSocket connections in the agent dashboard application. The hook establishes a WebSocket connection to the server, handles incoming messages, manages connection status, and implements automatic reconnection logic. It provides a clean interface for components to receive real-time updates from the server and react to changes in connectivity.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** React hook: isolates side effects and subscription wiring so presentational components stay declarative.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `../lib/types`
* - `../lib/eventBus`
* - `../lib/api`
*
* ## Public surface
* - `useWebSocket` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **useWebSocket**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useEffect, useRef, useCallback, useState } from "react";
import type { WSMessage } from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { dashboardToken } from "../lib/api";
/** Callback invoked with each parsed {@link WSMessage} the socket receives. */
type MessageHandler = (msg: WSMessage) => void;
/**
* Owns the dashboard's single WebSocket connection: connects to `/ws` on the
* current origin (matching the page's http/https scheme to ws/wss and
* attaching the dashboard auth token when one is configured), forwards parsed
* messages to `onMessage` and to the shared {@link eventBus}, and
* auto-reconnects with capped exponential backoff on close - plus an
* immediate reconnect attempt on tab focus/network-online/visibility-change
* so the socket recovers quickly after a server restart or laptop sleep.
* Guards against React 18 StrictMode's mount→cleanup→remount cycle opening a
* duplicate socket (see the inline comment in `connect`).
* @param onMessage Called with every message parsed from the socket; the
* latest reference is used even across reconnects (no stale closures).
* @returns `{ connected }` - the current live connection state, for a status indicator.
*/
export function useWebSocket(onMessage: MessageHandler) {
const wsRef = useRef<WebSocket | null>(null);
const handlersRef = useRef<MessageHandler>(onMessage);
const [connected, setConnected] = useState(false);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
const mountedRef = useRef(true);
const reconnectAttempts = useRef(0);
handlersRef.current = onMessage;
const connect = useCallback(() => {
if (!mountedRef.current) return;
// Don't open a second socket if one is already alive or in flight.
// Without this, React 18 StrictMode (mount → cleanup → remount in dev)
// and the close→reconnect race could leave two sockets connected at the
// same time. Both would receive every server broadcast, producing
// duplicate stream_event deltas (doubled text, duplicate assistant
// bubbles, doubled rate_limit_event rows).
const existing = wsRef.current;
if (
existing &&
(existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)
) {
return;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host;
// Pass the optional dashboard token (GHSA-gr74-4xfh-6jw9) on the WS upgrade
// when one is configured; omitted entirely for the default loopback bind.
const token = dashboardToken();
const query = token ? `?token=${encodeURIComponent(token)}` : "";
const ws = new WebSocket(`${protocol}//${host}/ws${query}`);
ws.onopen = () => {
if (mountedRef.current) {
setConnected(true);
eventBus.setConnected(true);
reconnectAttempts.current = 0; // Reset on successful connection
}
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as WSMessage;
handlersRef.current(msg);
} catch {
// ignore malformed messages
}
};
ws.onclose = () => {
if (mountedRef.current) {
setConnected(false);
eventBus.setConnected(false);
// Exponential backoff capped low (0.5s, 1s, 2s, 3s max) so a server
// restart is picked up within a few seconds rather than after a long
// idle wait. The focus/online/visibility listeners below reconnect
// instantly on top of this for the common "user comes back" case.
// Jittered (×0.5–1.5) so a server restart is not met by every
// browser reconnecting in the same instant.
const delay =
Math.min(500 * Math.pow(2, reconnectAttempts.current), 3000) * (0.5 + Math.random());
reconnectAttempts.current++;
reconnectTimer.current = setTimeout(connect, delay);
}
};
ws.onerror = () => {
ws.close();
};
wsRef.current = ws;
}, []);
useEffect(() => {
mountedRef.current = true;
connect();
return () => {
mountedRef.current = false;
clearTimeout(reconnectTimer.current);
const ws = wsRef.current;
if (ws) {
// Detach handlers so a still-closing socket can't deliver a final
// onmessage / onclose into the bus after the component is gone.
ws.onopen = null;
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
ws.close();
wsRef.current = null;
}
};
}, [connect]);
// Reconnect *immediately* when the user/network signals the server is likely
// back: tab refocus, regained network, or page becoming visible again. This
// cancels any pending backoff timer and resets the attempt counter so we
// don't sit out a long delay - e.g. after the dashboard server restarts, the
// socket (and the Sagi eyes) recover the moment you look at the tab.
useEffect(() => {
const reconnectNow = () => {
if (!mountedRef.current) return;
const ws = wsRef.current;
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return; // already connected / connecting
}
clearTimeout(reconnectTimer.current);
reconnectAttempts.current = 0;
connect();
};
const onVisible = () => {
if (document.visibilityState === "visible") reconnectNow();
};
window.addEventListener("focus", reconnectNow);
window.addEventListener("online", reconnectNow);
document.addEventListener("visibilitychange", onVisible);
return () => {
window.removeEventListener("focus", reconnectNow);
window.removeEventListener("online", reconnectNow);
document.removeEventListener("visibilitychange", onVisible);
};
}, [connect]);
return { connected };
}