Files
AgentHub/client/src/hooks/useWebSocket.ts
T
nntrivi2001 8546748759 chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
  SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
  rule for sendFile/download, alerts ack route without inline regex),
  better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
  schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
  DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
  TypeScript 7, i18next/react-i18next/codemirror minors.

Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
  the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.

Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
  and gates the router by allowlist; /profiles* and /plugins/update-all stay
  admin-only. Member writes go through write-as, CLI calls through sudo as the
  member (never -y), reads through openSafe; cc_config_changed reaches only
  its owner. Not verified live on an S3 install yet.

Other
- Chat: an HTML file is an artifact only when written whole and not a project
  page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
2026-10-26 14:22:41 +07:00

198 lines
8.6 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 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> | undefined>(undefined);
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 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 };
}