Files
windmill/cli/src/commands/dev/dev.ts
T
2026-04-22 15:50:47 +00:00

248 lines
7.8 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 { WebSocket, WebSocketServer } from "ws";
import * as getPort from "get-port";
import * as http from "node:http";
import * as open from "open";
import { realpath } 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 {
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 { parseMetadataFile } from "../../utils/metadata.ts";
import {
getFolderSuffixWithSep,
getMetadataFileName,
extractFolderPath,
} from "../../utils/resource_folders.ts";
import { listSyncCodebases } from "../../utils/codebase.ts";
import { createPreviewLocalScriptReader } from "../../utils/local_path_scripts.ts";
const PORT = 3001;
async function dev(opts: GlobalOptions & SyncOptions) {
opts = await mergeConfigWithConfigFile(opts);
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 flowFolderSuffix = getFolderSuffixWithSep("flow");
const flowMetadataFile = getMetadataFileName("flow", "yaml");
async function loadPaths(pathsToLoad: string[]) {
const paths = pathsToLoad.filter((path) =>
exts.some(
(ext) => path.endsWith(ext) || path.endsWith(flowFolderSuffix + flowMetadataFile)
)
);
if (paths.length == 0) {
return;
}
const nativePath = (await realpath(paths[0])).replace(base + SEP, "");
const cpath = nativePath.replaceAll("\\", "/");
if (!ignore(nativePath, false)) {
const typ = getTypeStrFromPath(cpath);
log.info("Detected change in " + cpath + " (" + typ + ")");
if (typ == "flow") {
const localPath = extractFolderPath(cpath, "flow")!;
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,
);
// Replace PathScript modules with local file content so dev mode uses local versions
const localScriptReader = createPreviewLocalScriptReader({
exts,
defaultTs: opts.defaultTs,
codebases,
});
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
currentLastEdit = {
type: "flow",
flow: localFlow,
uriPath: localPath,
};
log.info("Updated " + localPath);
broadcastChanges(currentLastEdit);
} else if (typ == "script") {
const content = await readTextFile(cpath);
const splitted = cpath.split(".");
const wmPath = splitted[0];
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;
};
const connectedClients: Set<WebSocket> = new Set();
// Function to send a message to all connected clients
function broadcastChanges(lastEdit: LastEditScript | LastEditFlow) {
for (const client of connectedClients.values()) {
client.send(JSON.stringify(lastEdit));
}
}
async function startApp() {
const server = http.createServer((_req, res) => {
res.writeHead(200);
res.end();
});
const wss = new WebSocketServer({ server });
// WebSocket server event listeners
wss.on("connection", (ws: WebSocket) => {
connectedClients.add(ws);
console.log("New client connected");
ws.on("open", () => {
if (currentLastEdit) {
broadcastChanges(currentLastEdit);
}
});
ws.on("close", () => {
connectedClients.delete(ws);
console.log("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]);
}
});
});
// Start the server
const port = await getPort.default({ port: 3001 });
const url =
`${workspace.remote}dev?workspace=${workspace.workspaceId}&local=true&wm_token=${workspace.token}` +
(port === PORT ? "" : `&port=${port}`);
console.log(`Go to ${url}`);
try {
open.openApp(open.apps.browser, { arguments: [url] }).catch((error) => {
console.error(
`Failed to open browser, please navigate to ${url}, error: ${error}`
);
});
console.log("Opened browser for you");
} catch (error) {
console.error(
`Failed to open browser, please navigate to ${url}, ${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()]);
console.log("Stopped dev mode");
}
const command = new Command()
.description("Launch a dev server that watches for local file changes and auto-pushes them to the remote workspace. Provides live reload for scripts and flows during development.")
.option(
"--includes <pattern...:string>",
"Filter paths givena glob pattern or path"
)
.action(dev as any);
export default command;