Files
hermes-agent/web/src/lib/gatewayClient.ts
izumi0uu cab8673ea6 fix(dashboard): reload loopback tabs after stale session-token closes
Loopback dashboard tabs now share one one-shot stale-token recovery path across REST 401s, the PTY socket, the structured event socket, and the shared JSON-RPC gateway wrapper. The shared client exposes only an optional close-event interception hook; the dashboard remains responsible for deciding that loopback 4401 means reload.

Constraint: Current main delegates the web gateway to apps/shared JsonRpcGatewayClient, and #54022 review requires a shared-client-compatible close-code hook plus direct ChatSidebar event-socket coverage.
Rejected: Restore the dashboard's old direct WebSocket implementation | stale against the shared JSON-RPC client and would duplicate transport behavior.
Confidence: high
Scope-risk: moderate
Directive: Keep stale-token policy dashboard-specific; the shared JSON-RPC client should expose close events without learning dashboard auth semantics.
Tested: npm --workspace web test (21 files, 106 tests); focused stale-token tests (5 files, 14 tests); npm --workspace web run typecheck; npm --workspace @hermes/shared run lint; npm --workspace @hermes/shared run typecheck; focused web eslint; git diff --check.
Not-tested: Manual browser smoke test across a real dashboard restart.
2026-08-04 15:26:37 +05:30

64 lines
2.1 KiB
TypeScript

/**
* Browser WebSocket client for the tui_gateway JSON-RPC protocol.
*
* Speaks the exact same newline-delimited JSON-RPC dialect that the Ink TUI
* drives over stdio. The server-side transport abstraction
* (tui_gateway/transport.py + ws.py) routes the same dispatcher's writes
* onto either stdout or a WebSocket depending on how the client connected.
*
* const gw = new GatewayClient()
* await gw.connect()
* const { session_id } = await gw.request<{ session_id: string }>("session.create")
* gw.on("message.delta", (ev) => console.log(ev.payload?.text))
* await gw.request("prompt.submit", { session_id, text: "hi" })
*/
import {
JsonRpcGatewayClient,
buildHermesWebSocketUrl,
type ConnectionState,
type GatewayEvent,
type GatewayEventName,
} from "@hermes/shared";
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload";
export type { ConnectionState, GatewayEvent, GatewayEventName };
export class GatewayClient extends JsonRpcGatewayClient {
constructor() {
super({
closedErrorMessage: "WebSocket closed",
connectErrorMessage: "WebSocket connection failed",
notConnectedErrorMessage: "gateway not connected",
onSocketClose: (event) => maybeReloadForLoopbackWsAuthFailure(event.code),
requestIdPrefix: "w",
});
}
async connect(token?: string): Promise<void> {
if (this.connectionState === "open" || this.connectionState === "connecting") {
return;
}
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the SPA
// must fetch a single-use ticket. Explicit ``token`` keeps the test-only
// override path.
const authParam = token ? (["token", token] as const) : await buildWsAuthParam();
if (!authParam[1]) {
throw new Error(
"Session token not available — page must be served by the Hermes dashboard server",
);
}
await super.connect(
buildHermesWebSocketUrl({
authParam,
basePath: HERMES_BASE_PATH,
path: "/api/ws",
}),
);
}
}