feat: add canvasHost liveReload option

This commit is contained in:
Peter Steinberger
2026-01-04 15:22:47 +01:00
parent 1e555e693a
commit d48dc71fa4
7 changed files with 82 additions and 22 deletions

View File

@@ -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.

View File

@@ -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
}
}
```

View File

@@ -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"),
"<html><body>no-reload</body></html>",
"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(

View File

@@ -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<WebSocket>();
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<void>((resolve) => wss.close(() => resolve()));
await watcher?.close().catch(() => {});
if (wss) {
await new Promise<void>((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;

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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,
});