mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
86d1d160f0
* 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>
353 lines
11 KiB
TypeScript
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"',
|
|
},
|
|
};
|
|
}
|