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) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-03-24 22:45:37 +01:00
parent 911df958e7
commit 632c8868bc
10 changed files with 370 additions and 43 deletions
+190 -31
View File
@@ -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<WebSocket> = new Set();
// Map each connected client to its optional watchPath filter
const clientWatchPaths: Map<WebSocket, string | undefined> = 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<number> {
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<void>((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=<wm_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 <pattern...:string>",
"Filter paths givena glob pattern or path"
)
.option(
"--proxy-port <port:number>",
"Port for the localhost reverse proxy (default: 3100)"
)
.action(dev as any);
export default command;
+24
View File
@@ -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}`);
+47 -10
View File
@@ -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/<folder>/<app>.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 \`<div>\`, \`<span>\`, and other non-interactive elements uses Svelte's event delegation system. If the runtime doesn't support it, you'll get errors.
- \`onclick\` on \`<button>\` elements is native and generally works fine.
- For modal overlays or click-outside patterns, prefer using \`<button>\` elements styled as overlays, or ensure the Svelte runtime is up to date.
## Best Practices
1. **Check DATATABLES.md** for existing tables before creating new ones
@@ -5019,6 +5011,7 @@ Launch a dev server that will spawn a webserver with HMR
**Options:**
- \`--includes <pattern...:string>\` - Filter paths givena glob pattern or path
- \`--proxy-port <port:number>\` - Port for the localhost reverse proxy (default: 3100)
### docs
@@ -5478,6 +5471,50 @@ workspace related commands
- \`workspace delete-fork <fork_name:string>\` - Delete a forked workspace and git branch
- \`-y --yes\` - Skip confirmation prompt
`,
"dev-preview": `---
name: dev-preview
description: Use when previewing Windmill scripts/flows locally via wmill dev.
---
# Dev Preview
Preview Windmill scripts and flows locally via \`wmill dev\`.
## Setup
Start the dev server (includes a localhost reverse proxy):
\`\`\`bash
wmill dev
\`\`\`
This starts:
- A file watcher on the local repo
- A reverse proxy on \`localhost:3100\` forwarding to the remote Windmill instance
- A dev WebSocket on \`/ws_dev\` for live file updates
## Preview a specific file
Open in browser or Claude Preview:
\`\`\`
http://localhost:3100/dev?path={wm_path}&local=true
\`\`\`
Where \`wm_path\` is the Windmill path derived from the local file:
- **Scripts**: strip the file extension — \`u/admin/my_script.ts\`\`u/admin/my_script\`
- **Flows**: strip the \`.flow/\` suffix — \`f/my_flow.flow/flow.yaml\`\`f/my_flow\`
The \`path\` parameter locks the preview to that file. Other file changes are ignored.
## Switch files
Navigate to a new URL with a different \`path\` param.
## Legacy mode
Without the \`path\` parameter, the dev page shows the last-edited file (original behavior).
`,
};
+19 -2
View File
@@ -331,6 +331,9 @@
}
})
const filterPath =
searchParams?.get('path') ?? sessionStorage.getItem('wm_dev_watch_path') ?? undefined
function connectWs() {
try {
if (socket) {
@@ -339,9 +342,19 @@
} catch (e) {
console.error('Failed to close websocket', e)
}
const port = searchParams?.get('port') || '3001'
const port = searchParams?.get('port')
// If port is explicitly set, use legacy ws://localhost:{port}/ws
// Otherwise connect to same-origin /ws_dev (works through the proxy)
const wsUrl = port ? `ws://localhost:${port}/ws` : `ws://${location.host}/ws_dev`
try {
socket = new WebSocket(`ws://localhost:${port}/ws`)
socket = new WebSocket(wsUrl)
socket.addEventListener('open', () => {
if (filterPath) {
socket?.send(JSON.stringify({ type: 'setWatch', path: filterPath }))
sessionStorage.setItem('wm_dev_watch_path', filterPath)
}
})
// Listen for messages
socket.addEventListener('message', (event) => {
@@ -356,6 +369,10 @@
console.log('Received invalid JSON: ' + msg)
return
}
if (data.type == 'watchSet') {
// Confirmation from server, no action needed
return
}
if (data.type == 'script') {
replaceScript(data)
} else if (data.type == 'flow') {
@@ -57,6 +57,7 @@ Launch a dev server that will spawn a webserver with HMR
**Options:**
- `--includes <pattern...:string>` - Filter paths givena glob pattern or path
- `--proxy-port <port:number>` - Port for the localhost reverse proxy (default: 3100)
### docs
+1
View File
@@ -1431,6 +1431,7 @@ Launch a dev server that will spawn a webserver with HMR
**Options:**
- \`--includes <pattern...:string>\` - Filter paths givena glob pattern or path
- \`--proxy-port <port:number>\` - Port for the localhost reverse proxy (default: 3100)
### docs
@@ -62,6 +62,7 @@ Launch a dev server that will spawn a webserver with HMR
**Options:**
- `--includes <pattern...:string>` - Filter paths givena glob pattern or path
- `--proxy-port <port:number>` - Port for the localhost reverse proxy (default: 3100)
### docs
@@ -0,0 +1,43 @@
---
name: dev-preview
description: Use when previewing Windmill scripts/flows locally via wmill dev.
---
# Dev Preview
Preview Windmill scripts and flows locally via `wmill dev`.
## Setup
Start the dev server (includes a localhost reverse proxy):
```bash
wmill dev
```
This starts:
- A file watcher on the local repo
- A reverse proxy on `localhost:3100` forwarding to the remote Windmill instance
- A dev WebSocket on `/ws_dev` for live file updates
## Preview a specific file
Open in browser or Claude Preview:
```
http://localhost:3100/dev?path={wm_path}&local=true
```
Where `wm_path` is the Windmill path derived from the local file:
- **Scripts**: strip the file extension — `u/admin/my_script.ts``u/admin/my_script`
- **Flows**: strip the `.flow/` suffix — `f/my_flow.flow/flow.yaml``f/my_flow`
The `path` parameter locks the preview to that file. Other file changes are ignored.
## Switch files
Navigate to a new URL with a different `path` param.
## Legacy mode
Without the `path` parameter, the dev page shows the last-edited file (original behavior).
+38
View File
@@ -0,0 +1,38 @@
# Dev Preview
Preview Windmill scripts and flows locally via `wmill dev`.
## Setup
Start the dev server (includes a localhost reverse proxy):
```bash
wmill dev
```
This starts:
- A file watcher on the local repo
- A reverse proxy on `localhost:3100` forwarding to the remote Windmill instance
- A dev WebSocket on `/ws_dev` for live file updates
## Preview a specific file
Open in browser or Claude Preview:
```
http://localhost:3100/dev?path={wm_path}&local=true
```
Where `wm_path` is the Windmill path derived from the local file:
- **Scripts**: strip the file extension — `u/admin/my_script.ts``u/admin/my_script`
- **Flows**: strip the `.flow/` suffix — `f/my_flow.flow/flow.yaml``f/my_flow`
The `path` parameter locks the preview to that file. Other file changes are ignored.
## Switch files
Navigate to a new URL with a different `path` param.
## Legacy mode
Without the `path` parameter, the dev page shows the last-edited file (original behavior).
+6
View File
@@ -726,6 +726,11 @@ SKILL_DEFINITIONS = [
'description': 'MUST use when using the CLI.',
'content_key': 'cli_commands',
},
{
'name': 'dev-preview',
'description': 'Use when previewing Windmill scripts/flows locally via wmill dev.',
'content_key': 'dev_preview',
},
]
@@ -755,6 +760,7 @@ def generate_skills(
'schedules': read_markdown_file(base_dir / "schedules.md"),
'resources': read_markdown_file(base_dir / "resources.md"),
'cli_commands': cli_commands,
'dev_preview': read_markdown_file(base_dir / "dev-preview.md"),
}
# CLI intro for script skills