feat: add canvasHost liveReload option
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
### Features
|
### Features
|
||||||
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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`
|
- also serves A2UI at `/__clawdis__/a2ui/` and is advertised to nodes as `canvasHostUrl`
|
||||||
(always used by nodes for Canvas/A2UI)
|
(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
|
```json5
|
||||||
{
|
{
|
||||||
canvasHost: {
|
canvasHost: {
|
||||||
root: "~/clawd/canvas",
|
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 () => {
|
it("serves canvas content from the mounted base path", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-canvas-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-canvas-"));
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type CanvasHostOpts = {
|
|||||||
port?: number;
|
port?: number;
|
||||||
listenHost?: string;
|
listenHost?: string;
|
||||||
allowInTests?: boolean;
|
allowInTests?: boolean;
|
||||||
|
liveReload?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CanvasHostServerOpts = CanvasHostOpts & {
|
export type CanvasHostServerOpts = CanvasHostOpts & {
|
||||||
@@ -45,6 +46,7 @@ export type CanvasHostHandlerOpts = {
|
|||||||
rootDir?: string;
|
rootDir?: string;
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
allowInTests?: boolean;
|
allowInTests?: boolean;
|
||||||
|
liveReload?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CanvasHostHandler = {
|
export type CanvasHostHandler = {
|
||||||
@@ -234,15 +236,19 @@ export async function createCanvasHostHandler(
|
|||||||
);
|
);
|
||||||
const rootReal = await prepareCanvasRoot(rootDir);
|
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>();
|
const sockets = new Set<WebSocket>();
|
||||||
wss.on("connection", (ws) => {
|
if (wss) {
|
||||||
sockets.add(ws);
|
wss.on("connection", (ws) => {
|
||||||
ws.on("close", () => sockets.delete(ws));
|
sockets.add(ws);
|
||||||
});
|
ws.on("close", () => sockets.delete(ws));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let debounce: NodeJS.Timeout | null = null;
|
let debounce: NodeJS.Timeout | null = null;
|
||||||
const broadcastReload = () => {
|
const broadcastReload = () => {
|
||||||
|
if (!liveReload) return;
|
||||||
for (const ws of sockets) {
|
for (const ws of sockets) {
|
||||||
try {
|
try {
|
||||||
ws.send("reload");
|
ws.send("reload");
|
||||||
@@ -261,19 +267,23 @@ export async function createCanvasHostHandler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let watcherClosed = false;
|
let watcherClosed = false;
|
||||||
const watcher = chokidar.watch(rootReal, {
|
const watcher = liveReload
|
||||||
ignoreInitial: true,
|
? chokidar.watch(rootReal, {
|
||||||
awaitWriteFinish: { stabilityThreshold: 75, pollInterval: 10 },
|
ignoreInitial: true,
|
||||||
ignored: [
|
awaitWriteFinish: { stabilityThreshold: 75, pollInterval: 10 },
|
||||||
/(^|[\\/])\../, // dotfiles
|
ignored: [
|
||||||
/(^|[\\/])node_modules([\\/]|$)/,
|
/(^|[\\/])\../, // dotfiles
|
||||||
],
|
/(^|[\\/])node_modules([\\/]|$)/,
|
||||||
});
|
],
|
||||||
watcher.on("all", () => scheduleReload());
|
})
|
||||||
watcher.on("error", (err) => {
|
: null;
|
||||||
|
watcher?.on("all", () => scheduleReload());
|
||||||
|
watcher?.on("error", (err) => {
|
||||||
if (watcherClosed) return;
|
if (watcherClosed) return;
|
||||||
watcherClosed = true;
|
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(() => {});
|
void watcher.close().catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,6 +292,7 @@ export async function createCanvasHostHandler(
|
|||||||
socket: Duplex,
|
socket: Duplex,
|
||||||
head: Buffer,
|
head: Buffer,
|
||||||
) => {
|
) => {
|
||||||
|
if (!wss) return false;
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
if (url.pathname !== CANVAS_WS_PATH) return false;
|
if (url.pathname !== CANVAS_WS_PATH) return false;
|
||||||
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
|
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
|
||||||
@@ -300,9 +311,9 @@ export async function createCanvasHostHandler(
|
|||||||
try {
|
try {
|
||||||
const url = new URL(urlRaw, "http://localhost");
|
const url = new URL(urlRaw, "http://localhost");
|
||||||
if (url.pathname === CANVAS_WS_PATH) {
|
if (url.pathname === CANVAS_WS_PATH) {
|
||||||
res.statusCode = 426;
|
res.statusCode = liveReload ? 426 : 404;
|
||||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
res.end("upgrade required");
|
res.end(liveReload ? "upgrade required" : "not found");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +361,7 @@ export async function createCanvasHostHandler(
|
|||||||
if (mime === "text/html") {
|
if (mime === "text/html") {
|
||||||
const html = await fs.readFile(filePath, "utf8");
|
const html = await fs.readFile(filePath, "utf8");
|
||||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||||
res.end(injectCanvasLiveReload(html));
|
res.end(liveReload ? injectCanvasLiveReload(html) : html);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,8 +385,10 @@ export async function createCanvasHostHandler(
|
|||||||
close: async () => {
|
close: async () => {
|
||||||
if (debounce) clearTimeout(debounce);
|
if (debounce) clearTimeout(debounce);
|
||||||
watcherClosed = true;
|
watcherClosed = true;
|
||||||
await watcher.close().catch(() => {});
|
await watcher?.close().catch(() => {});
|
||||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
if (wss) {
|
||||||
|
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -394,6 +407,7 @@ export async function startCanvasHost(
|
|||||||
rootDir: opts.rootDir,
|
rootDir: opts.rootDir,
|
||||||
basePath: CANVAS_HOST_PATH,
|
basePath: CANVAS_HOST_PATH,
|
||||||
allowInTests: opts.allowInTests,
|
allowInTests: opts.allowInTests,
|
||||||
|
liveReload: opts.liveReload,
|
||||||
}));
|
}));
|
||||||
const ownsHandler = opts.ownsHandler ?? opts.handler === undefined;
|
const ownsHandler = opts.ownsHandler ?? opts.handler === undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -477,6 +477,8 @@ export type CanvasHostConfig = {
|
|||||||
root?: string;
|
root?: string;
|
||||||
/** HTTP port to listen on (default: 18793). */
|
/** HTTP port to listen on (default: 18793). */
|
||||||
port?: number;
|
port?: number;
|
||||||
|
/** Enable live-reload file watching + WS reloads (default: true). */
|
||||||
|
liveReload?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TalkConfig = {
|
export type TalkConfig = {
|
||||||
|
|||||||
@@ -748,6 +748,7 @@ export const ClawdisSchema = z.object({
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
root: z.string().optional(),
|
root: z.string().optional(),
|
||||||
port: z.number().int().positive().optional(),
|
port: z.number().int().positive().optional(),
|
||||||
|
liveReload: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
talk: z
|
talk: z
|
||||||
|
|||||||
@@ -555,6 +555,7 @@ export async function startGatewayServer(
|
|||||||
rootDir: cfgAtStart.canvasHost?.root,
|
rootDir: cfgAtStart.canvasHost?.root,
|
||||||
basePath: CANVAS_HOST_PATH,
|
basePath: CANVAS_HOST_PATH,
|
||||||
allowInTests: opts.allowCanvasHostInTests,
|
allowInTests: opts.allowCanvasHostInTests,
|
||||||
|
liveReload: cfgAtStart.canvasHost?.liveReload,
|
||||||
});
|
});
|
||||||
if (handler.rootDir) {
|
if (handler.rootDir) {
|
||||||
canvasHost = handler;
|
canvasHost = handler;
|
||||||
@@ -860,6 +861,7 @@ export async function startGatewayServer(
|
|||||||
port: canvasHostPort,
|
port: canvasHostPort,
|
||||||
listenHost: bridgeHost,
|
listenHost: bridgeHost,
|
||||||
allowInTests: opts.allowCanvasHostInTests,
|
allowInTests: opts.allowCanvasHostInTests,
|
||||||
|
liveReload: cfgAtStart.canvasHost?.liveReload,
|
||||||
handler: canvasHost ?? undefined,
|
handler: canvasHost ?? undefined,
|
||||||
ownsHandler: canvasHost ? false : undefined,
|
ownsHandler: canvasHost ? false : undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user