Files
openclaw/src/canvas-host/a2ui.ts
2026-04-25 12:52:27 +01:00

152 lines
4.7 KiB
TypeScript

import fs from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http";
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 {
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;
let cachedA2uiResolvedAtMs = 0;
const A2UI_ROOT_RETRY_NULL_AFTER_MS = 10_000;
async function resolveA2uiRoot(): Promise<string | null> {
const here = path.dirname(fileURLToPath(import.meta.url));
const entryDir = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : null;
const candidates = [
// Running from source (bun) or dist/canvas-host chunk.
path.resolve(here, "a2ui"),
// Running from dist root chunk (common launchd path).
path.resolve(here, "canvas-host/a2ui"),
path.resolve(here, "../canvas-host/a2ui"),
// Entry path fallbacks (helps when cwd is not the repo root).
...(entryDir
? [
path.resolve(entryDir, "a2ui"),
path.resolve(entryDir, "canvas-host/a2ui"),
path.resolve(entryDir, "../canvas-host/a2ui"),
]
: []),
// Running from dist without copied assets (fallback to source).
path.resolve(here, "../../src/canvas-host/a2ui"),
path.resolve(here, "../src/canvas-host/a2ui"),
// Running from repo root.
path.resolve(process.cwd(), "src/canvas-host/a2ui"),
path.resolve(process.cwd(), "dist/canvas-host/a2ui"),
];
if (process.execPath) {
candidates.unshift(path.resolve(path.dirname(process.execPath), "a2ui"));
}
for (const dir of candidates) {
try {
const indexPath = path.join(dir, "index.html");
const bundlePath = path.join(dir, "a2ui.bundle.js");
await fs.stat(indexPath);
await fs.stat(bundlePath);
return dir;
} catch {
// try next
}
}
return null;
}
async function resolveA2uiRootReal(): Promise<string | null> {
const nowMs = Date.now();
if (
cachedA2uiRootReal !== undefined &&
(cachedA2uiRootReal !== null || nowMs - cachedA2uiResolvedAtMs < A2UI_ROOT_RETRY_NULL_AFTER_MS)
) {
return cachedA2uiRootReal;
}
if (!resolvingA2uiRoot) {
resolvingA2uiRoot = (async () => {
const root = await resolveA2uiRoot();
cachedA2uiRootReal = root ? await fs.realpath(root) : null;
cachedA2uiResolvedAtMs = Date.now();
resolvingA2uiRoot = null;
return cachedA2uiRootReal;
})();
}
return resolvingA2uiRoot;
}
export async function handleA2uiHttpRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const urlRaw = req.url;
if (!urlRaw) {
return false;
}
const url = new URL(urlRaw, "http://localhost");
const basePath = isA2uiPath(url.pathname) ? A2UI_PATH : undefined;
if (!basePath) {
return false;
}
if (req.method !== "GET" && req.method !== "HEAD") {
res.statusCode = 405;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Method Not Allowed");
return true;
}
const a2uiRootReal = await resolveA2uiRootReal();
if (!a2uiRootReal) {
res.statusCode = 503;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("A2UI assets not found");
return true;
}
const rel = url.pathname.slice(basePath.length);
const result = await resolveFileWithinRoot(a2uiRootReal, rel || "/");
if (!result) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("not found");
return true;
}
try {
const lower = lowercasePreservingWhitespace(result.realPath);
const mime =
lower.endsWith(".html") || lower.endsWith(".htm")
? "text/html"
: ((await detectMime({ filePath: result.realPath })) ?? "application/octet-stream");
res.setHeader("Cache-Control", "no-store");
if (req.method === "HEAD") {
res.setHeader("Content-Type", mime === "text/html" ? "text/html; charset=utf-8" : mime);
res.end();
return true;
}
if (mime === "text/html") {
const buf = await result.handle.readFile({ encoding: "utf8" });
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(injectCanvasLiveReload(buf));
return true;
}
res.setHeader("Content-Type", mime);
res.end(await result.handle.readFile());
return true;
} finally {
await result.handle.close().catch(() => {});
}
}