feat: add canvasHost liveReload option
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user