Files
windmill/cli/src/commands/app/bundle.ts
Ruben Fiszel 86d1d160f0 fix(cli): fall back to esbuild-wasm on native host/binary mismatch (#9629)
* fix(cli): fall back to esbuild-wasm on native host/binary mismatch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): guard tarball extraction, extend esbuild-wasm fallback to script bundling

Address CI review: prevent tar-slip in esbuild-wasm package extraction, route codebase/script and inline-rawscript bundling through getEsbuild() too, and move the loader to utils. Add a unit test for the tar-slip guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): make esbuild-wasm fallback concurrency-safe

Address CI review (P1): memoize getEsbuild() on an in-flight promise so concurrent first callers (parallel wmill sync push) share one probe/download instead of racing, and give each extraction a unique temp dir so concurrent extractions can't clobber each other.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:33:18 +02:00

353 lines
11 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import process from "node:process";
import { spawn } from "node:child_process";
import * as log from "../../core/log.ts";
import { colors } from "@cliffy/ansi/colors";
import * as windmillUtils from "@windmill-labs/shared-utils";
import { readTextFile, readTextFileSync } from "../../utils/utils.ts";
import { getEsbuild, stopEsbuild } from "../../utils/esbuild_loader.ts";
export interface BundleOptions {
entryPoint?: string;
outDir?: string;
sourcemap?: boolean;
minify?: boolean;
production?: boolean;
/**
* Absolute path to the workspace's shared `ui/` folder. When set, imports
* starting with `/ui/...` are resolved as files inside this directory.
* Allows raw apps to reuse components from the workspace-level shared folder.
*/
sharedUiDir?: string;
}
export interface BundleResult {
js: string;
css: string;
}
export const DEFAULT_BUILD_OPTIONS = {
bundle: true,
format: "iife" as const,
platform: "browser" as const,
target: "es2020",
jsx: "automatic" as const,
loader: {
".css": "css" as const,
},
logLevel: "info" as const,
write: true,
};
/**
* Detects which frontend frameworks are present in package.json
*/
export function detectFrameworks(appDir: string): { svelte: boolean; vue: boolean } {
const packageJsonPath = path.join(appDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
return { svelte: false, vue: false };
}
try {
const packageJson = JSON.parse(readTextFileSync(packageJsonPath));
const allDeps = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
return {
svelte: "svelte" in allDeps,
vue: "vue" in allDeps,
};
} catch {
return { svelte: false, vue: false };
}
}
/**
* Creates a Svelte esbuild plugin
* Uses the svelte compiler from the project's node_modules
*/
function createSveltePlugin(appDir: string): any {
return {
name: "svelte",
setup(build: any) {
build.onLoad({ filter: /\.svelte$/ }, async (args: any) => {
// Import svelte compiler from the project's node_modules
const svelte = await import("svelte/compiler");
// Load the file from the file system
const source = await readTextFile(args.path);
const filename = path.relative(process.cwd(), args.path);
// This converts a message in Svelte's format to esbuild's format
const convertMessage = ({ message, start, end }: any) => {
let location;
if (start && end) {
const lineText = source.split(/\r\n|\r|\n/g)[start.line - 1];
const lineEnd = start.line === end.line ? end.column : lineText.length;
location = {
file: filename,
line: start.line,
column: start.column,
length: lineEnd - start.column,
lineText,
};
}
return { text: message, location };
};
// Convert Svelte syntax to JavaScript
try {
const { js, warnings } = svelte.compile(source, { filename });
const contents = js.code + `//# sourceMappingURL=` + js.map.toUrl();
return { contents, warnings: warnings.map(convertMessage) };
} catch (e: any) {
return { errors: [convertMessage(e)] };
}
});
},
};
}
/**
* Creates framework-specific esbuild plugins based on detected dependencies
*/
export async function createFrameworkPlugins(appDir: string): Promise<any[]> {
const frameworks = detectFrameworks(appDir);
const plugins: any[] = [];
if (frameworks.svelte) {
log.info(colors.blue("🔧 Svelte detected, adding svelte plugin..."));
plugins.push(createSveltePlugin(appDir));
}
if (frameworks.vue) {
log.info(colors.blue("🔧 Vue detected, adding vue plugin..."));
throw new Error("Vue plugin not supported yet");
// try {
// const esbuildPluginVue = await import("esbuild-plugin-vue3");
// plugins.push(esbuildPluginVue.default());
// } catch (error: any) {
// log.warn(colors.yellow(`Failed to load vue plugin: ${error.message}`));
// }
}
return plugins;
}
/**
* Ensures node_modules exists in the specified directory
* Runs npm install if node_modules is missing
* @param appDir Directory to check for node_modules (defaults to entry point directory)
*/
export async function ensureNodeModules(appDir?: string): Promise<void> {
const targetDir = appDir ?? process.cwd();
const nodeModulesPath = path.join(targetDir, "node_modules");
if (!fs.existsSync(nodeModulesPath)) {
log.info(colors.yellow("📦 node_modules not found, running npm install..."));
const code = await new Promise<number>((resolve, reject) => {
const npmInstall = spawn("npm", ["install"], {
cwd: targetDir,
stdio: "inherit",
shell: true,
});
npmInstall.on("close", (code) => resolve(code ?? 0));
npmInstall.on("error", reject);
});
if (code !== 0) {
throw new Error(`npm install failed with exit code ${code}`);
}
log.info(colors.green("✅ npm install completed"));
}
}
/**
* Creates an esbuild bundle for the app
* @param options Bundle configuration options
* @returns Bundle result containing JS and CSS blobs
*/
export async function createBundle(
options: BundleOptions = {}
): Promise<BundleResult> {
// Native esbuild with a transparent esbuild-wasm fallback on host/binary
// version mismatch (see esbuild_loader.ts).
const esbuild = await getEsbuild();
// Detect frameworks to determine default entry point.
// Use the entryPoint's directory if provided, otherwise fall back to cwd.
const appDir = options.entryPoint ? path.dirname(options.entryPoint) : process.cwd();
const frameworks = detectFrameworks(appDir);
const defaultEntry = (frameworks.svelte || frameworks.vue) ? "index.ts" : "index.tsx";
const entryPoint = options.entryPoint ?? defaultEntry;
const outDir = options.outDir ?? "dist";
const sourcemap = options.sourcemap ?? false;
const minify = options.minify ?? true;
const production = options.production ?? true;
// Verify entry point exists
if (!fs.existsSync(entryPoint)) {
throw new Error(
`Entry point "${entryPoint}" not found. Please ensure the file exists.`
);
}
// Ensure node_modules exists in the app directory
await ensureNodeModules(appDir);
// Load framework-specific plugins (svelte, vue) based on package.json
const frameworkPlugins = await createFrameworkPlugins(appDir);
// Ensure output directory exists
const distDir = path.join(process.cwd(), outDir);
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
const outfile = path.join(outDir, "bundle.js");
// log.info("FOO")
// log.info("wmillTs" + JSON.stringify(wmillTs));
// Plugin to provide /wmill.ts as a virtual module
const wmillTs = (windmillUtils.wmillTsRaw as any).default ?? windmillUtils.wmillTsRaw;
const wmillPlugin = {
name: "wmill-virtual",
setup(build: any) {
// Intercept imports of wmill with various path formats:
// - wmill, wmill.ts (bare import)
// - /wmill, /wmill.ts (absolute)
// - ./wmill, ./wmill.ts (same directory)
// - ../wmill, ../../wmill, etc. (parent directories)
build.onResolve(
{ filter: /^(\.\.\/)+wmill(\.ts)?$|^(\.\/|\/)?wmill(\.ts)?$/ },
(args: any) => {
log.info(colors.yellow(`[wmill-virtual] Intercepted: ${args.path}`));
return {
path: args.path,
namespace: "wmill-virtual",
};
}
);
// Provide the virtual module content
build.onLoad({ filter: /.*/, namespace: "wmill-virtual" }, (args: any) => {
log.info(colors.yellow(`[wmill-virtual] Loading virtual module: ${args.path}`));
return {
contents: wmillTs,
loader: "ts",
};
});
},
};
const sharedUiPlugins: any[] = [];
if (options.sharedUiDir && fs.existsSync(options.sharedUiDir)) {
const sharedUiDir = options.sharedUiDir;
sharedUiPlugins.push({
name: "wmill-shared-ui",
setup(build: any) {
// Intercept imports of /ui/<file> and resolve to the workspace ui/ folder.
build.onResolve({ filter: /^\/ui\// }, (args: any) => {
const rel = args.path.slice("/ui/".length);
const candidates = [rel];
if (!path.extname(rel)) {
candidates.push(
rel + ".tsx",
rel + ".ts",
rel + ".jsx",
rel + ".js",
rel + ".css",
path.join(rel, "index.tsx"),
path.join(rel, "index.ts"),
);
}
for (const c of candidates) {
const full = path.join(sharedUiDir, c);
if (fs.existsSync(full)) {
return { path: full };
}
}
return {
errors: [
{
text: `Could not resolve shared UI import "${args.path}" in ${sharedUiDir}`,
},
],
};
});
},
});
}
const buildOptions = {
...DEFAULT_BUILD_OPTIONS,
entryPoints: [entryPoint],
outfile,
sourcemap,
minify,
// Keep outputs in memory: esbuild-wasm cannot write to the filesystem
// ("write" option unavailable), and the dist files were discarded after the
// read anyway. Native esbuild supports write:false + outputFiles too.
write: false as const,
define: {
"process.env.NODE_ENV": production ? '"production"' : '"development"',
},
plugins: [...frameworkPlugins, wmillPlugin, ...sharedUiPlugins],
};
log.info(colors.blue("📦 Building bundle..."));
try {
const result = await esbuild.build(buildOptions);
if (result.errors.length > 0) {
log.error(colors.red("❌ Build failed:"));
result.errors.forEach((error: any) => {
log.error(colors.red(error.text));
});
throw new Error("Build failed with errors");
}
log.info(colors.green("✅ Bundle created successfully"));
const outputFiles = result.outputFiles ?? [];
const jsFile = outputFiles.find((f) => f.path.endsWith(".js"));
const cssFile = outputFiles.find((f) => f.path.endsWith(".css"));
if (!jsFile) {
throw new Error("Expected a JS bundle in esbuild output but none found");
}
try {
fs.rmSync(distDir, { recursive: true });
} catch {
//ignore
}
return { js: jsFile.text, css: cssFile?.text ?? "" };
} finally {
// Stop the native esbuild service so the process can exit (no-op for wasm).
await stopEsbuild();
}
}
/**
* Gets the esbuild build options for use in watch mode (dev server)
* @param entryPoint Entry point file
* @returns esbuild build options
*/
export function getDevBuildOptions(entryPoint: string = "index.tsx") {
return {
...DEFAULT_BUILD_OPTIONS,
entryPoints: [entryPoint],
outfile: "dist/bundle.js",
sourcemap: true,
define: {
"process.env.NODE_ENV": '"development"',
},
};
}