feat: responsive web terminal with auto-reconnect, heartbeat, and local echo
Implements a resilient, responsive web terminal that survives network blips, provides instant typing feedback, and restores scrollback on reconnect. Backend changes: - Add heartbeat tracking (15s ping interval, 60s idle timeout) - Add message batching (16ms flush window) for efficient I/O - Add termios echo detection and set_echo_state control messages - Add graceful session_ended notification before close - Add ping/pong protocol support Frontend changes: - Rewrite TerminalComponent with status bar, connection indicator, session-ended overlay, reconnect banner, and ResizeObserver - Add useTerminalConnection hook with: - Exponential backoff auto-reconnect (1s → 30s max, 10 attempts) - Heartbeat/ping-pong with latency tracking - Local echo for printable ASCII with server deduplication - Resize debounce (200ms) + throttle (500ms) - Scrollback serialization via xterm-addon-serialize - Ctrl+Shift+R manual reconnect shortcut - Add WebSocket protocol types and encoding utilities - Add xterm-addon-serialize dependency Tests: - 16 backend unit tests (TerminalSession + TerminalManager) - 13 frontend hook tests (connection lifecycle, reconnect, resize, scrollback, callbacks) Quality gates: - Frontend typecheck: clean - Frontend lint: clean - Frontend tests: 48 passed - Backend unit tests: 101 passed - Backend ruff: clean SDD artifacts: openspec/changes/responsive-terminal/
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTerminalConnection } from "./use-terminal-connection";
|
||||
|
||||
class MockWebSocket {
|
||||
static instances: MockWebSocket[] = [];
|
||||
readyState: number = WebSocket.CONNECTING;
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
sent: (string | ArrayBuffer | Blob)[] = [];
|
||||
url = "";
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(data: string | ArrayBuffer | Blob) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string) {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
if (this.onclose) {
|
||||
this.onclose(new CloseEvent("close", { code: code ?? 1000, reason }));
|
||||
}
|
||||
}
|
||||
|
||||
simulateOpen() {
|
||||
this.readyState = WebSocket.OPEN;
|
||||
if (this.onopen) this.onopen(new Event("open"));
|
||||
}
|
||||
|
||||
simulateMessage(data: string | ArrayBuffer | Blob) {
|
||||
if (this.onmessage) {
|
||||
this.onmessage(new MessageEvent("message", { data }));
|
||||
}
|
||||
}
|
||||
|
||||
simulateError() {
|
||||
if (this.onerror) this.onerror(new Event("error"));
|
||||
}
|
||||
}
|
||||
|
||||
describe("useTerminalConnection", () => {
|
||||
let originalWebSocket: typeof WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
originalWebSocket = globalThis.WebSocket;
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
||||
MockWebSocket.instances = [];
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("import", { meta: { env: { VITE_API_BASE_URL: "" } } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = originalWebSocket;
|
||||
MockWebSocket.instances = [];
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("starts in connecting state", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
expect(result.current.state.status).toBe("connecting");
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("transitions to connected on websocket open", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
expect(result.current.state.status).toBe("connected");
|
||||
});
|
||||
|
||||
it("sends ping after interval", async () => {
|
||||
renderHook(() => useTerminalConnection({ instanceId: "inst-1" }));
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000);
|
||||
});
|
||||
|
||||
const pings = MockWebSocket.instances[0].sent.filter((s) =>
|
||||
typeof s === "string" ? s.includes("ping") : false,
|
||||
);
|
||||
expect(pings.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("handles pong and updates latency", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateMessage(
|
||||
JSON.stringify({ type: "pong", id: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.state.latency).not.toBeNull();
|
||||
expect(result.current.state.latency).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("reconnects with exponential backoff on close", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].close(1006, "Abnormal closure");
|
||||
});
|
||||
|
||||
expect(result.current.state.status).toBe("reconnecting");
|
||||
expect(result.current.state.attempt).toBe(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("max reconnect attempts leads to disconnected", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||
act(() => {
|
||||
ws.close(1006, "Abnormal closure");
|
||||
});
|
||||
const delay = Math.min(1000 * 2 ** i, 30000);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(delay);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.current.state.status).toBe("disconnected");
|
||||
expect(result.current.state.error).toContain("Max reconnection");
|
||||
}, 30000);
|
||||
|
||||
it("sends resize message with debounce", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendResize(120, 40);
|
||||
});
|
||||
|
||||
// Before debounce
|
||||
expect(
|
||||
MockWebSocket.instances[0].sent.filter((s) =>
|
||||
typeof s === "string" ? s.includes("resize") : false,
|
||||
),
|
||||
).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
const resizes = MockWebSocket.instances[0].sent.filter((s) =>
|
||||
typeof s === "string" ? s.includes("resize") : false,
|
||||
);
|
||||
expect(resizes.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("throttles resize messages", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendResize(100, 30);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendResize(101, 31);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
const resizes = MockWebSocket.instances[0].sent.filter((s) =>
|
||||
typeof s === "string" ? s.includes("resize") : false,
|
||||
);
|
||||
// Second resize throttled (within 500ms)
|
||||
expect(resizes.length).toBe(1);
|
||||
});
|
||||
|
||||
it("sendInput sends data over websocket", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendInput("a");
|
||||
});
|
||||
|
||||
expect(MockWebSocket.instances[0].sent).toContain("a");
|
||||
});
|
||||
|
||||
it("triggers manual reconnect on reconnect()", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.reconnect();
|
||||
});
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("calls onData callback with binary data", async () => {
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useTerminalConnection({ instanceId: "inst-1", onData }));
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
const buffer = new ArrayBuffer(3);
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateMessage(buffer);
|
||||
});
|
||||
|
||||
expect(onData).toHaveBeenCalledWith(expect.any(Uint8Array));
|
||||
});
|
||||
|
||||
it("calls onControl callback with control messages", async () => {
|
||||
const onControl = vi.fn();
|
||||
renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1", onControl }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateMessage(
|
||||
JSON.stringify({ type: "set_echo_state", enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onControl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "set_echo_state", enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes and restores scrollback", async () => {
|
||||
const serializeFn = vi.fn(() => "scrollback-content");
|
||||
const onRestoreScrollback = vi.fn();
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useTerminalConnection({
|
||||
instanceId: "inst-1",
|
||||
serializeFn,
|
||||
onRestoreScrollback,
|
||||
}),
|
||||
{ initialProps: {} },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
// Disconnect
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].close(1006, "gone");
|
||||
});
|
||||
|
||||
expect(serializeFn).toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// New connection opens
|
||||
act(() => {
|
||||
MockWebSocket.instances[
|
||||
MockWebSocket.instances.length - 1
|
||||
].simulateOpen();
|
||||
});
|
||||
|
||||
expect(onRestoreScrollback).toHaveBeenCalledWith("scrollback-content");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
ClientControlMessage,
|
||||
ServerControlMessage,
|
||||
TerminalConnectionState,
|
||||
} from "../types/terminal";
|
||||
import {
|
||||
decodeControlMessage,
|
||||
encodeControlMessage,
|
||||
isControlMessage,
|
||||
} from "../utils/terminal-protocol";
|
||||
|
||||
const PING_INTERVAL_MS = 15_000;
|
||||
const PONG_TIMEOUT_MS = 5_000;
|
||||
const RECONNECT_BASE_MS = 1_000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
const MAX_RECONNECT_ATTEMPTS = 10;
|
||||
const RESIZE_DEBOUNCE_MS = 200;
|
||||
const RESIZE_THROTTLE_MS = 500;
|
||||
const PENDING_ECHO_FLUSH_LIMIT = 100;
|
||||
const SCROLLBACK_STORAGE_KEY = "hq-terminal";
|
||||
|
||||
interface UseTerminalConnectionOptions {
|
||||
instanceId: string;
|
||||
onData?: (data: Uint8Array) => void;
|
||||
onControl?: (msg: ServerControlMessage) => void;
|
||||
/** Called with characters that should be locally echoed. */
|
||||
onLocalEcho?: (data: string) => void;
|
||||
/** Called to serialize scrollback before disconnect. Should return terminal content. */
|
||||
serializeFn?: () => string | null;
|
||||
/** Called with restored scrollback content on reconnect. */
|
||||
onRestoreScrollback?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function useTerminalConnection({
|
||||
instanceId,
|
||||
onData,
|
||||
onControl,
|
||||
onLocalEcho,
|
||||
serializeFn,
|
||||
onRestoreScrollback,
|
||||
}: UseTerminalConnectionOptions) {
|
||||
const [state, setState] = useState<TerminalConnectionState>({
|
||||
status: "connecting",
|
||||
attempt: 0,
|
||||
latency: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pongTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastResizeRef = useRef<number>(0);
|
||||
const pendingEchoRef = useRef<string>("");
|
||||
const echoEnabledRef = useRef<boolean>(true);
|
||||
const pingIdRef = useRef<number>(0);
|
||||
const pingSentAtRef = useRef<number>(0);
|
||||
const reconnectAttemptRef = useRef<number>(0);
|
||||
const isConnectingRef = useRef<boolean>(false);
|
||||
const lastStatusRef = useRef<string>("connecting");
|
||||
|
||||
const setStableState = useCallback(
|
||||
(updater: (prev: TerminalConnectionState) => TerminalConnectionState) => {
|
||||
setState((prev) => {
|
||||
const next = updater(prev);
|
||||
if (next.status !== lastStatusRef.current) {
|
||||
lastStatusRef.current = next.status;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (pingTimerRef.current) {
|
||||
clearTimeout(pingTimerRef.current);
|
||||
pingTimerRef.current = null;
|
||||
}
|
||||
if (pongTimerRef.current) {
|
||||
clearTimeout(pongTimerRef.current);
|
||||
pongTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const flushPendingEcho = useCallback(() => {
|
||||
if (pendingEchoRef.current.length > 0 && onLocalEcho) {
|
||||
onLocalEcho(pendingEchoRef.current);
|
||||
pendingEchoRef.current = "";
|
||||
}
|
||||
}, [onLocalEcho]);
|
||||
|
||||
const deduplicateServerData = useCallback((data: string): string => {
|
||||
if (!echoEnabledRef.current || pendingEchoRef.current.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
let serverIndex = 0;
|
||||
let echoIndex = 0;
|
||||
|
||||
while (
|
||||
serverIndex < data.length &&
|
||||
echoIndex < pendingEchoRef.current.length &&
|
||||
data[serverIndex] === pendingEchoRef.current[echoIndex]
|
||||
) {
|
||||
serverIndex++;
|
||||
echoIndex++;
|
||||
}
|
||||
|
||||
if (echoIndex > 0) {
|
||||
pendingEchoRef.current = pendingEchoRef.current.slice(echoIndex);
|
||||
}
|
||||
|
||||
return data.slice(serverIndex);
|
||||
}, []);
|
||||
|
||||
const handleBinaryMessage = useCallback(
|
||||
(buffer: ArrayBuffer) => {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
|
||||
if (onData) {
|
||||
onData(bytes);
|
||||
}
|
||||
|
||||
// Deduplicate local echo if active
|
||||
if (echoEnabledRef.current && pendingEchoRef.current.length > 0) {
|
||||
const remaining = deduplicateServerData(text);
|
||||
if (remaining.length > 0 && onLocalEcho) {
|
||||
onLocalEcho(remaining);
|
||||
}
|
||||
} else if (onLocalEcho) {
|
||||
onLocalEcho(text);
|
||||
}
|
||||
|
||||
// Flush stale pending echo buffer
|
||||
if (pendingEchoRef.current.length > PENDING_ECHO_FLUSH_LIMIT) {
|
||||
flushPendingEcho();
|
||||
}
|
||||
},
|
||||
[onData, onLocalEcho, deduplicateServerData, flushPendingEcho],
|
||||
);
|
||||
|
||||
const handleControlMessage = useCallback(
|
||||
(msg: ServerControlMessage) => {
|
||||
if (onControl) {
|
||||
onControl(msg);
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "pong": {
|
||||
const elapsed = Date.now() - pingSentAtRef.current;
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
latency: elapsed,
|
||||
status: prev.status === "reconnecting" ? "connected" : prev.status,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "status": {
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
status: "connected",
|
||||
attempt: 0,
|
||||
error: null,
|
||||
}));
|
||||
reconnectAttemptRef.current = 0;
|
||||
break;
|
||||
}
|
||||
case "set_echo_state": {
|
||||
echoEnabledRef.current = msg.enabled;
|
||||
if (!msg.enabled) {
|
||||
// Server disabled echo — flush any pending local echo
|
||||
flushPendingEcho();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "session_ended": {
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
status: "disconnected",
|
||||
error: `Session ended: ${msg.reason}`,
|
||||
}));
|
||||
clearTimers();
|
||||
wsRef.current?.close(1000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[onControl, setStableState, clearTimers, flushPendingEcho],
|
||||
);
|
||||
|
||||
const schedulePing = useCallback(() => {
|
||||
pingTimerRef.current = setTimeout(() => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const id = ++pingIdRef.current;
|
||||
pingSentAtRef.current = Date.now();
|
||||
const pingMsg: ClientControlMessage = { type: "ping", id };
|
||||
ws.send(encodeControlMessage(pingMsg));
|
||||
|
||||
// Set pong timeout
|
||||
pongTimerRef.current = setTimeout(() => {
|
||||
// Pong not received — connection is dead
|
||||
ws.close(1001, "Ping timeout");
|
||||
}, PONG_TIMEOUT_MS);
|
||||
}, PING_INTERVAL_MS);
|
||||
}, []);
|
||||
|
||||
const serializeScrollback = useCallback(() => {
|
||||
if (!serializeFn) return;
|
||||
try {
|
||||
const serialized = serializeFn();
|
||||
if (serialized) {
|
||||
sessionStorage.setItem(
|
||||
`${SCROLLBACK_STORAGE_KEY}-${instanceId}`,
|
||||
serialized,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore serialization errors
|
||||
}
|
||||
}, [serializeFn, instanceId]);
|
||||
|
||||
const restoreScrollback = useCallback(() => {
|
||||
if (!onRestoreScrollback) return;
|
||||
try {
|
||||
const key = `${SCROLLBACK_STORAGE_KEY}-${instanceId}`;
|
||||
const serialized = sessionStorage.getItem(key);
|
||||
if (serialized) {
|
||||
onRestoreScrollback(serialized);
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// Ignore restoration errors
|
||||
}
|
||||
}, [onRestoreScrollback, instanceId]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (isConnectingRef.current) return;
|
||||
isConnectingRef.current = true;
|
||||
|
||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
isConnectingRef.current = false;
|
||||
reconnectAttemptRef.current = 0;
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
status: "connected",
|
||||
attempt: 0,
|
||||
error: null,
|
||||
}));
|
||||
restoreScrollback();
|
||||
schedulePing();
|
||||
};
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
if (isControlMessage(event)) {
|
||||
const msg = decodeControlMessage(event.data as string);
|
||||
if (msg) {
|
||||
handleControlMessage(msg);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
handleBinaryMessage(event.data);
|
||||
} else if (event.data instanceof Blob) {
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
handleBinaryMessage(buffer);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event: CloseEvent) => {
|
||||
wsRef.current = null;
|
||||
clearTimers();
|
||||
|
||||
if (event.code === 1000 || event.code === 1001) {
|
||||
// Normal or going-away close
|
||||
setStableState(() => ({
|
||||
status: "disconnected",
|
||||
attempt: 0,
|
||||
latency: null,
|
||||
error: event.reason || null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Unexpected close — attempt reconnect
|
||||
const attempt = ++reconnectAttemptRef.current;
|
||||
if (attempt > MAX_RECONNECT_ATTEMPTS) {
|
||||
setStableState(() => ({
|
||||
status: "disconnected",
|
||||
attempt,
|
||||
latency: null,
|
||||
error: "Max reconnection attempts exceeded",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
serializeScrollback();
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_MS * 2 ** (attempt - 1),
|
||||
RECONNECT_MAX_MS,
|
||||
);
|
||||
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
status: "reconnecting",
|
||||
attempt,
|
||||
error: `Reconnecting in ${Math.round(delay / 1000)}s...`,
|
||||
}));
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
isConnectingRef.current = false;
|
||||
// Let onclose handle reconnection
|
||||
};
|
||||
}, [
|
||||
instanceId,
|
||||
setStableState,
|
||||
clearTimers,
|
||||
schedulePing,
|
||||
handleBinaryMessage,
|
||||
handleControlMessage,
|
||||
serializeScrollback,
|
||||
restoreScrollback,
|
||||
]);
|
||||
|
||||
const sendInput = useCallback(
|
||||
(data: string) => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
// Local echo for printable ASCII characters
|
||||
if (
|
||||
echoEnabledRef.current &&
|
||||
data.length === 1 &&
|
||||
data.charCodeAt(0) >= 32 &&
|
||||
data.charCodeAt(0) <= 126
|
||||
) {
|
||||
pendingEchoRef.current += data;
|
||||
if (onLocalEcho) {
|
||||
onLocalEcho(data);
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(data);
|
||||
},
|
||||
[onLocalEcho],
|
||||
);
|
||||
|
||||
const sendResize = useCallback((cols: number, rows: number) => {
|
||||
if (resizeTimerRef.current) {
|
||||
clearTimeout(resizeTimerRef.current);
|
||||
}
|
||||
|
||||
resizeTimerRef.current = setTimeout(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastResizeRef.current < RESIZE_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
lastResizeRef.current = now;
|
||||
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const msg: ClientControlMessage = { type: "resize", cols, rows };
|
||||
ws.send(encodeControlMessage(msg));
|
||||
}, RESIZE_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
clearTimers();
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, "Manual reconnect");
|
||||
wsRef.current = null;
|
||||
}
|
||||
reconnectAttemptRef.current = 0;
|
||||
setStableState(() => ({
|
||||
status: "connecting",
|
||||
attempt: 0,
|
||||
latency: null,
|
||||
error: null,
|
||||
}));
|
||||
connect();
|
||||
}, [clearTimers, connect, setStableState]);
|
||||
|
||||
// Initial connection
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
clearTimers();
|
||||
if (resizeTimerRef.current) {
|
||||
clearTimeout(resizeTimerRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, "Component unmount");
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [instanceId, connect, clearTimers]);
|
||||
|
||||
// Keyboard shortcut for manual reconnect
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === "R") {
|
||||
e.preventDefault();
|
||||
reconnect();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [reconnect]);
|
||||
|
||||
return {
|
||||
state,
|
||||
sendInput,
|
||||
sendResize,
|
||||
reconnect,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user