perf: split canvas a2ui shared imports

This commit is contained in:
Peter Steinberger
2026-04-25 12:48:15 +01:00
parent 40e4a00c8e
commit 56573185f2
4 changed files with 90 additions and 74 deletions

View File

@@ -0,0 +1,72 @@
import { lowercasePreservingWhitespace } from "../shared/string-coerce.js";
export const A2UI_PATH = "/__openclaw__/a2ui";
export const CANVAS_HOST_PATH = "/__openclaw__/canvas";
export const CANVAS_WS_PATH = "/__openclaw__/ws";
export function isA2uiPath(pathname: string): boolean {
return pathname === A2UI_PATH || pathname.startsWith(`${A2UI_PATH}/`);
}
export function injectCanvasLiveReload(html: string): string {
const snippet = `
<script>
(() => {
// Cross-platform action bridge helper.
// Works on:
// - iOS: window.webkit.messageHandlers.openclawCanvasA2UIAction.postMessage(...)
// - Android: window.openclawCanvasA2UIAction.postMessage(...)
const handlerNames = ["openclawCanvasA2UIAction"];
function postToNode(payload) {
try {
const raw = typeof payload === "string" ? payload : JSON.stringify(payload);
for (const name of handlerNames) {
const iosHandler = globalThis.webkit?.messageHandlers?.[name];
if (iosHandler && typeof iosHandler.postMessage === "function") {
iosHandler.postMessage(raw);
return true;
}
const androidHandler = globalThis[name];
if (androidHandler && typeof androidHandler.postMessage === "function") {
// Important: call as a method on the interface object (binding matters on Android WebView).
androidHandler.postMessage(raw);
return true;
}
}
} catch {}
return false;
}
function sendUserAction(userAction) {
const id =
(userAction && typeof userAction.id === "string" && userAction.id.trim()) ||
(globalThis.crypto?.randomUUID?.() ?? String(Date.now()));
const action = { ...userAction, id };
return postToNode({ userAction: action });
}
globalThis.OpenClaw = globalThis.OpenClaw ?? {};
globalThis.OpenClaw.postMessage = postToNode;
globalThis.OpenClaw.sendUserAction = sendUserAction;
globalThis.openclawPostMessage = postToNode;
globalThis.openclawSendUserAction = sendUserAction;
try {
const cap = new URLSearchParams(location.search).get("oc_cap");
const proto = location.protocol === "https:" ? "wss" : "ws";
const capQuery = cap ? "?oc_cap=" + encodeURIComponent(cap) : "";
const ws = new WebSocket(proto + "://" + location.host + ${JSON.stringify(CANVAS_WS_PATH)} + capQuery);
ws.onmessage = (ev) => {
if (String(ev.data || "") === "reload") location.reload();
};
} catch {}
})();
</script>
`.trim();
const idx = lowercasePreservingWhitespace(html).lastIndexOf("</body>");
if (idx >= 0) {
return `${html.slice(0, idx)}\n${snippet}\n${html.slice(idx)}`;
}
return `${html}\n${snippet}\n`;
}

View File

