188 lines
5.1 KiB
TypeScript
188 lines
5.1 KiB
TypeScript
import { generateUUID } from "./uuid";
|
|
import {
|
|
GATEWAY_CLIENT_MODES,
|
|
GATEWAY_CLIENT_NAMES,
|
|
type GatewayClientMode,
|
|
type GatewayClientName,
|
|
} from "../../../src/gateway/protocol/client-info.js";
|
|
|
|
export type GatewayEventFrame = {
|
|
type: "event";
|
|
event: string;
|
|
payload?: unknown;
|
|
seq?: number;
|
|
stateVersion?: { presence: number; health: number };
|
|
};
|
|
|
|
export type GatewayResponseFrame = {
|
|
type: "res";
|
|
id: string;
|
|
ok: boolean;
|
|
payload?: unknown;
|
|
error?: { code: string; message: string; details?: unknown };
|
|
};
|
|
|
|
export type GatewayHelloOk = {
|
|
type: "hello-ok";
|
|
protocol: number;
|
|
features?: { methods?: string[]; events?: string[] };
|
|
snapshot?: unknown;
|
|
policy?: { tickIntervalMs?: number };
|
|
};
|
|
|
|
type Pending = {
|
|
resolve: (value: unknown) => void;
|
|
reject: (err: unknown) => void;
|
|
};
|
|
|
|
export type GatewayBrowserClientOptions = {
|
|
url: string;
|
|
token?: string;
|
|
password?: string;
|
|
clientName?: GatewayClientName;
|
|
clientVersion?: string;
|
|
platform?: string;
|
|
mode?: GatewayClientMode;
|
|
instanceId?: string;
|
|
onHello?: (hello: GatewayHelloOk) => void;
|
|
onEvent?: (evt: GatewayEventFrame) => void;
|
|
onClose?: (info: { code: number; reason: string }) => void;
|
|
onGap?: (info: { expected: number; received: number }) => void;
|
|
};
|
|
|
|
export class GatewayBrowserClient {
|
|
private ws: WebSocket | null = null;
|
|
private pending = new Map<string, Pending>();
|
|
private closed = false;
|
|
private lastSeq: number | null = null;
|
|
private backoffMs = 800;
|
|
|
|
constructor(private opts: GatewayBrowserClientOptions) {}
|
|
|
|
start() {
|
|
this.closed = false;
|
|
this.connect();
|
|
}
|
|
|
|
stop() {
|
|
this.closed = true;
|
|
this.ws?.close();
|
|
this.ws = null;
|
|
this.flushPending(new Error("gateway client stopped"));
|
|
}
|
|
|
|
get connected() {
|
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
}
|
|
|
|
private connect() {
|
|
if (this.closed) return;
|
|
this.ws = new WebSocket(this.opts.url);
|
|
this.ws.onopen = () => this.sendConnect();
|
|
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
|
|
this.ws.onclose = (ev) => {
|
|
const reason = String(ev.reason ?? "");
|
|
this.ws = null;
|
|
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
|
|
this.opts.onClose?.({ code: ev.code, reason });
|
|
this.scheduleReconnect();
|
|
};
|
|
this.ws.onerror = () => {
|
|
// ignored; close handler will fire
|
|
};
|
|
}
|
|
|
|
private scheduleReconnect() {
|
|
if (this.closed) return;
|
|
const delay = this.backoffMs;
|
|
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
|
|
window.setTimeout(() => this.connect(), delay);
|
|
}
|
|
|
|
private flushPending(err: Error) {
|
|
for (const [, p] of this.pending) p.reject(err);
|
|
this.pending.clear();
|
|
}
|
|
|
|
private sendConnect() {
|
|
const auth =
|
|
this.opts.token || this.opts.password
|
|
? {
|
|
token: this.opts.token,
|
|
password: this.opts.password,
|
|
}
|
|
: undefined;
|
|
const params = {
|
|
minProtocol: 3,
|
|
maxProtocol: 3,
|
|
client: {
|
|
id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
|
version: this.opts.clientVersion ?? "dev",
|
|
platform: this.opts.platform ?? navigator.platform ?? "web",
|
|
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
|
|
instanceId: this.opts.instanceId,
|
|
},
|
|
caps: [],
|
|
auth,
|
|
userAgent: navigator.userAgent,
|
|
locale: navigator.language,
|
|
};
|
|
|
|
void this.request<GatewayHelloOk>("connect", params)
|
|
.then((hello) => {
|
|
this.backoffMs = 800;
|
|
this.opts.onHello?.(hello);
|
|
})
|
|
.catch(() => {
|
|
// 4008 = application-defined code (browser rejects 1008 "Policy Violation")
|
|
this.ws?.close(4008, "connect failed");
|
|
});
|
|
}
|
|
|
|
private handleMessage(raw: string) {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const frame = parsed as { type?: unknown };
|
|
if (frame.type === "event") {
|
|
const evt = parsed as GatewayEventFrame;
|
|
const seq = typeof evt.seq === "number" ? evt.seq : null;
|
|
if (seq !== null) {
|
|
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
|
|
this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
|
|
}
|
|
this.lastSeq = seq;
|
|
}
|
|
this.opts.onEvent?.(evt);
|
|
return;
|
|
}
|
|
|
|
if (frame.type === "res") {
|
|
const res = parsed as GatewayResponseFrame;
|
|
const pending = this.pending.get(res.id);
|
|
if (!pending) return;
|
|
this.pending.delete(res.id);
|
|
if (res.ok) pending.resolve(res.payload);
|
|
else pending.reject(new Error(res.error?.message ?? "request failed"));
|
|
return;
|
|
}
|
|
}
|
|
|
|
request<T = unknown>(method: string, params?: unknown): Promise<T> {
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
return Promise.reject(new Error("gateway not connected"));
|
|
}
|
|
const id = generateUUID();
|
|
const frame = { type: "req", id, method, params };
|
|
const p = new Promise<T>((resolve, reject) => {
|
|
this.pending.set(id, { resolve: (v) => resolve(v as T), reject });
|
|
});
|
|
this.ws.send(JSON.stringify(frame));
|
|
return p;
|
|
}
|
|
}
|