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.
64 lines
2.1 KiB
TypeScript
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",
|
|
}),
|
|
);
|
|
}
|
|
}
|