nanobot/webui/src/lib/runtime.ts

346 lines
11 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;
const LOOPBACK_HOST_PORT_PARAM = "nativeHostPort";
const LOOPBACK_HOST_TOKEN_PARAM = "nativeHostToken";
const LOOPBACK_HOST_STORAGE_KEY = "nanobot-webui.native-host";
interface LoopbackHostConfig {
port: number;
token: string;
}
let loopbackHostApi: NanobotHostApi | null = null;
declare global {
interface Window {
nanobotHost?: NanobotHostApi;
}
}
function getHostApi(): NanobotHostApi | null {
if (typeof window === "undefined") return null;
return window.nanobotHost ?? loopbackHostApi;
}
/**
* Install the external native-host bridge advertised in the URL fragment.
*
* Only a loopback port is accepted; callers cannot redirect privileged host
* actions to an arbitrary origin. The short-lived bridge token is removed
* from the URL and retained only for the lifetime of this browser tab.
*/
export function initializeLoopbackRuntimeHost(): boolean {
if (typeof window === "undefined") return false;
const config = consumeLoopbackHostConfig() ?? loadLoopbackHostConfig();
loopbackHostApi = config ? createLoopbackHostApi(config) : null;
return loopbackHostApi !== 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?.bind(api),
restartEngine: api?.restartEngine?.bind(api),
openLogs: api?.openLogs?.bind(api),
exportDiagnostics: api?.exportDiagnostics?.bind(api),
};
}
export function getRuntimeHost(
surface?: string | null,
capabilities?: Partial<RuntimeCapabilities> | null,
): RuntimeHost {
const api = getHostApi();
const runtimeSurface =
surface == null ? (api ? "native" : "browser") : toRuntimeSurface(surface);
return createRuntimeHost(runtimeSurface, capabilities);
}
export function isNativeRuntime(surface?: string | null): boolean {
return getHostApi() !== null || toRuntimeSurface(surface) === "native";
}
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();
const { closeSocket, onSocketEvent, openSocket, sendSocket } = api ?? {};
if (
!openSocket
|| !sendSocket
|| !closeSocket
|| !onSocketEvent
) {
return null;
}
return {
closeSocket: (id) => closeSocket.call(api, id),
onSocketEvent: (listener) => onSocketEvent.call(api, listener),
openSocket: (url) => openSocket.call(api, url),
sendSocket: (id, data) => sendSocket.call(api, id, data),
};
}
function consumeLoopbackHostConfig(): LoopbackHostConfig | null {
const hash = window.location.hash || "";
const queryStart = hash.indexOf("?");
if (queryStart < 0) return null;
const path = hash.slice(0, queryStart) || "#/";
const params = new URLSearchParams(hash.slice(queryStart + 1));
const hasBridgeParams = params.has(LOOPBACK_HOST_PORT_PARAM)
|| params.has(LOOPBACK_HOST_TOKEN_PARAM);
if (!hasBridgeParams) return null;
const config = validateLoopbackHostConfig({
port: Number(params.get(LOOPBACK_HOST_PORT_PARAM)),
token: params.get(LOOPBACK_HOST_TOKEN_PARAM) ?? "",
});
params.delete(LOOPBACK_HOST_PORT_PARAM);
params.delete(LOOPBACK_HOST_TOKEN_PARAM);
const nextQuery = params.toString();
const nextHash = `${path}${nextQuery ? `?${nextQuery}` : ""}`;
window.history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}${nextHash}`,
);
try {
if (config) {
window.sessionStorage.setItem(LOOPBACK_HOST_STORAGE_KEY, JSON.stringify(config));
} else {
window.sessionStorage.removeItem(LOOPBACK_HOST_STORAGE_KEY);
}
} catch {
// The current page can still use the bridge when session storage is unavailable.
}
return config;
}
function loadLoopbackHostConfig(): LoopbackHostConfig | null {
try {
const raw = window.sessionStorage.getItem(LOOPBACK_HOST_STORAGE_KEY);
if (!raw) return null;
return validateLoopbackHostConfig(JSON.parse(raw) as Partial<LoopbackHostConfig>);
} catch {
return null;
}
}
function validateLoopbackHostConfig(
value: Partial<LoopbackHostConfig>,
): LoopbackHostConfig | null {
const port = Number(value.port);
const token = typeof value.token === "string" ? value.token : "";
if (!Number.isInteger(port) || port < 1 || port > 65_535) return null;
if (!/^[A-Za-z0-9_-]{32,128}$/.test(token)) return null;
return { port, token };
}
function createLoopbackHostApi(config: LoopbackHostConfig): NanobotHostApi {
return {
async pickFolder(): Promise<string | null> {
let response: Response;
try {
response = await fetch(`http://127.0.0.1:${config.port}/v1/pick-folder`, {
method: "POST",
cache: "no-store",
credentials: "omit",
referrerPolicy: "no-referrer",
headers: { Authorization: `Bearer ${config.token}` },
});
} catch {
throw new Error("Native folder picker is unavailable. Reopen Nanobot and try again.");
}
const body = await response.json().catch(() => null) as {
error?: unknown;
path?: unknown;
} | null;
if (!response.ok) {
const detail = typeof body?.error === "string" ? body.error : `HTTP ${response.status}`;
throw new Error(`Native folder picker failed: ${detail}`);
}
if (body?.path === null) return null;
if (typeof body?.path !== "string" || !body.path) {
throw new Error("Native folder picker returned an invalid path.");
}
return body.path;
},
};
}
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;
}