163 lines
6.0 KiB
TypeScript
163 lines
6.0 KiB
TypeScript
import type { LocalRuntimeTransport } from "@/services/local-runtime";
|
|
|
|
export type LocalRuntimeEvent = {
|
|
type: string;
|
|
data: string;
|
|
id?: string;
|
|
};
|
|
|
|
export type LocalRuntimeEventStreamOptions = {
|
|
signal?: AbortSignal;
|
|
lastEventId?: string;
|
|
onEvent(event: LocalRuntimeEvent): void;
|
|
};
|
|
|
|
export class CanvasRuntimeStreamError extends Error {
|
|
constructor(
|
|
readonly code: string,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "CanvasRuntimeStreamError";
|
|
}
|
|
}
|
|
|
|
const MAX_EVENT_BYTES = 256 * 1024;
|
|
const AUTO_CONNECT_MODES = new Set(["new", "recent", "choose"]);
|
|
|
|
export function shouldAutoConnectCanvasRuntime(params: URLSearchParams) {
|
|
return AUTO_CONNECT_MODES.has(params.get("mode") ?? "");
|
|
}
|
|
|
|
type CanvasRuntimeStore = {
|
|
getState(): {
|
|
connection: string;
|
|
modules: Array<{ id: string }>;
|
|
error?: string;
|
|
connect(signal?: AbortSignal): Promise<void>;
|
|
};
|
|
};
|
|
|
|
export async function prepareCanvasRuntimeConnection(store: CanvasRuntimeStore, signal?: AbortSignal) {
|
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
await store.getState().connect(signal);
|
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
const state = store.getState();
|
|
if (state.connection !== "connected" || state.error) {
|
|
throw new CanvasRuntimeStreamError("canvas_runtime_unavailable", "本机运行时尚未连接");
|
|
}
|
|
if (!state.modules.some((module) => module.id === "canvas-agent")) {
|
|
throw new CanvasRuntimeStreamError("canvas_module_unavailable", "Canvas Agent 模块未加载");
|
|
}
|
|
}
|
|
|
|
export function waitForCanvasRuntimeReconnect(signal: AbortSignal, delayMs = 1_000) {
|
|
return new Promise<void>((resolve) => {
|
|
if (signal.aborted) {
|
|
resolve();
|
|
return;
|
|
}
|
|
const finish = () => {
|
|
clearTimeout(timer);
|
|
signal.removeEventListener("abort", finish);
|
|
resolve();
|
|
};
|
|
const timer = setTimeout(finish, delayMs);
|
|
signal.addEventListener("abort", finish, { once: true });
|
|
});
|
|
}
|
|
|
|
export async function postCanvasRuntimeState(client: LocalRuntimeTransport, clientId: string, snapshot: unknown) {
|
|
try {
|
|
const response = await client.request(`/canvas/state?clientId=${encodeURIComponent(clientId)}`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(snapshot),
|
|
});
|
|
if (!response.ok) throw new Error("state rejected");
|
|
} catch {
|
|
throw new CanvasRuntimeStreamError("canvas_state_sync_failed", "画布状态同步失败");
|
|
}
|
|
}
|
|
|
|
export async function consumeLocalRuntimeEventStream(client: LocalRuntimeTransport, path: string, options: LocalRuntimeEventStreamOptions) {
|
|
if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
const headers = new Headers();
|
|
if (options.lastEventId) headers.set("Last-Event-ID", options.lastEventId);
|
|
const response = await client.request(path, {
|
|
method: "GET",
|
|
headers,
|
|
signal: options.signal,
|
|
});
|
|
if (!response.ok) throw new CanvasRuntimeStreamError("canvas_stream_unavailable", "Canvas Agent 事件流不可用");
|
|
if (!response.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream") || !response.body) {
|
|
throw new CanvasRuntimeStreamError("canvas_stream_invalid", "Canvas Agent 事件流响应无效");
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
let buffer = "";
|
|
let eventType = "";
|
|
let eventId: string | undefined;
|
|
let data: string[] = [];
|
|
let eventBytes = 0;
|
|
const dispatch = () => {
|
|
if (data.length) {
|
|
options.onEvent({
|
|
type: eventType || "message",
|
|
data: data.join("\n"),
|
|
...(eventId !== undefined ? { id: eventId } : {}),
|
|
});
|
|
}
|
|
eventType = "";
|
|
eventId = undefined;
|
|
data = [];
|
|
eventBytes = 0;
|
|
};
|
|
const consumeLine = (line: string) => {
|
|
eventBytes += new TextEncoder().encode(line).byteLength + 1;
|
|
if (eventBytes > MAX_EVENT_BYTES) {
|
|
throw new CanvasRuntimeStreamError("canvas_event_too_large", "Canvas Agent 事件超过安全上限");
|
|
}
|
|
if (!line) {
|
|
dispatch();
|
|
return;
|
|
}
|
|
if (line.startsWith(":")) return;
|
|
const separator = line.indexOf(":");
|
|
const field = separator < 0 ? line : line.slice(0, separator);
|
|
const value = separator < 0 ? "" : line.slice(separator + 1).replace(/^ /, "");
|
|
if (field === "event") eventType = value;
|
|
else if (field === "data") data.push(value);
|
|
else if (field === "id" && !value.includes("\0")) eventId = value;
|
|
};
|
|
|
|
try {
|
|
while (true) {
|
|
const item = await reader.read();
|
|
if (item.done) break;
|
|
buffer += decoder.decode(item.value, { stream: true });
|
|
let newline = buffer.indexOf("\n");
|
|
while (newline >= 0) {
|
|
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
|
buffer = buffer.slice(newline + 1);
|
|
consumeLine(line);
|
|
newline = buffer.indexOf("\n");
|
|
}
|
|
if (eventBytes + new TextEncoder().encode(buffer).byteLength > MAX_EVENT_BYTES) {
|
|
throw new CanvasRuntimeStreamError("canvas_event_too_large", "Canvas Agent 事件超过安全上限");
|
|
}
|
|
}
|
|
buffer += decoder.decode();
|
|
if (buffer) consumeLine(buffer.replace(/\r$/, ""));
|
|
dispatch();
|
|
} catch (error) {
|
|
if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
if (error instanceof CanvasRuntimeStreamError) throw error;
|
|
throw new CanvasRuntimeStreamError("canvas_stream_invalid", "Canvas Agent 事件流响应无效");
|
|
} finally {
|
|
await reader.cancel().catch(() => undefined);
|
|
reader.releaseLock();
|
|
}
|
|
}
|