* Browser-friendly Gateway WebSocket client.
*
* Mirrors the protocol behavior of `src/gateway/client/GatewayWsClient.ts`
* but uses only browser-safe globals (`WebSocket`, `crypto.randomUUID`,
* `fetch`). Node 22+ exposes the same globals so this module is also used
* by the parity / contract tests under `tests/web-ui-client/`.
*/
import {
PILOTDECK_GATEWAY_PROTOCOL_VERSION_WEB,
type WebGatewayEvent,
type WebGatewayFrame,
type WebGatewayMethod,
type WebHelloOk,
type WebSubmitTurnInput,
} from "./protocol.js";
export type GatewayBrowserClientOptions = {
url: string;
token: string;
clientName?: "web" | "test";
clientVersion?: string;
protocolVersion?: string;
webSocketFactory?: (url: string) => WebSocketLike;
newId?: () => string;
helloTimeoutMs?: number;
};
* Subset of WebSocket we use. Allows test doubles in node without `ws`
* package.
*/
export interface WebSocketLike {
readonly readyState: number;
send(data: string): void;
close(code?: number, reason?: string): void;
addEventListener(
type: "open" | "message" | "close" | "error",
listener: (event: { data?: unknown; code?: number; reason?: string }) => void,
options?: { once?: boolean },
): void;
}
export type GatewayBrowserStream = AsyncIterable<WebGatewayEvent>;
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
};
const DEFAULT_HELLO_TIMEOUT_MS = 5000;
export class GatewayBrowserClient {
private ws?: WebSocketLike;
private hello?: WebHelloOk;
private readonly pending = new Map<string, PendingRequest>();
private readonly streams = new Map<string, AsyncEventQueue<WebGatewayEvent>>();
private connectError?: Error;
private closed = false;
constructor(private readonly options: GatewayBrowserClientOptions) {}
get connected(): boolean {
return this.hello !== undefined && !this.closed;
}
get serverInfo(): WebHelloOk["serverInfo"] | undefined {
return this.hello?.serverInfo;
}
async connect(): Promise<WebHelloOk> {
if (this.closed) {
throw new Error("GatewayBrowserClient was closed.");
}
if (this.hello) {
return this.hello;
}
const ws = this.openSocket();
this.ws = ws;
await waitForOpen(ws);
ws.addEventListener("message", (event) => {
this.handleMessage(typeof event.data === "string" ? event.data : String(event.data ?? ""));
});
ws.addEventListener("close", (event) => {
this.handleClose(event.code, event.reason);
});
ws.addEventListener("error", () => {
this.connectError ??= new Error("Gateway WebSocket error.");
});
ws.send(
JSON.stringify({
type: "hello",
protocolVersion:
this.options.protocolVersion ?? PILOTDECK_GATEWAY_PROTOCOL_VERSION_WEB,
clientName: this.options.clientName ?? "web",
clientVersion: this.options.clientVersion ?? "0.1.0",
token: this.options.token,
}),
);
const timeoutMs = this.options.helloTimeoutMs ?? DEFAULT_HELLO_TIMEOUT_MS;
return await this.waitForHello(timeoutMs);
}
request<T = unknown>(method: WebGatewayMethod, params: unknown): Promise<T> {
this.ensureConnected();
const id = this.newId();
return new Promise<T>((resolve, reject) => {
this.pending.set(id, {
resolve: (value) => resolve(value as T),
reject,
});
this.send({ type: "request", id, method, params });
});
}
stream(method: WebGatewayMethod, params: unknown): GatewayBrowserStream {
this.ensureConnected();
const id = this.newId();
const queue = new AsyncEventQueue<WebGatewayEvent>();
this.streams.set(id, queue);
this.send({ type: "request", id, method, params });
return queue;
}
submitTurn(input: WebSubmitTurnInput): GatewayBrowserStream {
return this.stream("submit_turn", input);
}
abortTurn(input: { sessionKey: string; runId?: string }): Promise<{ ok: boolean }> {
return this.request<{ ok: boolean }>("abort_turn", input);
}
listSessions(input: { projectKey?: string; limit?: number; cursor?: string }) {
return this.request<import("./protocol.js").WebListSessionsResult>(
"list_sessions",
input,
);
}
newSession(input: {
projectKey?: string;
channelKey: import("./protocol.js").WebGatewayChannelKey;
hint?: string;
}) {
return this.request<{ sessionKey: string }>("new_session", input);
}
resumeSession(input: { sessionKey: string }) {
return this.request<{ sessionKey: string }>("resume_session", input);
}
closeSession(input: { sessionKey: string; reason?: string }) {
return this.request<{ ok: boolean }>("close_session", input);
}
describeServer() {
return this.request<import("./protocol.js").WebHelloOk["serverInfo"]>(
"describe_server",
{},
);
}
getActiveTurnSnapshot(input: import("./protocol.js").WebActiveTurnSnapshotInput) {
return this.request<import("./protocol.js").WebActiveTurnSnapshot>(
"active_turn_snapshot",
input,
);
}
permissionDecide(input: import("./protocol.js").WebPermissionDecision) {
return this.request<{ delivered: boolean }>("permission_decide", input);
}
grantSessionPermission(input: import("./protocol.js").WebSessionPermissionGrant) {
return this.request<{ granted: boolean; entry?: string }>("grant_session_permission", input);
}
elicitationRespond(input: {
sessionKey: string;
requestId: string;
answer: import("./protocol.js").WebElicitationAnswer;
}) {
return this.request<{ delivered: boolean }>("elicitation_respond", input);
}
readSessionMessages(
input: import("./protocol.js").WebReadSessionMessagesInput,
) {
return this.request<import("./protocol.js").WebReadSessionMessagesResult>(
"read_session_messages",
input,
);
}
listProjects(): Promise<import("./protocol.js").WebListProjectsResult> {
return this.request<import("./protocol.js").WebListProjectsResult>("list_projects", {});
}
describeProject(input: { projectKey: string }) {
return this.request<import("./protocol.js").WebProjectSummary>("describe_project", input);
}
cronCreate(input: unknown) {
return this.request<unknown>("cron_create", input);
}
cronList(input: unknown) {
return this.request<unknown>("cron_list", input);
}
cronDelete(input: unknown) {
return this.request<unknown>("cron_delete", input);
}
cronStop(input: unknown) {
return this.request<unknown>("cron_stop", input);
}
close(): void {
if (this.closed) {
return;
}
this.closed = true;
this.ws?.close();
this.failPendingAndStreams(new Error("Gateway client closed."));
}
private openSocket(): WebSocketLike {
if (this.options.webSocketFactory) {
return this.options.webSocketFactory(this.options.url);
}
if (typeof WebSocket === "undefined") {
throw new Error("WebSocket is not available in this environment.");
}
return new WebSocket(this.options.url) as unknown as WebSocketLike;
}
private send(frame: unknown): void {
if (!this.ws || this.closed) {
throw new Error("Gateway WebSocket is not connected.");
}
this.ws.send(JSON.stringify(frame));
}
private async waitForHello(timeoutMs: number): Promise<WebHelloOk> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (this.hello) {
return this.hello;
}
if (this.connectError || this.closed) {
throw this.connectError ?? new Error("Gateway WebSocket closed before hello.");
}
await sleep(10);
}
throw new Error("Gateway hello timed out.");
}
private ensureConnected(): void {
if (!this.hello) {
throw new Error("GatewayBrowserClient: call connect() before requests.");
}
if (this.closed) {
throw new Error("GatewayBrowserClient: client is closed.");
}
}
private handleMessage(raw: string): void {
let frame: WebGatewayFrame;
try {
frame = JSON.parse(raw) as WebGatewayFrame;
} catch {
return;
}
if ((frame as WebHelloOk).type === "hello_ok") {
this.hello = frame as WebHelloOk;
return;
}
if (frame.type === "response") {
const pending = this.pending.get(frame.id);
if (!pending) return;
this.pending.delete(frame.id);
if (frame.ok) {
pending.resolve(frame.result);
} else {
pending.reject(
Object.assign(new Error(frame.error.message), { code: frame.error.code }),
);
}
return;
}
if (frame.type === "event") {
const stream = this.streams.get(frame.id);
if (!stream) return;
if (!frame.final) {
stream.push(frame.event);
return;
}
this.streams.delete(frame.id);
stream.close();
}
}
private handleClose(code?: number, reason?: string): void {
if (this.closed) {
return;
}
this.closed = true;
const error = new Error(
`Gateway WebSocket closed (code=${code ?? "?"}${reason ? `, reason=${reason}` : ""}).`,
);
if (!this.hello) {
this.connectError ??= error;
}
this.failPendingAndStreams(error);
}
private failPendingAndStreams(error: Error): void {
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
for (const stream of this.streams.values()) {
stream.fail(error);
}
this.streams.clear();
}
private newId(): string {
if (this.options.newId) {
return this.options.newId();
}
const c =
typeof globalThis !== "undefined" &&
(globalThis as unknown as { crypto?: Crypto }).crypto;
if (c && typeof c.randomUUID === "function") {
return c.randomUUID();
}
return `web-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
}
export async function readLocalGatewayToken(
fetcher: (url: string) => Promise<Response> = (url) => fetch(url),
url = "/auth/local-token",
): Promise<string> {
const response = await fetcher(url);
if (!response.ok) {
throw new Error(`Failed to read local token (HTTP ${response.status}).`);
}
const body = (await response.json()) as { token?: string };
if (typeof body.token !== "string" || body.token.length === 0) {
throw new Error("Local token endpoint returned an invalid body.");
}
return body.token;
}
class AsyncEventQueue<T> implements AsyncIterable<T> {
private readonly values: T[] = [];
private readonly waiters: Array<(result: IteratorResult<T>) => void> = [];
private closed = false;
private error?: Error;
push(value: T): void {
if (this.closed) {
return;
}
const waiter = this.waiters.shift();
if (waiter) {
waiter({ done: false, value });
return;
}
this.values.push(value);
}
close(): void {
if (this.closed) return;
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter({ done: true, value: undefined });
}
}
fail(error: Error): void {
this.error = error;
this.close();
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: () => this.next(),
};
}
private next(): Promise<IteratorResult<T>> {
if (this.error) {
const err = this.error;
this.error = undefined;
return Promise.reject(err);
}
const value = this.values.shift();
if (value !== undefined) {
return Promise.resolve({ done: false, value });
}
if (this.closed) {
return Promise.resolve({ done: true, value: undefined as never });
}
return new Promise((resolve) => this.waiters.push(resolve));
}
}
function waitForOpen(ws: WebSocketLike): Promise<void> {
return new Promise<void>((resolve, reject) => {
const onOpen = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error("Failed to open Gateway WebSocket."));
};
function cleanup(): void {
}
ws.addEventListener("open", onOpen, { once: true });
ws.addEventListener("error", onError, { once: true });
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}