import fs from "node:fs/promises"; import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { Socket } from "node:net"; import os from "node:os"; import path from "node:path"; import type { Duplex } from "node:stream"; import chokidar from "chokidar"; 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 { CANVAS_HOST_PATH, CANVAS_WS_PATH, handleA2uiHttpRequest, injectCanvasLiveReload, } from "./a2ui.js"; export type CanvasHostOpts = { runtime: RuntimeEnv; rootDir?: string; port?: number; listenHost?: string; allowInTests?: boolean; liveReload?: boolean; }; export type CanvasHostServerOpts = CanvasHostOpts & { handler?: CanvasHostHandler; ownsHandler?: boolean; }; export type CanvasHostServer = { port: number; rootDir: string; close: () => Promise; }; export type CanvasHostHandlerOpts = { runtime: RuntimeEnv; rootDir?: string; basePath?: string; allowInTests?: boolean; liveReload?: boolean; }; export type CanvasHostHandler = { rootDir: string; basePath: string; handleHttpRequest: (req: IncomingMessage, res: ServerResponse) => Promise; handleUpgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => boolean; close: () => Promise; }; function defaultIndexHTML() { return ` Clawdbot Canvas

Clawdbot Canvas

Interactive test page (auto-reload enabled)
Ready.
`; } function normalizeUrlPath(rawPath: string): string { const decoded = decodeURIComponent(rawPath || "/"); const normalized = path.posix.normalize(decoded); return normalized.startsWith("/") ? normalized : `/${normalized}`; } async function resolveFilePath(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; } } function isDisabledByEnv() { if (process.env.CLAWDBOT_SKIP_CANVAS_HOST === "1") return true; if (process.env.NODE_ENV === "test") return true; if (process.env.VITEST) return true; return false; } function normalizeBasePath(rawPath: string | undefined) { const trimmed = (rawPath ?? CANVAS_HOST_PATH).trim(); const normalized = normalizeUrlPath(trimmed || CANVAS_HOST_PATH); if (normalized === "/") return "/"; return normalized.replace(/\/+$/, ""); } async function prepareCanvasRoot(rootDir: string) { await ensureDir(rootDir); const rootReal = await fs.realpath(rootDir); try { const indexPath = path.join(rootReal, "index.html"); await fs.stat(indexPath); } catch { try { await fs.writeFile(path.join(rootReal, "index.html"), defaultIndexHTML(), "utf8"); } catch { // ignore; we'll still serve the "missing file" message if needed. } } return rootReal; } export async function createCanvasHostHandler( opts: CanvasHostHandlerOpts, ): Promise { const basePath = normalizeBasePath(opts.basePath); if (isDisabledByEnv() && opts.allowInTests !== true) { return { rootDir: "", basePath, handleHttpRequest: async () => false, handleUpgrade: () => false, close: async () => {}, }; } const rootDir = resolveUserPath(opts.rootDir ?? path.join(os.homedir(), "clawd", "canvas")); const rootReal = await prepareCanvasRoot(rootDir); const liveReload = opts.liveReload !== false; const wss = liveReload ? new WebSocketServer({ noServer: true }) : null; const sockets = new Set(); if (wss) { wss.on("connection", (ws) => { sockets.add(ws); ws.on("close", () => sockets.delete(ws)); }); } let debounce: NodeJS.Timeout | null = null; const broadcastReload = () => { if (!liveReload) return; for (const ws of sockets) { try { ws.send("reload"); } catch { // ignore } } }; const scheduleReload = () => { if (debounce) clearTimeout(debounce); debounce = setTimeout(() => { debounce = null; broadcastReload(); }, 75); debounce.unref?.(); }; let watcherClosed = false; const watcher = liveReload ? chokidar.watch(rootReal, { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 75, pollInterval: 10 }, usePolling: opts.allowInTests === true, ignored: [ /(^|[\\/])\../, // dotfiles /(^|[\\/])node_modules([\\/]|$)/, ], }) : null; watcher?.on("all", () => scheduleReload()); watcher?.on("error", (err) => { if (watcherClosed) return; watcherClosed = true; opts.runtime.error( `canvasHost watcher error: ${String(err)} (live reload disabled; consider canvasHost.liveReload=false or a smaller canvasHost.root)`, ); void watcher.close().catch(() => {}); }); const handleUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer) => { if (!wss) return false; const url = new URL(req.url ?? "/", "http://localhost"); if (url.pathname !== CANVAS_WS_PATH) return false; wss.handleUpgrade(req, socket as Socket, head, (ws) => { wss.emit("connection", ws, req); }); return true; }; const handleHttpRequest = async (req: IncomingMessage, res: ServerResponse) => { const urlRaw = req.url; if (!urlRaw) return false; try { const url = new URL(urlRaw, "http://localhost"); if (url.pathname === CANVAS_WS_PATH) { res.statusCode = liveReload ? 426 : 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end(liveReload ? "upgrade required" : "not found"); return true; } let urlPath = url.pathname; if (basePath !== "/") { if (urlPath === basePath) { urlPath = "/"; } else if (urlPath.startsWith(`${basePath}/`)) { urlPath = urlPath.slice(basePath.length) || "/"; } else { 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 filePath = await resolveFilePath(rootReal, urlPath); if (!filePath) { if (urlPath === "/" || urlPath.endsWith("/")) { res.statusCode = 404; res.setHeader("Content-Type", "text/html; charset=utf-8"); res.end( `Clawdbot Canvas
Missing file.\nCreate ${rootDir}/index.html
`, ); return true; } 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" : ((await 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(liveReload ? injectCanvasLiveReload(html) : html); return true; } res.setHeader("Content-Type", mime); res.end(await fs.readFile(filePath)); return true; } catch (err) { opts.runtime.error(`canvasHost request failed: ${String(err)}`); res.statusCode = 500; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("error"); return true; } }; return { rootDir, basePath, handleHttpRequest, handleUpgrade, close: async () => { if (debounce) clearTimeout(debounce); watcherClosed = true; await watcher?.close().catch(() => {}); if (wss) { await new Promise((resolve) => wss.close(() => resolve())); } }, }; } export async function startCanvasHost(opts: CanvasHostServerOpts): Promise { if (isDisabledByEnv() && opts.allowInTests !== true) { return { port: 0, rootDir: "", close: async () => {} }; } const handler = opts.handler ?? (await createCanvasHostHandler({ runtime: opts.runtime, rootDir: opts.rootDir, basePath: CANVAS_HOST_PATH, allowInTests: opts.allowInTests, liveReload: opts.liveReload, })); const ownsHandler = opts.ownsHandler ?? opts.handler === undefined; const bindHost = opts.listenHost?.trim() || "0.0.0.0"; const server: Server = http.createServer((req, res) => { if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return; void (async () => { if (await handleA2uiHttpRequest(req, res)) return; if (await handler.handleHttpRequest(req, res)) return; res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Not Found"); })().catch((err) => { opts.runtime.error(`canvasHost request failed: ${String(err)}`); res.statusCode = 500; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("error"); }); }); server.on("upgrade", (req, socket, head) => { if (handler.handleUpgrade(req, socket, head)) return; socket.destroy(); }); const listenPort = typeof opts.port === "number" && Number.isFinite(opts.port) && opts.port > 0 ? opts.port : 0; await new Promise((resolve, reject) => { const onError = (err: NodeJS.ErrnoException) => { server.off("listening", onListening); reject(err); }; const onListening = () => { server.off("error", onError); resolve(); }; server.once("error", onError); server.once("listening", onListening); server.listen(listenPort, bindHost); }); const addr = server.address(); const boundPort = typeof addr === "object" && addr ? addr.port : 0; opts.runtime.log( `canvas host listening on http://${bindHost}:${boundPort} (root ${handler.rootDir})`, ); return { port: boundPort, rootDir: handler.rootDir, close: async () => { if (ownsHandler) await handler.close(); await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())), ); }, }; }