@@ -4,13 +4,16 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { detectMime } from "../media/mime.js";
import { lowercasePreservingWhitespace } from "../shared/string-coerce.js";
import { A2UI_PATH, injectCanvasLiveReload, isA2uiPath } from "./a2ui-shared.js";
import { resolveFileWithinRoot } from "./file-resolver.js";
export const A2UI_PATH = "/__openclaw__/a2ui";
export const CANVAS_HOST_PATH = "/__openclaw__/canvas";
export const CANVAS_WS_PATH = "/__openclaw__/ws";
export {
A2UI_PATH,
CANVAS_HOST_PATH,
CANVAS_WS_PATH,
injectCanvasLiveReload,
isA2uiPath,
} from "./a2ui-shared.js";
let cachedA2uiRootReal: string | null | undefined;
let resolvingA2uiRoot: Promise<string | null> | null = null;
@@ -79,67 +82,6 @@ async function resolveA2uiRootReal(): Promise<string | null> {
return resolvingA2uiRoot;
}
export function injectCanvasLiveReload(html: string): string {
const snippet = `
<script>
(() => {
// Cross-platform action bridge helper.
// Works on:
// - iOS: window.webkit.messageHandlers.openclawCanvasA2UIAction.postMessage(...)
// - Android: window.openclawCanvasA2UIAction.postMessage(...)
const handlerNames = ["openclawCanvasA2UIAction"];
function postToNode(payload) {
try {
const raw = typeof payload === "string" ? payload : JSON.stringify(payload);
for (const name of handlerNames) {
const iosHandler = globalThis.webkit?.messageHandlers?.[name];
if (iosHandler && typeof iosHandler.postMessage === "function") {
iosHandler.postMessage(raw);
return true;
}
const androidHandler = globalThis[name];
if (androidHandler && typeof androidHandler.postMessage === "function") {
// Important: call as a method on the interface object (binding matters on Android WebView).
androidHandler.postMessage(raw);
return true;
}
}
} catch {}
return false;
}
function sendUserAction(userAction) {
const id =
(userAction && typeof userAction.id === "string" && userAction.id.trim()) ||
(globalThis.crypto?.randomUUID?.() ?? String(Date.now()));
const action = { ...userAction, id };
return postToNode({ userAction: action });
}
globalThis.OpenClaw = globalThis.OpenClaw ?? {};
globalThis.OpenClaw.postMessage = postToNode;
globalThis.OpenClaw.sendUserAction = sendUserAction;
globalThis.openclawPostMessage = postToNode;
globalThis.openclawSendUserAction = sendUserAction;
try {
const cap = new URLSearchParams(location.search).get("oc_cap");
const proto = location.protocol === "https:" ? "wss" : "ws";
const capQuery = cap ? "?oc_cap=" + encodeURIComponent(cap) : "";
const ws = new WebSocket(proto + "://" + location.host + ${JSON.stringify(CANVAS_WS_PATH)} + capQuery);
ws.onmessage = (ev) => {
if (String(ev.data || "") === "reload") location.reload();
};
} catch {}
})();
</script>
`.trim();
const idx = lowercasePreservingWhitespace(html).lastIndexOf("</body>");
if (idx >= 0) {
return `${html.slice(0, idx)}\n${snippet}\n${html.slice(idx)}`;
}
return `${html}\n${snippet}\n`;
}
export async function handleA2uiHttpRequest(
req: IncomingMessage,
res: ServerResponse,
@@ -150,8 +92,7 @@ export async function handleA2uiHttpRequest(
}
const url = new URL(urlRaw, "http://localhost");
const basePath =
url.pathname === A2UI_PATH || url.pathname.startsWith(`${A2UI_PATH}/`) ? A2UI_PATH : undefined;
const basePath = isA2uiPath(url.pathname) ? A2UI_PATH : undefined;
if (!basePath) {
return false;
}

View File

@@ -9,9 +9,8 @@ import {
A2UI_PATH,
CANVAS_HOST_PATH,
CANVAS_WS_PATH,
handleA2uiHttpRequest,
injectCanvasLiveReload,
} from "./a2ui.js";
} from "./a2ui-shared.js";
type MockWatcher = {
on: (event: string, cb: (...args: unknown[]) => void) => MockWatcher;
@@ -106,6 +105,7 @@ async function captureHandlerResponse(
}
async function captureA2uiResponse(url: string, method = "GET"): Promise<CapturedResponse> {
const { handleA2uiHttpRequest } = await import("./a2ui.js");
return await captureHttpResponse(handleA2uiHttpRequest, url, method);
}

View File

@@ -20,9 +20,9 @@ import { ensureDir, resolveUserPath } from "../utils.js";
import {
CANVAS_HOST_PATH,
CANVAS_WS_PATH,
handleA2uiHttpRequest,
injectCanvasLiveReload,
} from "./a2ui.js";
isA2uiPath,
} from "./a2ui-shared.js";
import { normalizeUrlPath, resolveFileWithinRoot } from "./file-resolver.js";
type ChokidarWatch = typeof import("chokidar").watch;
@@ -469,8 +469,11 @@ export async function startCanvasHost(opts: CanvasHostServerOpts): Promise<Canva
return;
}
void (async () => {
if (await handleA2uiHttpRequest(req, res)) {
return;
if (req.url && isA2uiPath(new URL(req.url, "http://localhost").pathname)) {
const { handleA2uiHttpRequest } = await import("./a2ui.js");
if (await handleA2uiHttpRequest(req, res)) {
return;
}
}
if (await handler.handleHttpRequest(req, res)) {
return;