diff --git a/package.json b/package.json
index a12b7b627..2d10db5c3 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
"ajv": "^8.17.1",
"body-parser": "^2.2.1",
"chalk": "^5.6.2",
+ "chokidar": "^3.6.0",
"commander": "^14.0.2",
"croner": "^9.1.0",
"detect-libc": "^2.1.2",
diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts
new file mode 100644
index 000000000..a159ca376
--- /dev/null
+++ b/src/canvas-host/server.test.ts
@@ -0,0 +1,71 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+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";
+
+describe("canvas host", () => {
+ it("injects live reload script", () => {
+ const out = injectCanvasLiveReload("
Hello");
+ expect(out).toContain("/__clawdis/ws");
+ expect(out).toContain("location.reload");
+ });
+
+ it("serves HTML with injection and broadcasts reload on file changes", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-canvas-"));
+ const index = path.join(dir, "index.html");
+ await fs.writeFile(index, "v1", "utf8");
+
+ const server = await startCanvasHost({
+ runtime: defaultRuntime,
+ rootDir: dir,
+ port: 0,
+ bind: "loopback",
+ allowInTests: true,
+ });
+
+ try {
+ const res = await fetch(`http://127.0.0.1:${server.port}/`);
+ const html = await res.text();
+ expect(res.status).toBe(200);
+ expect(html).toContain("v1");
+ expect(html).toContain("/__clawdis/ws");
+
+ const ws = new WebSocket(`ws://127.0.0.1:${server.port}/__clawdis/ws`);
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error("ws open timeout")),
+ 2000,
+ );
+ ws.on("open", () => {
+ clearTimeout(timer);
+ resolve();
+ });
+ ws.on("error", (err) => {
+ clearTimeout(timer);
+ reject(err);
+ });
+ });
+
+ const msg = new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error("reload timeout")),
+ 4000,
+ );
+ ws.on("message", (data) => {
+ clearTimeout(timer);
+ resolve(String(data));
+ });
+ });
+
+ await fs.writeFile(index, "v2", "utf8");
+ expect(await msg).toBe("reload");
+ ws.close();
+ } finally {
+ await server.close();
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts
new file mode 100644
index 000000000..2a8582b16
--- /dev/null
+++ b/src/canvas-host/server.ts
@@ -0,0 +1,247 @@
+import fs from "node:fs/promises";
+import http, { type Server } from "node:http";
+import os from "node:os";
+import path from "node:path";
+
+import chokidar from "chokidar";
+import express from "express";
+import { type WebSocket, WebSocketServer } from "ws";
+import type { BridgeBindMode } from "../config/config.js";
+import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
+import { detectMime } from "../media/mime.js";
+import type { RuntimeEnv } from "../runtime.js";
+import { ensureDir, resolveUserPath } from "../utils.js";
+
+export type CanvasHostOpts = {
+ runtime: RuntimeEnv;
+ rootDir?: string;
+ port?: number;
+ bind?: BridgeBindMode;
+ allowInTests?: boolean;
+};
+
+export type CanvasHostServer = {
+ port: number;
+ rootDir: string;
+ close: () => Promise;
+};
+
+const WS_PATH = "/__clawdis/ws";
+
+export function injectCanvasLiveReload(html: string): string {
+ const snippet = `
+
+`.trim();
+
+ const idx = html.toLowerCase().lastIndexOf("