import type { Socket } from "node:net"; import type { Duplex } from "node:stream"; import chokidar from "chokidar"; import * as fsSync from "node:fs"; import fs from "node:fs/promises"; import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http"; import os from "node:os"; import path from "node:path"; import { type WebSocket, WebSocketServer } from "ws"; import type { RuntimeEnv } from "../runtime.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js"; import { detectMime } from "../media/mime.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 ` OpenClaw Canvas

OpenClaw 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; } const tryOpen = async (relative: string) => { try { return await openFileWithinRoot({ rootDir: rootReal, relativePath: relative }); } catch (err) { if (err instanceof SafeOpenError) { return null; } throw err; } }; if (normalized.endsWith("/")) { return await tryOpen(path.posix.join(rel, "index.html")); } const candidate = path.join(rootReal, rel); try { const st = await fs.lstat(candidate); if (st.isSymbolicLink()) { return null; } if (st.isDirectory()) { return await tryOpen(path.posix.join(rel, "index.html")); } } catch { // ignore } return await tryOpen(rel); } function isDisabledByEnv() { if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_CANVAS_HOST)) { return true; } if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_CANVAS_HOST)) { 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; } function resolveDefaultCanvasRoot(): string { const candidates = [path.join(os.homedir(), ".openclaw", "canvas")]; const existing = candidates.find((dir) => { try { return fsSync.statSync(dir).isDirectory(); } catch { return false; } }); return existing ?? candidates[0]; } 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 ?? resolveDefaultCanvasRoot()); 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.startsWith(`${basePath}/`)) { return false; } urlPath = urlPath === basePath ? "/" : urlPath.slice(basePath.length) || "/"; } 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 opened = await resolveFilePath(rootReal, urlPath); if (!opened) { if (urlPath === "/" || urlPath.endsWith("/")) { res.statusCode = 404; res.setHeader("Content-Type", "text/html; charset=utf-8"); res.end( `OpenClaw 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 { handle, realPath } = opened; let data: Buffer; try { data = await handle.readFile(); } finally { await handle.close().catch(() => {}); } const lower = realPath.toLowerCase(); const mime = lower.endsWith(".html") || lower.endsWith(".htm") ? "text/html" : ((await detectMime({ filePath: realPath })) ?? "application/octet-stream"); res.setHeader("Cache-Control", "no-store"); if (mime === "text/html") { const html = data.toString("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(data); 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())), ); }, }; }