From a1b34ef0ef5799626880ebf12c75ae6be5cab665 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 16:45:16 +0100 Subject: [PATCH] refactor: extract canvas a2ui handler --- src/canvas-host/a2ui.ts | 204 +++++++++++++++++++++++++++++++++ src/canvas-host/server.test.ts | 3 +- src/canvas-host/server.ts | 126 +------------------- 3 files changed, 208 insertions(+), 125 deletions(-) create mode 100644 src/canvas-host/a2ui.ts diff --git a/src/canvas-host/a2ui.ts b/src/canvas-host/a2ui.ts new file mode 100644 index 000000000..e76617a4f --- /dev/null +++ b/src/canvas-host/a2ui.ts @@ -0,0 +1,204 @@ +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"; + +export const A2UI_PATH = "/__clawdis__/a2ui"; +const WS_PATH = "/__clawdis/ws"; + +let cachedA2uiRootReal: string | null | undefined; +let resolvingA2uiRoot: Promise | null = null; + +async function resolveA2uiRoot(): Promise { + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + // Running from source (tsx) or dist (tsc + copied assets). + path.resolve(here, "a2ui"), + // Running from dist without copied assets (fallback to source). + 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 { + if (cachedA2uiRootReal !== undefined) return cachedA2uiRootReal; + if (!resolvingA2uiRoot) { + resolvingA2uiRoot = (async () => { + const root = await resolveA2uiRoot(); + cachedA2uiRootReal = root ? await fs.realpath(root) : null; + return cachedA2uiRootReal; + })(); + } + return resolvingA2uiRoot; +} + +function normalizeUrlPath(rawPath: string): string { + const decoded = decodeURIComponent(rawPath || "/"); + const normalized = path.posix.normalize(decoded); + return normalized.startsWith("/") ? normalized : `/${normalized}`; +} + +async function resolveA2uiFilePath(rootReal: string, urlPath: string) { + const normalized = normalizeUrlPath(urlPath); + const rel = normalized.replace(/^\/+/, ""); + if (rel.split("/").some((p) => p === "..")) return null; + + let candidate = path.join(rootReal, rel); + if (normalized.endsWith("/")) { + candidate = path.join(candidate, "index.html"); + } + + try { + const st = await fs.stat(candidate); + if (st.isDirectory()) { + candidate = path.join(candidate, "index.html"); + } + } catch { + // ignore + } + + const rootPrefix = rootReal.endsWith(path.sep) + ? rootReal + : `${rootReal}${path.sep}`; + try { + const lstat = await fs.lstat(candidate); + if (lstat.isSymbolicLink()) return null; + const real = await fs.realpath(candidate); + if (!real.startsWith(rootPrefix)) return null; + return real; + } catch { + return null; + } +} + +export function injectCanvasLiveReload(html: string): string { + const snippet = ` + +`.trim(); + + const idx = html.toLowerCase().lastIndexOf(""); + 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, +): Promise { + const urlRaw = req.url; + if (!urlRaw) return false; + + const url = new URL(urlRaw, "http://localhost"); + if (url.pathname !== A2UI_PATH && !url.pathname.startsWith(`${A2UI_PATH}/`)) { + 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(A2UI_PATH.length); + const filePath = await resolveA2uiFilePath(a2uiRootReal, rel || "/"); + if (!filePath) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("not found"); + return true; + } + + const lower = filePath.toLowerCase(); + const mime = + lower.endsWith(".html") || lower.endsWith(".htm") + ? "text/html" + : (detectMime({ filePath }) ?? "application/octet-stream"); + res.setHeader("Cache-Control", "no-store"); + + if (mime === "text/html") { + const html = await fs.readFile(filePath, "utf8"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(injectCanvasLiveReload(html)); + return true; + } + + res.setHeader("Content-Type", mime); + res.end(await fs.readFile(filePath)); + return true; +} diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index dba9d8d95..45da42e1e 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -4,7 +4,8 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { WebSocket } from "ws"; import { defaultRuntime } from "../runtime.js"; -import { injectCanvasLiveReload, startCanvasHost } from "./server.js"; +import { startCanvasHost } from "./server.js"; +import { injectCanvasLiveReload } from "./a2ui.js"; describe("canvas host", () => { it("injects live reload script", () => { diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts index b3d868a49..c4a83a0f2 100644 --- a/src/canvas-host/server.ts +++ b/src/canvas-host/server.ts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import http, { type Server } from "node:http"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import chokidar from "chokidar"; import express from "express"; @@ -10,6 +9,7 @@ import { type WebSocket, WebSocketServer } from "ws"; import { detectMime } from "../media/mime.js"; import type { RuntimeEnv } from "../runtime.js"; import { ensureDir, resolveUserPath } from "../utils.js"; +import { handleA2uiHttpRequest, injectCanvasLiveReload } from "./a2ui.js"; export type CanvasHostOpts = { runtime: RuntimeEnv; @@ -26,93 +26,6 @@ export type CanvasHostServer = { }; const WS_PATH = "/__clawdis/ws"; -const A2UI_PATH = "/__clawdis__/a2ui"; - -async function resolveA2uiRoot(): Promise { - const here = path.dirname(fileURLToPath(import.meta.url)); - const candidates = [ - // Running from source (tsx) or dist (tsc + copied assets). - path.resolve(here, "a2ui"), - // Running from dist without copied assets (fallback to source). - 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; -} - -export function injectCanvasLiveReload(html: string): string { - const snippet = ` - -`.trim(); - - const idx = html.toLowerCase().lastIndexOf(""); - if (idx >= 0) { - return `${html.slice(0, idx)}\n${snippet}\n${html.slice(idx)}`; - } - return `${html}\n${snippet}\n`; -} function defaultIndexHTML() { return ` @@ -268,9 +181,6 @@ export async function startCanvasHost( } const bindHost = opts.listenHost?.trim() || "0.0.0.0"; - const a2uiRoot = await resolveA2uiRoot(); - const a2uiRootReal = a2uiRoot ? await fs.realpath(a2uiRoot) : null; - const app = express(); app.disable("x-powered-by"); @@ -282,39 +192,7 @@ export async function startCanvasHost( return; } - if ( - url.pathname === A2UI_PATH || - url.pathname.startsWith(`${A2UI_PATH}/`) - ) { - if (!a2uiRootReal) { - res - .status(503) - .type("text/plain; charset=utf-8") - .send("A2UI assets not found"); - return; - } - const rel = url.pathname.slice(A2UI_PATH.length); - const filePath = await resolveFilePath(a2uiRootReal, rel || "/"); - if (!filePath) { - res.status(404).send("not found"); - return; - } - const lower = filePath.toLowerCase(); - const mime = - lower.endsWith(".html") || lower.endsWith(".htm") - ? "text/html" - : (detectMime({ filePath }) ?? "application/octet-stream"); - res.setHeader("Cache-Control", "no-store"); - if (mime === "text/html") { - const html = await fs.readFile(filePath, "utf8"); - res - .type("text/html; charset=utf-8") - .send(injectCanvasLiveReload(html)); - return; - } - res.type(mime).send(await fs.readFile(filePath)); - return; - } + if (await handleA2uiHttpRequest(req, res)) return; const filePath = await resolveFilePath(rootReal, url.pathname); if (!filePath) {