feat(webui): polish native host experience
This commit is contained in:
@@ -9,11 +9,20 @@ import type {
|
||||
GoalStateWsPayload,
|
||||
WorkspaceScopePayload,
|
||||
} from "./types";
|
||||
import { createHostWebSocket } from "./runtime";
|
||||
|
||||
/** WebSocket readyState constants, referenced by value to stay portable
|
||||
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
|
||||
const WS_OPEN = 1;
|
||||
const WS_CLOSING = 2;
|
||||
const HOST_SOCKET_URL_PREFIX = "nanobot-host://";
|
||||
|
||||
function createDefaultSocket(url: string): WebSocket {
|
||||
if (url.startsWith(HOST_SOCKET_URL_PREFIX)) {
|
||||
return createHostWebSocket(url);
|
||||
}
|
||||
return new WebSocket(url);
|
||||
}
|
||||
|
||||
/** Inbound WebSocket ``console.log`` / parse-failure ``console.warn``.
|
||||
*
|
||||
@@ -129,7 +138,7 @@ export class NanobotClient {
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly shouldReconnect: boolean;
|
||||
private readonly maxBackoffMs: number;
|
||||
private readonly socketFactory: (url: string) => WebSocket;
|
||||
private socketFactory: (url: string) => WebSocket;
|
||||
private currentUrl: string;
|
||||
private status_: ConnectionStatus = "idle";
|
||||
private readyChatId: string | null = null;
|
||||
@@ -140,8 +149,7 @@ export class NanobotClient {
|
||||
constructor(private options: NanobotClientOptions) {
|
||||
this.shouldReconnect = options.reconnect ?? true;
|
||||
this.maxBackoffMs = options.maxBackoffMs ?? 15_000;
|
||||
this.socketFactory =
|
||||
options.socketFactory ?? ((url) => new WebSocket(url));
|
||||
this.socketFactory = options.socketFactory ?? createDefaultSocket;
|
||||
this.currentUrl = options.url;
|
||||
}
|
||||
|
||||
@@ -154,8 +162,11 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
/** Swap the URL (e.g. after fetching a fresh token) then reconnect. */
|
||||
updateUrl(url: string): void {
|
||||
updateUrl(url: string, socketFactory?: (url: string) => WebSocket): void {
|
||||
this.currentUrl = url;
|
||||
if (socketFactory) {
|
||||
this.socketFactory = socketFactory;
|
||||
}
|
||||
}
|
||||
|
||||
onStatus(handler: StatusHandler): Unsubscribe {
|
||||
|
||||
@@ -51,6 +51,11 @@ type HostSocketBridge = Required<Pick<
|
||||
"closeSocket" | "onSocketEvent" | "openSocket" | "sendSocket"
|
||||
>>;
|
||||
|
||||
const HOST_WS_CONNECTING = 0;
|
||||
const HOST_WS_OPEN = 1;
|
||||
const HOST_WS_CLOSING = 2;
|
||||
const HOST_WS_CLOSED = 3;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nanobotHost?: NanobotHostApi;
|
||||
@@ -122,7 +127,7 @@ class HostWebSocket {
|
||||
onerror: ((this: WebSocket, ev: Event) => unknown) | null = null;
|
||||
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null;
|
||||
onopen: ((this: WebSocket, ev: Event) => unknown) | null = null;
|
||||
readyState: number = WebSocket.CONNECTING;
|
||||
readyState: number = HOST_WS_CONNECTING;
|
||||
readonly url: string;
|
||||
|
||||
private id: string | null = null;
|
||||
@@ -140,7 +145,7 @@ class HostWebSocket {
|
||||
this.id = id;
|
||||
},
|
||||
() => {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
this.readyState = HOST_WS_CLOSED;
|
||||
this.onerror?.call(this as unknown as WebSocket, new Event("error"));
|
||||
this.onclose?.call(this as unknown as WebSocket, closeEvent());
|
||||
this.unsubscribe();
|
||||
@@ -149,14 +154,14 @@ class HostWebSocket {
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.readyState === WebSocket.CLOSING || this.readyState === WebSocket.CLOSED) {
|
||||
if (this.readyState === HOST_WS_CLOSING || this.readyState === HOST_WS_CLOSED) {
|
||||
return;
|
||||
}
|
||||
this.readyState = WebSocket.CLOSING;
|
||||
this.readyState = HOST_WS_CLOSING;
|
||||
if (this.id) {
|
||||
void this.api.closeSocket(this.id);
|
||||
} else {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
this.readyState = HOST_WS_CLOSED;
|
||||
this.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -165,7 +170,7 @@ class HostWebSocket {
|
||||
if (typeof data !== "string") {
|
||||
throw new Error("Host WebSocket bridge only supports text frames");
|
||||
}
|
||||
if (this.readyState === WebSocket.OPEN && this.id) {
|
||||
if (this.readyState === HOST_WS_OPEN && this.id) {
|
||||
void this.api.sendSocket(this.id, data);
|
||||
return;
|
||||
}
|
||||
@@ -175,7 +180,7 @@ class HostWebSocket {
|
||||
private handleEvent(event: HostSocketEvent): void {
|
||||
if (!this.id || event.id !== this.id) return;
|
||||
if (event.type === "open") {
|
||||
this.readyState = WebSocket.OPEN;
|
||||
this.readyState = HOST_WS_OPEN;
|
||||
this.onopen?.call(this as unknown as WebSocket, new Event("open"));
|
||||
while (this.queued.length > 0 && this.id) {
|
||||
const data = this.queued.shift();
|
||||
@@ -194,7 +199,7 @@ class HostWebSocket {
|
||||
this.onerror?.call(this as unknown as WebSocket, new Event("error"));
|
||||
return;
|
||||
}
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
this.readyState = HOST_WS_CLOSED;
|
||||
this.onclose?.call(
|
||||
this as unknown as WebSocket,
|
||||
closeEvent(event.code, event.reason),
|
||||
|
||||
Reference in New Issue
Block a user