217 lines
6.1 KiB
TypeScript
217 lines
6.1 KiB
TypeScript
import type { RuntimeCapabilities, RuntimeSurface } from "./types";
|
|
|
|
export interface RuntimeHost {
|
|
surface: RuntimeSurface;
|
|
capabilities: RuntimeCapabilities;
|
|
socketFactory?: (url: string) => WebSocket;
|
|
pickFolder?: () => Promise<string | null>;
|
|
restartEngine?: () => Promise<void>;
|
|
openLogs?: () => Promise<void>;
|
|
exportDiagnostics?: () => Promise<string>;
|
|
}
|
|
|
|
export interface HostRuntimeInfo {
|
|
surface: "native";
|
|
app_version: string;
|
|
engine_status: "starting" | "ready" | "restarting" | "stopped" | "crashed";
|
|
data_dir: string;
|
|
logs_dir: string;
|
|
config_path: string;
|
|
workspace_path: string;
|
|
python: string;
|
|
api_base?: string;
|
|
engine_transport?: "unix_socket";
|
|
}
|
|
|
|
export interface NanobotHostApi {
|
|
getRuntimeInfo(): Promise<HostRuntimeInfo>;
|
|
restartEngine(): Promise<void>;
|
|
pickFolder(): Promise<string | null>;
|
|
openLogs(): Promise<void>;
|
|
exportDiagnostics(): Promise<string>;
|
|
openSocket?(url: string): Promise<string>;
|
|
sendSocket?(id: string, data: string): Promise<void>;
|
|
closeSocket?(id: string): Promise<void>;
|
|
onSocketEvent?(
|
|
listener: (event: HostSocketEvent) => void,
|
|
): () => void;
|
|
onRuntimeStatus?(
|
|
listener: (status: HostRuntimeInfo["engine_status"]) => void,
|
|
): () => void;
|
|
}
|
|
|
|
export type HostSocketEvent =
|
|
| { id: string; type: "open" }
|
|
| { data: string; id: string; type: "message" }
|
|
| { id: string; message: string; type: "error" }
|
|
| { code?: number; id: string; reason?: string; type: "close" };
|
|
|
|
type HostSocketBridge = Required<Pick<
|
|
NanobotHostApi,
|
|
"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;
|
|
}
|
|
}
|
|
|
|
export function getHostApi(): NanobotHostApi | null {
|
|
if (typeof window === "undefined") return null;
|
|
return window.nanobotHost ?? null;
|
|
}
|
|
|
|
export function toRuntimeSurface(surface: string | null | undefined): RuntimeSurface {
|
|
return surface === "native" ? "native" : "browser";
|
|
}
|
|
|
|
export function createRuntimeHost(
|
|
surface: RuntimeSurface,
|
|
capabilities?: Partial<RuntimeCapabilities> | null,
|
|
): RuntimeHost {
|
|
const api = getHostApi();
|
|
const mergedCapabilities = {
|
|
can_export_diagnostics: false,
|
|
can_open_logs: false,
|
|
can_pick_folder: false,
|
|
can_restart_engine: false,
|
|
...(capabilities ?? {}),
|
|
};
|
|
const bridge = getHostSocketBridge();
|
|
return {
|
|
surface,
|
|
capabilities: mergedCapabilities,
|
|
socketFactory: bridge ? createHostWebSocket : undefined,
|
|
pickFolder: api?.pickFolder,
|
|
restartEngine: api?.restartEngine,
|
|
openLogs: api?.openLogs,
|
|
exportDiagnostics: api?.exportDiagnostics,
|
|
};
|
|
}
|
|
|
|
export function createHostWebSocket(url: string): WebSocket {
|
|
const api = getHostSocketBridge();
|
|
if (!api) {
|
|
throw new Error("Host WebSocket bridge is not available");
|
|
}
|
|
return new HostWebSocket(api, url) as unknown as WebSocket;
|
|
}
|
|
|
|
function getHostSocketBridge(): HostSocketBridge | null {
|
|
const api = getHostApi();
|
|
if (
|
|
!api?.openSocket
|
|
|| !api.sendSocket
|
|
|| !api.closeSocket
|
|
|| !api.onSocketEvent
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
closeSocket: api.closeSocket,
|
|
onSocketEvent: api.onSocketEvent,
|
|
openSocket: api.openSocket,
|
|
sendSocket: api.sendSocket,
|
|
};
|
|
}
|
|
|
|
class HostWebSocket {
|
|
binaryType: BinaryType = "blob";
|
|
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null = null;
|
|
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 = HOST_WS_CONNECTING;
|
|
readonly url: string;
|
|
|
|
private id: string | null = null;
|
|
private readonly queued: string[] = [];
|
|
private readonly unsubscribe: () => void;
|
|
|
|
constructor(
|
|
private readonly api: HostSocketBridge,
|
|
url: string,
|
|
) {
|
|
this.url = url;
|
|
this.unsubscribe = api.onSocketEvent((event) => this.handleEvent(event));
|
|
void api.openSocket(url).then(
|
|
(id) => {
|
|
this.id = id;
|
|
},
|
|
() => {
|
|
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();
|
|
},
|
|
);
|
|
}
|
|
|
|
close(): void {
|
|
if (this.readyState === HOST_WS_CLOSING || this.readyState === HOST_WS_CLOSED) {
|
|
return;
|
|
}
|
|
this.readyState = HOST_WS_CLOSING;
|
|
if (this.id) {
|
|
void this.api.closeSocket(this.id);
|
|
} else {
|
|
this.readyState = HOST_WS_CLOSED;
|
|
this.unsubscribe();
|
|
}
|
|
}
|
|
|
|
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
|
if (typeof data !== "string") {
|
|
throw new Error("Host WebSocket bridge only supports text frames");
|
|
}
|
|
if (this.readyState === HOST_WS_OPEN && this.id) {
|
|
void this.api.sendSocket(this.id, data);
|
|
return;
|
|
}
|
|
this.queued.push(data);
|
|
}
|
|
|
|
private handleEvent(event: HostSocketEvent): void {
|
|
if (!this.id || event.id !== this.id) return;
|
|
if (event.type === "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();
|
|
if (data !== undefined) void this.api.sendSocket(this.id, data);
|
|
}
|
|
return;
|
|
}
|
|
if (event.type === "message") {
|
|
this.onmessage?.call(
|
|
this as unknown as WebSocket,
|
|
new MessageEvent("message", { data: event.data }),
|
|
);
|
|
return;
|
|
}
|
|
if (event.type === "error") {
|
|
this.onerror?.call(this as unknown as WebSocket, new Event("error"));
|
|
return;
|
|
}
|
|
this.readyState = HOST_WS_CLOSED;
|
|
this.onclose?.call(
|
|
this as unknown as WebSocket,
|
|
closeEvent(event.code, event.reason),
|
|
);
|
|
this.unsubscribe();
|
|
}
|
|
}
|
|
|
|
function closeEvent(code = 1006, reason = ""): CloseEvent {
|
|
if (typeof CloseEvent !== "undefined") {
|
|
return new CloseEvent("close", { code, reason });
|
|
}
|
|
return new Event("close") as CloseEvent;
|
|
}
|