Files
windmill/cli/src/commands/dev/dev.ts
T
2026-04-29 18:02:39 +00:00

917 lines
32 KiB
TypeScript

import { Command } from "@cliffy/command";
import * as log from "../../core/log.ts";
import { sep as SEP } from "node:path";
import { yamlParseFile } from "../../utils/yaml.ts";
import { stringify as yamlStringify } from "yaml";
import { WebSocket, WebSocketServer } from "ws";
import * as http from "node:http";
import * as https from "node:https";
import * as open from "open";
import { access, readdir, realpath, stat, unlink, writeFile } from "node:fs/promises";
import { readTextFile } from "../../utils/utils.ts";
import { watch } from "node:fs";
import { getTypeStrFromPath, GlobalOptions } from "../../types.ts";
import { ignoreF } from "../sync/sync.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace } from "../../core/context.ts";
import {
GLOBAL_CONFIG_OPT,
SyncOptions,
mergeConfigWithConfigFile,
} from "../../core/conf.ts";
import { exts, removeExtensionToPath } from "../script/script.ts";
import { inferContentTypeFromFilePath } from "../../utils/script_common.ts";
import { OpenFlow } from "../../../gen/types.gen.ts";
import { FlowFile } from "../flow/flow.ts";
import { replaceInlineScripts, replaceAllPathScriptsWithLocal } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { extractInlineScripts, extractCurrentMapping } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { newPathAssigner } from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts";
import { parseMetadataFile } from "../../utils/metadata.ts";
import {
getMetadataFileName,
extractFolderPath,
getNonDottedPaths,
loadNonDottedPathsSetting,
} from "../../utils/resource_folders.ts";
import * as path from "node:path";
import * as fs from "node:fs";
import { listSyncCodebases } from "../../utils/codebase.ts";
import { createPreviewLocalScriptReader } from "../../utils/local_path_scripts.ts";
import { resolveBindPort, BIND_HOST } from "../../utils/port-probe.ts";
import {
snapshotPathScripts,
tagReplacedPathScripts,
restorePathScripts,
} from "./pathscript-restore.ts";
const PORT = 3001;
type WmPathItem = {
path: string;
kind: "flow" | "script" | "raw_app";
summary?: string;
};
const FLOW_SUFFIXES = [".flow", "__flow"] as const;
const APP_SUFFIXES = [".app", "__app", ".raw_app", "__raw_app"] as const;
// Extensions the dev round-trip might have written into a flow folder as
// inline scripts. Derived from script.ts's `exts` so adding a new language
// there auto-extends orphan cleanup; otherwise stale inline scripts of that
// language would silently linger. Excludes `.yml` — user fixtures commonly
// use it in flow folders, and leaving a stale `.playbook.yml` inline script
// is preferable to deleting a fixture. `.js` is added explicitly for
// hand-written flows that aren't in the `exts` list.
//
// Anything else (README.md, fixtures, .env*, TODO.md…) is preserved during
// orphan cleanup so we don't trample user-added files.
const INLINE_SCRIPT_EXTS = new Set([
// path.extname(".py") === "" (Node treats ".py" as a hidden filename, not
// an extension), so prefix with a dummy character before extracting.
...exts.map((e) => path.extname("x" + e)).filter((e) => e !== ".yml"),
".js",
]);
function stripFolderSuffix(rel: string, suffixes: readonly string[]): string {
for (const s of suffixes) {
if (rel.endsWith(s)) return rel.slice(0, -s.length);
}
return rel;
}
function isFlowFolderName(name: string): boolean {
return FLOW_SUFFIXES.some((s) => name.endsWith(s));
}
// Normalize a windmill path: strip trailing slash and any flow folder suffix
// so f/foo, f/foo/, f/foo.flow, and f/foo.flow/ all compare equal.
function normalizeWmPath(p: string): string {
return stripFolderSuffix(p.replace(/\/$/, ""), FLOW_SUFFIXES);
}
// Anchor on path segments — substring matches like cpath.includes(".flow/")
// also fire on innocent names like "notes_about__flow_design/readme.md".
function isInsideFlowFolder(cpath: string): boolean {
return cpath.split("/").some(isFlowFolderName);
}
// Return the path prefix up to and including the first flow-folder segment,
// with a trailing slash. Returns undefined if no segment matches.
function findFlowFolderPrefix(cpath: string): string | undefined {
const segs = cpath.split("/");
for (let i = 0; i < segs.length; i++) {
if (isFlowFolderName(segs[i])) {
return segs.slice(0, i + 1).join("/") + "/";
}
}
return undefined;
}
async function listWorkspacePaths(): Promise<WmPathItem[]> {
// Walk first, capturing each item's metadata file path. Then read summaries in
// parallel — one tree pass plus N file reads is faster than a serialized walk.
const items: (WmPathItem & { _metaPath?: string })[] = [];
async function walk(dir: string, rel: string) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
const childAbs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (isFlowFolderName(entry.name)) {
items.push({
path: stripFolderSuffix(childRel, FLOW_SUFFIXES),
kind: "flow",
_metaPath: path.join(childAbs, "flow.yaml"),
});
continue;
}
if (APP_SUFFIXES.some((s) => entry.name.endsWith(s))) {
items.push({ path: stripFolderSuffix(childRel, APP_SUFFIXES), kind: "raw_app" });
continue;
}
await walk(childAbs, childRel);
} else if (entry.isFile()) {
const matchedExt = exts.find((ext) => entry.name.endsWith(ext));
if (matchedExt) {
const noExtAbs = childAbs.slice(0, -matchedExt.length);
items.push({
path: childRel.slice(0, -matchedExt.length),
kind: "script",
_metaPath: noExtAbs + ".script.yaml",
});
}
}
}
}
await walk(process.cwd(), "");
await Promise.all(
items.map(async (item) => {
if (!item._metaPath) return;
try {
const meta: any = await yamlParseFile(item._metaPath);
if (typeof meta?.summary === "string" && meta.summary.length > 0) {
item.summary = meta.summary;
}
} catch {
// No metadata file or unparseable — leave summary undefined
}
})
);
items.sort((a, b) => a.path.localeCompare(b.path));
return items.map(({ _metaPath, ...item }) => item);
}
export interface DevOpts {
proxyPort?: number;
path?: string;
open?: boolean;
}
export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
// Auto-detect flow folder: if no --path and cwd is a flow folder, resolve path and chdir to workspace root
if (!opts.path) {
const cwd = process.cwd();
const cwdBasename = path.basename(cwd);
// Need to init nonDottedPaths before checking suffix
await loadNonDottedPathsSetting();
if (isFlowFolderName(cwdBasename)) {
GLOBAL_CONFIG_OPT.noCdToRoot = true;
// Find workspace root
let searchDir = cwd;
let workspaceRoot: string | undefined;
while (true) {
const wmillYaml = path.join(searchDir, "wmill.yaml");
if (fs.existsSync(wmillYaml)) {
workspaceRoot = searchDir;
break;
}
const parentDir = path.dirname(searchDir);
if (parentDir === searchDir) break;
searchDir = parentDir;
}
if (workspaceRoot) {
const relPath = path.relative(workspaceRoot, cwd).replaceAll("\\", "/");
opts.path = stripFolderSuffix(relPath, FLOW_SUFFIXES);
log.info(`Detected flow folder, path: ${opts.path}`);
process.chdir(workspaceRoot);
}
}
}
opts = await mergeConfigWithConfigFile(opts);
// Normalize once so broadcastChanges' equality check survives user input
// like --path f/foo/ or --path f/foo.flow (and the same set via wmill.yaml).
if (opts.path) {
opts.path = normalizeWmPath(opts.path);
}
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
log.info("Started dev mode");
let currentLastEdit: LastEditScript | LastEditFlow | undefined = undefined;
const fsWatcher = watch(".", { recursive: true });
const base = await realpath(".");
const ignore = await ignoreF(opts);
const codebases = await listSyncCodebases(opts);
const changesTimeouts: Record<string, ReturnType<typeof setTimeout>> = {};
function watchChanges() {
return new Promise<void>((_resolve, _reject) => {
fsWatcher.on("change", (_eventType, filename) => {
if (!filename) return;
const filePath = typeof filename === "string" ? filename : filename.toString();
const key = filePath;
if (changesTimeouts[key]) {
clearTimeout(changesTimeouts[key]);
}
changesTimeouts[key] = setTimeout(async () => {
delete changesTimeouts[key];
await loadPaths([filePath]).catch((error) => {
log.error(
`Failed to reload ${filePath}: ${error instanceof Error ? error.message : error}`
);
});
}, 100);
});
fsWatcher.on("error", (err) => {
_reject(err);
});
});
}
const flowMetadataFile = getMetadataFileName("flow", "yaml");
async function loadPaths(pathsToLoad: string[]) {
const paths = pathsToLoad.filter((p) =>
exts.some(
(ext) => p.endsWith(ext)
|| p.endsWith(".flow/" + flowMetadataFile)
|| p.endsWith("__flow/" + flowMetadataFile)
)
);
if (paths.length == 0) {
return;
}
const nativePath = (await realpath(paths[0])).replace(base + SEP, "");
const cpath = nativePath.replaceAll("\\", "/");
// Bypass ignore for paths inside flow folders — ignore() only checks the configured
// suffix (dotted or non-dotted), but the workspace may contain both kinds
const insideFlow = isInsideFlowFolder(cpath);
if (insideFlow || !ignore(nativePath, false)) {
let typ: string;
if (insideFlow) {
// Force flow type for any file inside a flow folder — getTypeStrFromPath
// only recognises the configured suffix (dotted or non-dotted) and would
// mis-classify or throw for the other variant
typ = "flow";
} else {
typ = getTypeStrFromPath(cpath);
}
log.info("Detected change in " + cpath + " (" + typ + ")");
if (typ == "flow") {
// Try extractFolderPath, fallback to segment-anchored extraction for
// mixed suffix cases (extractFolderPath only checks the configured suffix).
let localPath = extractFolderPath(cpath, "flow") ?? findFlowFolderPrefix(cpath);
if (!localPath) return;
const wmFlowPath = stripFolderSuffix(localPath.replace(/\/$/, ""), FLOW_SUFFIXES);
const localFlow = (await yamlParseFile(
localPath + "flow.yaml"
)) as FlowFile;
await replaceInlineScripts(
localFlow.value.modules,
async (path: string) => await readTextFile(localPath + path),
log,
localPath,
SEP,
undefined,
);
// Snapshot PathScript modules before replacement, then tag after.
// Helpers walk `flowValue` (modules/failure_module/preprocessor_module),
// so pass `.value`, not the FlowFile wrapper.
snapshotPathScripts(localFlow.value);
const localScriptReader = createPreviewLocalScriptReader({
exts,
defaultTs: opts.defaultTs,
codebases,
});
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
tagReplacedPathScripts(localFlow.value);
currentLastEdit = {
type: "flow",
flow: localFlow,
uriPath: localPath,
path: wmFlowPath,
};
log.info("Updated " + wmFlowPath);
broadcastChanges(currentLastEdit);
} else if (typ == "script") {
const splitted = cpath.split(".");
const wmPath = splitted[0];
const content = await readTextFile(cpath);
const lang = inferContentTypeFromFilePath(cpath, opts.defaultTs);
const typed =
(await parseMetadataFile(
removeExtensionToPath(cpath),
undefined,
)
)?.payload
currentLastEdit = {
type: "script",
content,
path: wmPath,
language: lang,
tag: typed?.tag,
lock: typed?.lock,
};
log.info("Updated " + wmPath);
broadcastChanges(currentLastEdit);
}
}
}
type LastEditScript = {
type: "script";
content: string;
path: string;
language: string;
tag?: string;
lock?: string;
};
type LastEditFlow = {
type: "flow";
flow: OpenFlow;
uriPath: string;
path: string;
};
// Load a resource by its windmill path (e.g., "u/admin/my_script" or "f/my_flow")
async function loadWmPath(wmPath: string): Promise<LastEditScript | LastEditFlow | undefined> {
wmPath = normalizeWmPath(wmPath);
// Try as flow — check both dotted and non-dotted suffixes
let flowDir: string | undefined;
let flowYaml: string | undefined;
for (const suffix of [".flow", "__flow"]) {
const candidate = wmPath + suffix + "/";
try {
await access(candidate + "flow.yaml");
flowDir = candidate;
flowYaml = candidate + "flow.yaml";
break;
} catch {}
}
try {
if (!flowDir || !flowYaml) throw new Error("not a flow");
const localFlow = (await yamlParseFile(flowYaml)) as FlowFile;
await replaceInlineScripts(
localFlow.value.modules,
async (p: string) => await readTextFile(flowDir + p),
log,
flowDir,
SEP,
undefined,
);
snapshotPathScripts(localFlow.value);
const localScriptReader = createPreviewLocalScriptReader({
exts,
defaultTs: opts.defaultTs,
codebases,
});
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
tagReplacedPathScripts(localFlow.value);
const edit: LastEditFlow = {
type: "flow",
flow: localFlow,
uriPath: flowDir,
path: wmPath,
};
currentLastEdit = edit;
return edit;
} catch {
// Not a flow, try as script
}
// Try as script
for (const ext of exts) {
const filePath = wmPath + ext;
try {
await access(filePath);
const content = await readTextFile(filePath);
const lang = inferContentTypeFromFilePath(filePath, opts.defaultTs);
const typed = (await parseMetadataFile(removeExtensionToPath(filePath), undefined))?.payload;
const edit: LastEditScript = {
type: "script",
content,
path: wmPath,
language: lang,
tag: typed?.tag,
lock: typed?.lock,
};
currentLastEdit = edit;
return edit;
} catch {
continue;
}
}
log.error(`Could not find file for path: ${wmPath}`);
return undefined;
}
// Handle flow edits from the dev UI — write changes back to disk
// Mirrors the windmill-vscode extension's `processFlowMessage`
// (src/extension.ts). Keep them in step — the dev page is the same code in
// both contexts, and divergence here means the same flow round-trips
// differently between VS Code and the local dev preview.
//
// Deliberate divergence: the orphan-cleanup pass is restricted to
// INLINE_SCRIPT_EXTS so unrelated files (README.md, fixtures, etc.) are
// not deleted. The extension's version doesn't filter and would delete
// them — that's a known issue tracked separately.
async function handleFlowRoundTrip(data: { flow: any; uriPath: string }) {
if (!data.uriPath || !data.flow?.value) return;
let flowDir = data.uriPath;
if (!flowDir.endsWith("/")) flowDir += "/";
if (flowDir.includes("://")) {
flowDir = new URL(flowDir).pathname;
}
// Restore PathScripts BEFORE extracting so we don't write a file for the
// inlined body of a `type: 'script'` reference.
restorePathScripts(data.flow.value);
const flowYamlPath = flowDir + "flow.yaml";
let currentLoadedFlow: any[] | undefined;
let currentLoadedFailureModule: any | undefined;
let currentLoadedPreprocessorModule: any | undefined;
try {
const currentFlow = (await yamlParseFile(flowYamlPath)) as FlowFile;
currentLoadedFlow = currentFlow.value?.modules;
currentLoadedFailureModule = currentFlow.value?.failure_module;
currentLoadedPreprocessorModule = currentFlow.value?.preprocessor_module;
} catch {
// flow.yaml doesn't exist yet or is invalid
}
const inlineScriptMapping: Record<string, string> = {};
extractCurrentMapping(
currentLoadedFlow,
inlineScriptMapping,
currentLoadedFailureModule,
currentLoadedPreprocessorModule,
);
// Share one pathAssigner across all extraction calls so failure /
// preprocessor modules don't collide on filenames with main modules.
const extractOptions = { skipInlineScriptSuffix: getNonDottedPaths() };
const pathAssigner = newPathAssigner(opts.defaultTs ?? "bun", extractOptions);
const allExtracted = extractInlineScripts(
data.flow.value.modules ?? [],
inlineScriptMapping,
"/",
opts.defaultTs ?? "bun",
pathAssigner,
extractOptions,
);
if (data.flow.value.failure_module?.value?.type === "rawscript") {
allExtracted.push(...extractInlineScripts(
[data.flow.value.failure_module],
inlineScriptMapping,
"/",
opts.defaultTs ?? "bun",
pathAssigner,
extractOptions,
));
}
if (data.flow.value.preprocessor_module?.value?.type === "rawscript") {
allExtracted.push(...extractInlineScripts(
[data.flow.value.preprocessor_module],
inlineScriptMapping,
"/",
opts.defaultTs ?? "bun",
pathAssigner,
extractOptions,
));
}
for (const s of allExtracted) {
const filePath = flowDir + s.path;
// `!inline foo.ts` is a YAML directive that points at another file —
// treat it as a placeholder, not as content to overwrite.
if (s.content.startsWith("!inline ")) {
try {
await stat(filePath);
} catch {
await writeFile(filePath, "", "utf-8");
}
continue;
}
let needsWrite = true;
try {
const existing = await readTextFile(filePath);
if (existing === s.content) needsWrite = false;
} catch {
// File doesn't exist
}
if (needsWrite) {
await writeFile(filePath, s.content, "utf-8");
log.info(`Wrote inline script: ${filePath}`);
}
}
// Only rewrite flow.yaml when the serialized YAML actually differs from
// what's on disk. Avoids noisy mtime updates that re-trigger the watcher.
const flowYaml = yamlStringify(data.flow);
let currentYaml: string | undefined;
try {
currentYaml = await readTextFile(flowYamlPath);
} catch {
// File doesn't exist
}
if (currentYaml?.trimEnd() !== flowYaml.trimEnd()) {
await writeFile(flowYamlPath, flowYaml, "utf-8");
log.info(`Wrote flow: ${flowYamlPath}`);
}
// Orphan cleanup: extension does this unconditionally and overshoots,
// deleting README.md / fixtures / .env.local. We restrict to known
// inline-script extensions.
const extractedPaths = new Set(allExtracted.map((s) => s.path));
try {
const dirFiles = await readdir(flowDir);
for (const file of dirFiles) {
if (file === "flow.yaml" || file === "flow.json" || file.startsWith(".")) continue;
if (!INLINE_SCRIPT_EXTS.has(path.extname(file))) continue;
if (!extractedPaths.has(file)) {
await unlink(flowDir + file);
log.info(`Removed orphaned file: ${flowDir + file}`);
}
}
} catch {
// Directory read failed
}
}
const connectedClients: Set<WebSocket> = new Set();
// Send a message to all connected clients, gated by --path when set so we
// don't spam clients (or risk yanking their view) with edits to unrelated files.
function broadcastChanges(lastEdit: LastEditScript | LastEditFlow) {
if (opts.path && normalizeWmPath(lastEdit.path) !== opts.path) {
return;
}
for (const client of connectedClients.values()) {
client.send(JSON.stringify(lastEdit));
}
}
function setupDevWs(wss: WebSocketServer) {
wss.on("connection", (ws: WebSocket) => {
connectedClients.add(ws);
console.log("New dev client connected");
// Push the currently loaded edit so the page renders immediately on
// page load, without waiting for a file change to trigger a broadcast.
if (currentLastEdit && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify(currentLastEdit));
} catch (e) {
console.error("Failed to push initial state to new client:", e);
}
}
// Debounce flow round-trip writes per-client. Each keystroke in the
// dev page produces a `flow` message; without this, every keystroke
// races on the same flow.yaml + inline scripts, and the resulting
// fsWatcher echoes engage the client's lockChanges mid-typing,
// dropping subsequent edits. Mirrors the windmill-vscode extension's
// 200ms debounce in src/extension.ts.
let pendingFlowMessage: { flow: any; uriPath: string } | undefined;
let flowDebounceTimer: ReturnType<typeof setTimeout> | undefined;
ws.on("close", () => {
connectedClients.delete(ws);
if (flowDebounceTimer) {
clearTimeout(flowDebounceTimer);
flowDebounceTimer = undefined;
pendingFlowMessage = undefined;
}
console.log("Dev client disconnected");
});
ws.on("message", (message: WebSocket.RawData) => {
let data;
try {
data = JSON.parse(message.toString());
} catch (e) {
console.log("Received invalid JSON: " + message + " " + e);
return;
}
if (data.type === "load") {
loadPaths([data.path]);
} else if (data.type === "flow") {
pendingFlowMessage = data;
if (flowDebounceTimer) {
clearTimeout(flowDebounceTimer);
}
flowDebounceTimer = setTimeout(() => {
const msg = pendingFlowMessage;
pendingFlowMessage = undefined;
flowDebounceTimer = undefined;
if (msg) {
handleFlowRoundTrip(msg).catch((err) => {
log.error(`Failed to write flow changes: ${err}`);
});
}
}, 200);
} else if (data.type === "loadWmPath") {
loadWmPath(data.path).then((edit) => {
if (edit && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(edit));
}
}).catch((err) => {
log.error(`Failed to load path ${data.path}: ${err}`);
});
} else if (data.type === "listPaths") {
listWorkspacePaths().then((items) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "paths", items }));
}
}).catch((err) => {
log.error(`Failed to list paths: ${err}`);
});
}
});
});
}
function maybeOpenBrowser(url: string) {
if (opts.open === false) return;
try {
open.default(url).catch((error) => {
console.error(
`Failed to open browser, please navigate to ${url}, error: ${error}`
);
});
console.log(`Opened browser at ${url}`);
} catch (error) {
console.error(
`Failed to open browser, please navigate to ${url}, ${error}`
);
}
}
// --- Proxy mode (when --proxy-port is set) ---
//
// Runs a localhost HTTP server that:
// - serves the dev page from `http://localhost:<proxyPort>/` (forwarded
// to the remote workspace), so embedders that need a localhost origin
// can render it (e.g. Claude Code's port-detection preview), and
// - upgrades local /ws connections back to this same process for the
// live-reload channel.
//
// The simpler "direct" mode below works for standalone browser tabs and the
// VS Code extension's iframe — only embedders that demand a localhost origin
// need this proxy.
async function startProxyServer(requestedPort: number) {
// Probe both IPv4 and IPv6 stacks before binding. If the requested port is
// taken on either, walk upward to the next free one so we don't silently
// collide with a leftover dev server (see cli/src/utils/port-probe.ts).
const proxyPort = await resolveBindPort(requestedPort, "--proxy-port", {
info: (m) => console.log(m),
warn: (m) => console.warn(m),
});
const remote = new URL(workspace.remote);
const isHttps = remote.protocol === "https:";
const remoteHost = remote.hostname;
const remotePort = remote.port ? parseInt(remote.port) : (isHttps ? 443 : 80);
const httpModule = isHttps ? https : http;
const devWss = new WebSocketServer({ noServer: true });
setupDevWs(devWss);
const proxyWss = new WebSocketServer({ noServer: true });
const proxyServer = http.createServer((clientReq, clientRes) => {
const parsedUrl = new URL(clientReq.url ?? "/", `http://localhost`);
if (parsedUrl.pathname === "/" || parsedUrl.pathname === "") {
let devUrl = `/dev?workspace=${workspace.workspaceId}&local=true&wm_token=${workspace.token}&port=${proxyPort}`;
if (opts.path) {
devUrl += `&path=${opts.path}`;
}
clientRes.writeHead(302, { Location: devUrl });
clientRes.end();
return;
}
const fwdHeaders: Record<string, string | string[] | undefined> = {
...clientReq.headers,
host: remote.host,
};
delete fwdHeaders["connection"];
delete fwdHeaders["keep-alive"];
delete fwdHeaders["transfer-encoding"];
delete fwdHeaders["accept-encoding"];
const proxyOpts: http.RequestOptions = {
hostname: remoteHost,
port: remotePort,
path: clientReq.url,
method: clientReq.method,
headers: fwdHeaders,
};
const proxyReq = httpModule.request(proxyOpts, (proxyRes) => {
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 });
});
// WebSocket upgrades
proxyServer.on("upgrade", (req, socket, head) => {
const pathname = req.url?.split("?")[0] ?? "";
if (pathname === "/ws_dev" || pathname === "/ws") {
devWss.handleUpgrade(req, socket, head, (ws) => {
devWss.emit("connection", ws, req);
});
return;
}
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;
}
socket.destroy();
});
return new Promise<void>((resolve) => {
proxyServer.listen(proxyPort, BIND_HOST, () => {
console.log(`Dev proxy listening on http://localhost:${proxyPort}`);
if (opts.path) {
console.log(`Watching ${opts.path} — edits will live-reload in the dev page`);
} else {
console.log(
"Open the dev page and pick a flow or script to preview — edits will live-reload"
);
console.log("(pass --path <path> to skip the picker)");
}
maybeOpenBrowser(`http://localhost:${proxyPort}/`);
resolve();
});
});
}
// --- Direct mode (no localhost HTTP proxy) ---
//
// The browser loads the dev page from the remote workspace URL and opens a
// WebSocket directly to this localhost server. Used when:
// - the user runs `wmill dev` and opens a regular browser tab, or
// - the VS Code extension iframe loads the dev page (its iframe URL omits
// `local=true`, so it never opens this WS, but everything else still
// functions through the existing remote workspace connection).
//
// This is the simplest topology: a bare WebSocket server. The reverse-proxy
// mode (above) is only needed when something needs to embed the dev UI on a
// localhost origin (Claude Code's port-detection preview).
async function startDirectServer() {
const server = http.createServer((_req, res) => {
res.writeHead(200);
res.end();
});
const wss = new WebSocketServer({ server });
setupDevWs(wss);
// Probe both IPv4 and IPv6 stacks before binding. Same dual-stack
// collision risk as proxy mode: a leftover wmill dev on [::]:3001 would
// silently steer localhost:3001 traffic to the wrong process if we only
// probed one stack (which is what getPort does).
const port = await resolveBindPort(PORT, "wmill dev", {
info: (m) => console.log(m),
warn: (m) => console.warn(m),
});
const url =
`${workspace.remote}dev?workspace=${workspace.workspaceId}&local=true&wm_token=${workspace.token}` +
(port === PORT ? "" : `&port=${port}`) +
(opts.path ? `&path=${opts.path}` : "");
if (opts.open === false) {
console.log(`Go to ${url}`);
}
maybeOpenBrowser(url);
if (opts.path) {
console.log(`Watching ${opts.path} — edits will live-reload in the dev page`);
} else {
console.log(
"Open the dev page and pick a flow or script to preview — edits will live-reload"
);
}
server.listen(port, BIND_HOST, () => {
console.log(`Dev WebSocket listening on ws://localhost:${port}/ws`);
});
}
// --- Start ---
// If --path is set, load it immediately
if (opts.path) {
await loadWmPath(opts.path);
}
const startServer = opts.proxyPort
? () => startProxyServer(opts.proxyPort!)
: () => startDirectServer();
await Promise.all([startServer(), watchChanges()]);
console.log("Stopped dev mode");
}
const command = new Command()
.description("Watch local file changes and live-reload the dev page for preview. Does NOT deploy to the remote workspace — use wmill sync push for that.")
.option(
"--includes <pattern...:string>",
"Filter paths given a glob pattern or path"
)
.option(
"--proxy-port <port:number>",
"Port for a localhost reverse proxy to the remote Windmill server"
)
.option(
"--path <path:string>",
"Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)"
)
.option(
"--no-open",
"Do not open the browser automatically"
)
.action(dev as any);
export default command;