diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c4616ac4..f41dc7112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Features - Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app. - Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling. +- Canvas host: add `canvasHost.liveReload` to disable file watching + reload injection. - UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI. - Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes. - Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC. diff --git a/docs/configuration.md b/docs/configuration.md index ade33dde8..df0fb4949 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1043,11 +1043,15 @@ The server: - also serves A2UI at `/__clawdis__/a2ui/` and is advertised to nodes as `canvasHostUrl` (always used by nodes for Canvas/A2UI) +Disable live reload (and file watching) if the directory is large or you hit `EMFILE`: +- config: `canvasHost: { liveReload: false }` + ```json5 { canvasHost: { root: "~/clawd/canvas", - port: 18793 + port: 18793, + liveReload: true } } ``` diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index ec4f6ec9c..ea72989ef 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -49,6 +49,42 @@ describe("canvas host", () => { } }); + it("skips live reload injection when disabled", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-canvas-")); + await fs.writeFile( + path.join(dir, "index.html"), + "no-reload", + "utf8", + ); + + const server = await startCanvasHost({ + runtime: defaultRuntime, + rootDir: dir, + port: 0, + listenHost: "127.0.0.1", + allowInTests: true, + liveReload: false, + }); + + try { + const res = await fetch( + `http://127.0.0.1:${server.port}${CANVAS_HOST_PATH}/`, + ); + const html = await res.text(); + expect(res.status).toBe(200); + expect(html).toContain("no-reload"); + expect(html).not.toContain(CANVAS_WS_PATH); + + const wsRes = await fetch( + `http://127.0.0.1:${server.port}${CANVAS_WS_PATH}`, + ); + expect(wsRes.status).toBe(404); + } finally { + await server.close(); + await fs.rm(dir, { recursive: true, force: true }); + } + }); + it("serves canvas content from the mounted base path", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-canvas-")); await fs.writeFile( diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts index 00dc1f83e..7694c1d4f 100644 --- a/src/canvas-host/server.ts +++ b/src/canvas-host/server.ts @@ -27,6 +27,7 @@ export type CanvasHostOpts = { port?: number; listenHost?: string; allowInTests?: boolean; + liveReload?: boolean; }; export type CanvasHostServerOpts = CanvasHostOpts & { @@ -45,6 +46,7 @@ export type CanvasHostHandlerOpts = { rootDir?: string; basePath?: string; allowInTests?: boolean; + liveReload?: boolean; }; export type CanvasHostHandler = { @@ -234,15 +236,19 @@ export async function createCanvasHostHandler( ); const rootReal = await prepareCanvasRoot(rootDir); - const wss = new WebSocketServer({ noServer: true }); + const liveReload = opts.liveReload !== false; + const wss = liveReload ? new WebSocketServer({ noServer: true }) : null; const sockets = new Set(); - wss.on("connection", (ws) => { - sockets.add(ws); - ws.on("close", () => sockets.delete(ws)); - }); + 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"); @@ -261,19 +267,23 @@ export async function createCanvasHostHandler( }; let watcherClosed = false; - const watcher = chokidar.watch(rootReal, { - ignoreInitial: true, - awaitWriteFinish: { stabilityThreshold: 75, pollInterval: 10 }, - ignored: [ - /(^|[\\/])\../, // dotfiles - /(^|[\\/])node_modules([\\/]|$)/, - ], - }); - watcher.on("all", () => scheduleReload()); - watcher.on("error", (err) => { + const watcher = liveReload + ? chokidar.watch(rootReal, { + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 75, pollInterval: 10 }, + 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)}`); + opts.runtime.error( + `canvasHost watcher error: ${String(err)} (live reload disabled; consider canvasHost.liveReload=false or a smaller canvasHost.root)`, + ); void watcher.close().catch(() => {}); }); @@ -282,6 +292,7 @@ export async function createCanvasHostHandler( 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) => { @@ -300,9 +311,9 @@ export async function createCanvasHostHandler( try { const url = new URL(urlRaw, "http://localhost"); if (url.pathname === CANVAS_WS_PATH) { - res.statusCode = 426; + res.statusCode = liveReload ? 426 : 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("upgrade required"); + res.end(liveReload ? "upgrade required" : "not found"); return true; } @@ -350,7 +361,7 @@ export async function createCanvasHostHandler( if (mime === "text/html") { const html = await fs.readFile(filePath, "utf8"); res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(injectCanvasLiveReload(html)); + res.end(liveReload ? injectCanvasLiveReload(html) : html); return true; } @@ -374,8 +385,10 @@ export async function createCanvasHostHandler( close: async () => { if (debounce) clearTimeout(debounce); watcherClosed = true; - await watcher.close().catch(() => {}); - await new Promise((resolve) => wss.close(() => resolve())); + await watcher?.close().catch(() => {}); + if (wss) { + await new Promise((resolve) => wss.close(() => resolve())); + } }, }; } @@ -394,6 +407,7 @@ export async function startCanvasHost( rootDir: opts.rootDir, basePath: CANVAS_HOST_PATH, allowInTests: opts.allowInTests, + liveReload: opts.liveReload, })); const ownsHandler = opts.ownsHandler ?? opts.handler === undefined; diff --git a/src/config/types.ts b/src/config/types.ts index 0f497ed6c..50ff0141d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -477,6 +477,8 @@ export type CanvasHostConfig = { root?: string; /** HTTP port to listen on (default: 18793). */ port?: number; + /** Enable live-reload file watching + WS reloads (default: true). */ + liveReload?: boolean; }; export type TalkConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 692344640..996d2defb 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -748,6 +748,7 @@ export const ClawdisSchema = z.object({ enabled: z.boolean().optional(), root: z.string().optional(), port: z.number().int().positive().optional(), + liveReload: z.boolean().optional(), }) .optional(), talk: z diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 4c9fb42d5..efda86281 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -555,6 +555,7 @@ export async function startGatewayServer( rootDir: cfgAtStart.canvasHost?.root, basePath: CANVAS_HOST_PATH, allowInTests: opts.allowCanvasHostInTests, + liveReload: cfgAtStart.canvasHost?.liveReload, }); if (handler.rootDir) { canvasHost = handler; @@ -860,6 +861,7 @@ export async function startGatewayServer( port: canvasHostPort, listenHost: bridgeHost, allowInTests: opts.allowCanvasHostInTests, + liveReload: cfgAtStart.canvasHost?.liveReload, handler: canvasHost ?? undefined, ownsHandler: canvasHost ? false : undefined, });