zeroclaw/web/src/lib/ws.ts
simianastronaut 2539bcafe0 fix(gateway): pass bearer token in WebSocket subprotocol for dashboard auth
The dashboard WebSocket client was only sending ['zeroclaw.v1'] as the
protocols parameter, omitting the bearer token subprotocol. When
require_pairing = true, the server extracts the token from
Sec-WebSocket-Protocol as a fallback (browsers cannot set custom
headers on WebSocket connections). Without the bearer.<token> entry
in the protocols array, subprotocol-based authentication always failed.

Include `bearer.<token>` in the protocols array when a token is
available, matching the server's extract_ws_token() expectation.

Closes #3011

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 16:37:41 -04:00

145 lines
4.4 KiB
TypeScript

import type { WsMessage } from '../types/api';
import { getToken } from './auth';
import { generateUUID } from './uuid';
export type WsMessageHandler = (msg: WsMessage) => void;
export type WsOpenHandler = () => void;
export type WsCloseHandler = (ev: CloseEvent) => void;
export type WsErrorHandler = (ev: Event) => void;
export interface WebSocketClientOptions {
/** Base URL override. Defaults to current host with ws(s) protocol. */
baseUrl?: string;
/** Delay in ms before attempting reconnect. Doubles on each failure up to maxReconnectDelay. */
reconnectDelay?: number;
/** Maximum reconnect delay in ms. */
maxReconnectDelay?: number;
/** Set to false to disable auto-reconnect. Default true. */
autoReconnect?: boolean;
}
const DEFAULT_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 30000;
const SESSION_STORAGE_KEY = 'zeroclaw_session_id';
/** Return a stable session ID, persisted in sessionStorage across reconnects. */
function getOrCreateSessionId(): string {
let id = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!id) {
id = generateUUID();
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
}
return id;
}
export class WebSocketClient {
private ws: WebSocket | null = null;
private currentDelay: number;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private intentionallyClosed = false;
public onMessage: WsMessageHandler | null = null;
public onOpen: WsOpenHandler | null = null;
public onClose: WsCloseHandler | null = null;
public onError: WsErrorHandler | null = null;
private readonly baseUrl: string;
private readonly reconnectDelay: number;
private readonly maxReconnectDelay: number;
private readonly autoReconnect: boolean;
constructor(options: WebSocketClientOptions = {}) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.baseUrl =
options.baseUrl ?? `${protocol}//${window.location.host}`;
this.reconnectDelay = options.reconnectDelay ?? DEFAULT_RECONNECT_DELAY;
this.maxReconnectDelay = options.maxReconnectDelay ?? MAX_RECONNECT_DELAY;
this.autoReconnect = options.autoReconnect ?? true;
this.currentDelay = this.reconnectDelay;
}
/** Open the WebSocket connection. */
connect(): void {
this.intentionallyClosed = false;
this.clearReconnectTimer();
const token = getToken();
const sessionId = getOrCreateSessionId();
const params = new URLSearchParams();
if (token) params.set('token', token);
params.set('session_id', sessionId);
const url = `${this.baseUrl}/ws/chat?${params.toString()}`;
const protocols: string[] = ['zeroclaw.v1'];
if (token) protocols.push(`bearer.${token}`);
this.ws = new WebSocket(url, protocols);
this.ws.onopen = () => {
this.currentDelay = this.reconnectDelay;
this.onOpen?.();
};
this.ws.onmessage = (ev: MessageEvent) => {
try {
const msg = JSON.parse(ev.data) as WsMessage;
this.onMessage?.(msg);
} catch {
// Ignore non-JSON frames
}
};
this.ws.onclose = (ev: CloseEvent) => {
this.onClose?.(ev);
this.scheduleReconnect();
};
this.ws.onerror = (ev: Event) => {
this.onError?.(ev);
};
}
/** Send a chat message to the agent. */
sendMessage(content: string): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket is not connected');
}
this.ws.send(JSON.stringify({ type: 'message', content }));
}
/** Close the connection without auto-reconnecting. */
disconnect(): void {
this.intentionallyClosed = true;
this.clearReconnectTimer();
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
/** Returns true if the socket is open. */
get connected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
// ---------------------------------------------------------------------------
// Reconnection logic
// ---------------------------------------------------------------------------
private scheduleReconnect(): void {
if (this.intentionallyClosed || !this.autoReconnect) return;
this.reconnectTimer = setTimeout(() => {
this.currentDelay = Math.min(this.currentDelay * 2, this.maxReconnectDelay);
this.connect();
}, this.currentDelay);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}