From 632c8868bcaab69aa20f2969a0c47e4c0cfceb60 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 24 Mar 2026 22:45:37 +0100 Subject: [PATCH] feat(cli): add localhost reverse proxy to wmill dev for Claude Desktop preview Adds a reverse proxy to `wmill dev` that serves the Windmill UI on localhost, enabling Claude Desktop/Preview to open dev pages. Each connected dev page can watch a specific file via the `path` URL param and `setWatch` WebSocket message. Key changes: - CLI: single-port proxy (default :3100) that forwards HTTP to remote Windmill, handles /ws_dev locally for dev file changes, and proxies /ws/* to remote - CLI: per-client watch filtering so multiple tabs can watch different files - CLI: flow broadcasts now include a `path` field - Frontend: Dev.svelte connects to same-origin /ws_dev when no port param, sends setWatch on connect, filters messages client-side as safety net - CLI init: generates .claude/skills/dev-preview/SKILL.md and .claude/launch.json Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/commands/dev/dev.ts | 221 +++++++++++++++--- cli/src/commands/init/init.ts | 24 ++ cli/src/guidance/skills.ts | 57 ++++- frontend/src/lib/components/Dev.svelte | 21 +- .../auto-generated/cli/cli-commands.md | 1 + system_prompts/auto-generated/prompts.ts | 1 + .../skills/cli-commands/SKILL.md | 1 + .../skills/dev-preview/SKILL.md | 43 ++++ system_prompts/base/dev-preview.md | 38 +++ system_prompts/generate.py | 6 + 10 files changed, 370 insertions(+), 43 deletions(-) create mode 100644 system_prompts/auto-generated/skills/dev-preview/SKILL.md create mode 100644 system_prompts/base/dev-preview.md diff --git a/cli/src/commands/dev/dev.ts b/cli/src/commands/dev/dev.ts index 41b6b8fd9c..23af82398b 100644 --- a/cli/src/commands/dev/dev.ts +++ b/cli/src/commands/dev/dev.ts @@ -6,6 +6,7 @@ import { WebSocket, WebSocketServer } from "ws"; import * as getPort from "get-port"; import * as http from "node:http"; +import * as https from "node:https"; import * as open from "open"; import { readFile, realpath } from "node:fs/promises"; import { watch } from "node:fs"; @@ -32,7 +33,9 @@ import { listSyncCodebases } from "../../utils/codebase.ts"; import { createPreviewLocalScriptReader } from "../../utils/local_path_scripts.ts"; const PORT = 3001; -async function dev(opts: GlobalOptions & SyncOptions) { +const PROXY_PORT = 3100; + +async function dev(opts: GlobalOptions & SyncOptions & { proxyPort?: number }) { opts = await mergeConfigWithConfigFile(opts); const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -106,12 +109,14 @@ async function dev(opts: GlobalOptions & SyncOptions) { codebases, }); await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log); + const wmFlowPath = localPath.replace(/\.flow\/$/, "").replace(/\/$/, ""); currentLastEdit = { type: "flow", flow: localFlow, uriPath: localPath, + path: wmFlowPath, }; - log.info("Updated " + localPath); + log.info("Updated " + wmFlowPath); broadcastChanges(currentLastEdit); } else if (typ == "script") { const content = await readFile(cpath, "utf-8"); @@ -153,38 +158,49 @@ async function dev(opts: GlobalOptions & SyncOptions) { type: "flow"; flow: OpenFlow; uriPath: string; + path: string; }; - const connectedClients: Set = new Set(); + // Map each connected client to its optional watchPath filter + const clientWatchPaths: Map = new Map(); - // Function to send a message to all connected clients + function getEditPath(lastEdit: LastEditScript | LastEditFlow): string { + return lastEdit.path; + } + + function normalizePath(p: string): string { + return p.replace(/\.flow\/?$/, "").replace(/\/$/, ""); + } + + // Send file changes to clients, filtered by their watchPath function broadcastChanges(lastEdit: LastEditScript | LastEditFlow) { - for (const client of connectedClients.values()) { - client.send(JSON.stringify(lastEdit)); + const editPath = normalizePath(getEditPath(lastEdit)); + const msg = JSON.stringify(lastEdit); + for (const [client, watchPath] of clientWatchPaths.entries()) { + if (watchPath === undefined || normalizePath(watchPath) === editPath) { + client.send(msg); + } } } - async function startApp() { - const server = http.createServer((_req, res) => { - res.writeHead(200); - res.end(); - }); - const wss = new WebSocketServer({ server }); - - // WebSocket server event listeners + function setupDevWs(wss: WebSocketServer) { wss.on("connection", (ws: WebSocket) => { - connectedClients.add(ws); - console.log("New client connected"); + clientWatchPaths.set(ws, undefined); + console.log("New dev client connected"); ws.on("open", () => { if (currentLastEdit) { - broadcastChanges(currentLastEdit); + // Send the current state to the new client + const watchPath = clientWatchPaths.get(ws); + if (watchPath === undefined || normalizePath(watchPath) === normalizePath(getEditPath(currentLastEdit))) { + ws.send(JSON.stringify(currentLastEdit)); + } } }); ws.on("close", () => { - connectedClients.delete(ws); - console.log("Client disconnected"); + clientWatchPaths.delete(ws); + console.log("Dev client disconnected"); }); ws.on("message", (message: WebSocket.RawData) => { @@ -198,37 +214,176 @@ async function dev(opts: GlobalOptions & SyncOptions) { if (data.type === "load") { loadPaths([data.path]); + } else if (data.type === "setWatch") { + const path = data.path as string; + clientWatchPaths.set(ws, path); + console.log(`Client watching: ${path}`); + ws.send(JSON.stringify({ type: "watchSet", path })); + // Send current state for the watched path if available + if (currentLastEdit && normalizePath(getEditPath(currentLastEdit)) === normalizePath(path)) { + ws.send(JSON.stringify(currentLastEdit)); + } } }); }); + } - // Start the server - const port = await getPort.default({ port: 3001 }); - const url = + async function startLegacyServer(): Promise { + const server = http.createServer((_req, res) => { + res.writeHead(200); + res.end(); + }); + const wss = new WebSocketServer({ server }); + setupDevWs(wss); + + const port = await getPort.default({ port: PORT }); + return new Promise((resolve) => { + server.listen(port, () => { + console.log(`Legacy dev server listening on port ${port}`); + resolve(port); + }); + }); + } + + async function startProxyServer(remoteUrl: string, proxyPort: number, wsPort: number) { + const remote = new URL(remoteUrl); + const isHttps = remote.protocol === "https:"; + const remoteHost = remote.hostname; + const remotePort = remote.port ? parseInt(remote.port) : (isHttps ? 443 : 80); + const httpModule = isHttps ? https : http; + + // Dev WebSocket server (handles /ws_dev path) + const devWss = new WebSocketServer({ noServer: true }); + setupDevWs(devWss); + + // Separate WSS for proxied WebSocket connections (not tracked as dev clients) + const proxyWss = new WebSocketServer({ noServer: true }); + + const proxyServer = http.createServer((clientReq, clientRes) => { + const proxyOpts: http.RequestOptions = { + hostname: remoteHost, + port: remotePort, + path: clientReq.url, + method: clientReq.method, + headers: { + ...clientReq.headers, + host: remote.host, + }, + }; + + const proxyReq = httpModule.request(proxyOpts, (proxyRes) => { + // Rewrite Set-Cookie domain to localhost + const setCookie = proxyRes.headers["set-cookie"]; + if (setCookie) { + proxyRes.headers["set-cookie"] = setCookie.map((cookie) => + cookie.replace(/domain=[^;]+/gi, "domain=localhost") + ); + } + clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); + proxyRes.pipe(clientRes, { end: true }); + }); + + proxyReq.on("error", (err) => { + console.error("Proxy error:", err.message); + clientRes.writeHead(502); + clientRes.end("Bad Gateway"); + }); + + clientReq.pipe(proxyReq, { end: true }); + }); + + // Handle WebSocket upgrades + proxyServer.on("upgrade", (req, socket, head) => { + const pathname = req.url?.split("?")[0] ?? ""; + + if (pathname === "/ws_dev") { + // Handle locally: dev file-change WebSocket + devWss.handleUpgrade(req, socket, head, (ws) => { + devWss.emit("connection", ws, req); + }); + return; + } + + // Proxy all other WebSocket paths to remote + if (pathname.startsWith("/ws/") || pathname.startsWith("/ws_mp/") || pathname.startsWith("/ws_debug/")) { + const wsProtocol = isHttps ? "wss" : "ws"; + const remoteWsUrl = `${wsProtocol}://${remote.host}${req.url}`; + const remoteWs = new WebSocket(remoteWsUrl, { + headers: { + ...req.headers, + host: remote.host, + }, + }); + + remoteWs.on("open", () => { + proxyWss.handleUpgrade(req, socket, head, (clientWs) => { + clientWs.on("message", (data) => { + if (remoteWs.readyState === WebSocket.OPEN) { + remoteWs.send(data); + } + }); + remoteWs.on("message", (data) => { + if (clientWs.readyState === WebSocket.OPEN) { + clientWs.send(data); + } + }); + clientWs.on("close", () => remoteWs.close()); + remoteWs.on("close", () => clientWs.close()); + }); + }); + + remoteWs.on("error", (err) => { + console.error("WebSocket proxy error:", err.message); + socket.destroy(); + }); + return; + } + + // Unknown WS path — destroy + socket.destroy(); + }); + + return new Promise((resolve) => { + proxyServer.listen(proxyPort, () => { + console.log(`Dev proxy listening on http://localhost:${proxyPort}`); + resolve(); + }); + }); + } + + async function startApp() { + const wsPort = await startLegacyServer(); + + const proxyPort = opts.proxyPort ?? PROXY_PORT; + await startProxyServer(workspace.remote, proxyPort, wsPort); + + const legacyUrl = `${workspace.remote}dev?workspace=${workspace.workspaceId}&local=true&wm_token=${workspace.token}` + - (port === PORT ? "" : `&port=${port}`); + (wsPort === PORT ? "" : `&port=${wsPort}`); + + const proxyUrl = + `http://localhost:${proxyPort}/dev?workspace=${workspace.workspaceId}&local=true&wm_token=${workspace.token}`; + + console.log(`\nLegacy dev URL: ${legacyUrl}`); + console.log(`Proxy dev URL: ${proxyUrl}`); + console.log(`\nTo watch a specific file: ${proxyUrl}&path=`); - console.log(`Go to ${url}`); try { - open.openApp(open.apps.browser, { arguments: [url] }).catch((error) => { + open.openApp(open.apps.browser, { arguments: [legacyUrl] }).catch((error) => { console.error( - `Failed to open browser, please navigate to ${url}, error: ${error}` + `Failed to open browser, please navigate to ${legacyUrl}, error: ${error}` ); }); console.log("Opened browser for you"); } catch (error) { console.error( - `Failed to open browser, please navigate to ${url}, ${error}` + `Failed to open browser, please navigate to ${legacyUrl}, ${error}` ); } console.log( "Dev server will automatically point to the last script edited locally" ); - - server.listen(port, () => { - console.log(`Server listening on port ${port}`); - }); } await Promise.all([startApp(), watchChanges()]); @@ -241,6 +396,10 @@ const command = new Command() "--includes ", "Filter paths givena glob pattern or path" ) + .option( + "--proxy-port ", + "Port for the localhost reverse proxy (default: 3100)" + ) .action(dev as any); export default command; diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index 5883967b77..05b7aac55b 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -346,6 +346,30 @@ async function initAction(opts: InitOptions) { log.warn(`Could not create skills: ${skillError}`); } } + + // Create .claude/launch.json for Claude Preview dev server + try { + const launchJsonPath = ".claude/launch.json"; + const launchJson = { + version: "0.0.1", + configurations: [ + { + name: "windmill-dev", + runtimeExecutable: "wmill", + runtimeArgs: ["dev"], + port: 3100, + }, + ], + }; + await writeFile(launchJsonPath, JSON.stringify(launchJson, null, 2) + "\n", "utf-8"); + log.info(colors.green("Created .claude/launch.json")); + } catch (launchError) { + if (launchError instanceof Error) { + log.warn(`Could not create launch.json: ${launchError.message}`); + } else { + log.warn(`Could not create launch.json: ${launchError}`); + } + } } catch (error) { if (error instanceof Error) { log.warn(`Could not create guidance files: ${error.message}`); diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 3101059ac9..a934e3158b 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -32,6 +32,7 @@ export const SKILLS: SkillMetadata[] = [ { name: "schedules", description: "MUST use when configuring schedules." }, { name: "resources", description: "MUST use when managing resources." }, { name: "cli-commands", description: "MUST use when using the CLI." }, + { name: "dev-preview", description: "Use when previewing Windmill scripts/flows locally via wmill dev." }, ]; // Skill content for each skill (loaded inline for bundling) @@ -4602,18 +4603,9 @@ Tell the user they can run these commands (do NOT run them yourself): | \`wmill app dev\` | Start dev server with live reload | | \`wmill app generate-agents\` | Refresh AGENTS.md and DATATABLES.md | | \`wmill app generate-locks\` | Generate lock files for backend runnables | -| \`wmill sync push --extra-includes "f//.raw_app/**" --yes\` | Deploy this specific raw app to Windmill (never do a blanket \`wmill sync push\`) | +| \`wmill sync push\` | Deploy app to Windmill | | \`wmill sync pull\` | Pull latest from Windmill | -## Svelte 5 Event Handling - -When building Svelte 5 raw apps, be aware of event delegation: - -- The Svelte runtime version in \`node_modules/svelte\` **must match** the compiler version used by \`wmill sync push\`. If you get \`$.delegated is undefined\` errors at runtime, run \`npm install svelte@latest\` in the raw app folder and re-push. -- \`onclick\` on \`
\`, \`\`, and other non-interactive elements uses Svelte's event delegation system. If the runtime doesn't support it, you'll get errors. -- \`onclick\` on \`