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>
This commit is contained in:
Ruben Fiszel
2026-06-17 02:33:18 +02:00
committed by GitHub
parent 8a3f69dda8
commit 86d1d160f0
7 changed files with 289 additions and 20 deletions
+3
View File
@@ -28,6 +28,9 @@ rmSync(outDir, { recursive: true, force: true });
// Build with bun — bundle everything except esbuild (platform-specific binary),
// svelte (optional, only needed for `wmill app bundle/dev`), and parser packages
// (loaded at runtime via init() with readFileSync for the .wasm binary).
// esbuild-wasm is not a dependency at all: the host/binary-mismatch fallback in
// esbuild_loader.ts downloads and caches the whole esbuild-wasm package at
// runtime, so it stays out of the bundle and the published dependencies.
console.log("Bundling with bun build...");
const buildResult = Bun.spawnSync([
"bun", "build", "src/main.ts",
+17 -16
View File
@@ -6,6 +6,7 @@ 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;
@@ -170,8 +171,9 @@ export async function ensureNodeModules(appDir?: string): Promise<void> {
export async function createBundle(
options: BundleOptions = {}
): Promise<BundleResult> {
// Dynamically import esbuild
const esbuild = await import("esbuild");
// 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.
@@ -286,6 +288,10 @@ export async function createBundle(
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"',
},
@@ -307,29 +313,24 @@ export async function createBundle(
log.info(colors.green("✅ Bundle created successfully"));
// Read the generated files
const jsPath = path.join(process.cwd(), outfile);
const cssPath = path.join(process.cwd(), outDir, "bundle.css");
const outputFiles = result.outputFiles ?? [];
const jsFile = outputFiles.find((f) => f.path.endsWith(".js"));
const cssFile = outputFiles.find((f) => f.path.endsWith(".css"));
if (!fs.existsSync(jsPath)) {
throw new Error(`Expected JS bundle at ${jsPath} but file not found`);
if (!jsFile) {
throw new Error("Expected a JS bundle in esbuild output but none found");
}
const jsContent = readTextFileSync(jsPath);
const cssContent = fs.existsSync(cssPath)
? readTextFileSync(cssPath)
: "";
try {
fs.rmSync(distDir, { recursive: true });
} catch {
//ignore
}
return { js: jsContent, css: cssContent };
return { js: jsFile.text, css: cssFile?.text ?? "" };
} finally {
// Stop esbuild
await esbuild.stop();
// Stop the native esbuild service so the process can exit (no-op for wasm).
await stopEsbuild();
}
}
+5 -1
View File
@@ -437,7 +437,11 @@ async function dev(opts: DevOptions, appFolder?: string) {
const rawApp = (await yamlParseFile(rawAppPath)) as any;
const appPath = rawApp?.custom_path ?? "u/unknown/newapp";
// Dynamically import esbuild only when the dev command is called
// Dynamically import esbuild only when the dev command is called.
// Native-only here (no esbuild-wasm fallback via getEsbuild): dev is a local
// interactive command that relies on context()/watch, whose semantics under
// wasm are untested. The host/binary-mismatch fallback covers the bundling
// paths that run on workers/CI via `wmill sync push`.
const esbuild = await import("esbuild");
const host = opts.host ?? DEFAULT_HOST;
+3 -2
View File
@@ -58,6 +58,7 @@ import { SyncCodebase, listSyncCodebases } from "../../utils/codebase.ts";
import { pollJobWithQueueLogging } from "../../utils/job_polling.ts";
import fs from "node:fs";
import { createTarBlob, type TarEntry } from "../../utils/tar.ts";
import { getEsbuild } from "../../utils/esbuild_loader.ts";
import { execSync } from "node:child_process";
import { NewScript, Script, ScriptModule } from "../../../gen/types.gen.ts";
@@ -328,7 +329,7 @@ export async function handleFile(
}).toString();
log.info("Custom bundler executed for " + path);
} else {
const esbuild = await import("esbuild");
const esbuild = await getEsbuild();
log.info(`Started bundling ${path} ...`);
const startTime = performance.now();
@@ -1565,7 +1566,7 @@ async function preview(
maxBuffer: 1024 * 1024 * 50,
}).toString();
} else {
const esbuild = await import("esbuild");
const esbuild = await getEsbuild();
if (!opts.silent) {
log.info(`Bundling ${filePath} for preview...`);
+228
View File
@@ -0,0 +1,228 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import process from "node:process";
import { createGunzip } from "node:zlib";
import { Readable } from "node:stream";
import { pathToFileURL } from "node:url";
import * as tar from "tar-stream";
import * as log from "../core/log.ts";
// esbuild splits into a JS host package and a per-platform native binary
// (@esbuild/<platform>). They must be the same version. A broken or incremental
// install can leave the on-disk binary at a different version than the pinned
// host, which crashes service start with
// Cannot start service: Host version "X" does not match binary version "Y"
// The running code can't fix what npm/bun put on disk, so when that happens we
// fall back to esbuild-wasm, whose binary is a single version-pinned .wasm. To
// keep that 14MB out of every CLI install, the esbuild-wasm package is not a
// dependency: it is downloaded once and cached on disk, on the fallback path
// only. We download the whole package (not just the .wasm) because esbuild-wasm
// reads the app's files from disk by spawning `node bin/esbuild`, which needs
// bin/esbuild + esbuild.wasm + wasm_exec*.js co-located on disk.
type Esbuild = typeof import("esbuild");
// Version to fall back to if the native host's version can't be read. Keep in
// sync with the "esbuild" pin in cli/package.json.
const FALLBACK_VERSION = "0.28.0";
let cached: Esbuild | undefined;
let inFlight: Promise<Esbuild> | undefined;
// Distinguishes concurrent extraction temp dirs within a process.
let extractCounter = 0;
/**
* Returns a working esbuild module, preferring the native binary and falling
* back to esbuild-wasm only when the native host/binary versions don't match.
* Memoized for the process: concurrent first callers (e.g. a parallel
* `wmill sync push`) share one probe/download instead of each running their own.
*/
export function getEsbuild(): Promise<Esbuild> {
if (cached) return Promise.resolve(cached);
if (inFlight) return inFlight;
inFlight = acquireEsbuild()
.then((esbuild) => {
cached = esbuild;
return esbuild;
})
.finally(() => {
inFlight = undefined;
});
return inFlight;
}
async function acquireEsbuild(): Promise<Esbuild> {
// Escape hatch: skip native entirely (e.g. a host known to have a broken
// install, or to exercise the fallback path).
if (process.env.WINDMILL_FORCE_ESBUILD_WASM) {
return loadWasmEsbuild(await nativeHostVersion());
}
try {
const esbuild = await import("esbuild");
// The native service only starts on the first call; force it with the most
// trivial op so any breakage (host/binary version mismatch, a dead service)
// surfaces now rather than mid-build. The mismatch detail is printed to the
// child's stderr while the thrown error is generic ("service was stopped"),
// so we fall back on ANY smoke-test failure rather than matching a string.
await esbuild.transform("");
return esbuild;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.warn(
`native esbuild is not usable; falling back to esbuild-wasm (${msg.trim()})`
);
}
return loadWasmEsbuild(await nativeHostVersion());
}
/**
* Stops the esbuild service (native or wasm — both spawn a child process) so the
* process can exit. Safe to call repeatedly; the service restarts lazily on the
* next build.
*/
export async function stopEsbuild(): Promise<void> {
await cached?.stop();
}
async function nativeHostVersion(): Promise<string> {
try {
return (await import("esbuild")).version ?? FALLBACK_VERSION;
} catch {
return FALLBACK_VERSION;
}
}
async function loadWasmEsbuild(version: string): Promise<Esbuild> {
const pkgDir = await ensureWasmPackage(version);
const mainJs = path.join(pkgDir, "lib", "main.js");
// The Node build (lib/main.js) reads app files from disk by spawning
// `node bin/esbuild`, so it works with on-disk entry points and node_modules,
// unlike the browser build.
return (await import(pathToFileURL(mainJs).href)) as unknown as Esbuild;
}
/**
* Returns a directory containing an extracted esbuild-wasm package (with
* lib/main.js). Uses an explicit override, then an on-disk cache, then downloads
* and extracts the npm tarball.
*/
async function ensureWasmPackage(version: string): Promise<string> {
// Explicit local override wins (air-gapped / self-hosted workers): a path to
// an already-extracted esbuild-wasm package directory.
const override = process.env.WINDMILL_ESBUILD_WASM_PATH;
if (override) return override;
const destDir = path.join(cacheDir(), `esbuild-wasm-${version}`);
if (fs.existsSync(path.join(destDir, "lib", "main.js"))) {
return destDir;
}
const url = process.env.WINDMILL_ESBUILD_WASM_URL ??
`https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-${version}.tgz`;
log.info(`Downloading esbuild-wasm@${version} from ${url} ...`);
const res = await fetch(url);
if (!res.ok || !res.body) {
throw new Error(
`Failed to download esbuild-wasm@${version} (${res.status} ${res.statusText}). ` +
`Set WINDMILL_ESBUILD_WASM_PATH to an extracted esbuild-wasm package dir, ` +
`point WINDMILL_ESBUILD_WASM_URL at a reachable tarball, or repair the native esbuild install.`
);
}
// Extract to a unique temp dir and rename into place so a crash or a
// concurrent writer can't leave a half-extracted package behind, and so two
// extractions never share an in-progress directory.
const tmpDir = `${destDir}.${process.pid}.${extractCounter++}.tmp`;
fs.rmSync(tmpDir, { recursive: true, force: true });
await extractTarball(res.body, tmpDir);
if (!fs.existsSync(path.join(tmpDir, "lib", "main.js"))) {
fs.rmSync(tmpDir, { recursive: true, force: true });
throw new Error(`esbuild-wasm@${version} tarball did not contain lib/main.js`);
}
try {
fs.renameSync(tmpDir, destDir);
} catch {
// Another process won the race, or rename across devices failed; clean up
// and let the existsSync check below decide whether the cache is usable.
fs.rmSync(tmpDir, { recursive: true, force: true });
}
if (!fs.existsSync(path.join(destDir, "lib", "main.js"))) {
throw new Error(`Failed to cache esbuild-wasm@${version} at ${destDir}`);
}
return destDir;
}
/**
* Resolves a tar entry to an absolute path inside destDir, stripping the leading
* "package/" component that npm tarballs use. Returns null if the entry would
* escape destDir (tar-slip), since WINDMILL_ESBUILD_WASM_URL allows untrusted
* tarball sources.
*/
export function resolveTarEntryPath(
destDir: string,
entryName: string
): string | null {
const rel = entryName.replace(/^[^/]+\//, "");
const root = path.resolve(destDir);
const outPath = path.resolve(root, rel);
if (outPath !== root && !outPath.startsWith(root + path.sep)) {
return null;
}
return outPath;
}
// Extracts an npm tarball (gzipped tar) into destDir, stripping the leading
// "package/" path component that npm tarballs use.
async function extractTarball(
body: ReadableStream<Uint8Array>,
destDir: string
): Promise<void> {
const extract = tar.extract();
extract.on("entry", (header, stream, next) => {
if (header.type !== "file") {
stream.resume();
stream.on("end", next);
return;
}
const outPath = resolveTarEntryPath(destDir, header.name);
if (!outPath) {
// Reject tar-slip entries that would write outside the cache dir.
stream.resume();
stream.on("end", () =>
next(new Error(`unsafe path in esbuild-wasm tarball: ${header.name}`))
);
return;
}
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const ws = fs.createWriteStream(outPath, { mode: header.mode ?? 0o644 });
stream.pipe(ws);
ws.on("finish", next);
ws.on("error", next);
stream.on("error", next);
});
await new Promise<void>((resolve, reject) => {
extract.on("finish", resolve);
extract.on("error", reject);
Readable.fromWeb(body as unknown as Parameters<typeof Readable.fromWeb>[0])
.pipe(createGunzip())
.on("error", reject)
.pipe(extract)
.on("error", reject);
});
}
function cacheDir(): string {
const explicit = process.env.WINDMILL_CACHE_DIR;
if (explicit) return explicit;
const xdg = process.env.XDG_CACHE_HOME;
if (xdg) return path.join(xdg, "windmill");
try {
return path.join(os.homedir(), ".cache", "windmill");
} catch {
return path.join(os.tmpdir(), "windmill");
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { execFileSync } from "node:child_process";
import { stat } from "node:fs/promises";
import { readTextFile } from "./utils.ts";
import { getEsbuild } from "./esbuild_loader.ts";
import type { SyncCodebase } from "./codebase.ts";
import { parseMetadataFileIfExists } from "./metadata.ts";
import { inferContentTypeFromFilePath } from "./script_common.ts";
@@ -43,7 +44,7 @@ async function bundleSingleFileCodebaseScript(
).toString();
}
const esbuild = await import("esbuild");
const esbuild = await getEsbuild();
const out = await esbuild.build({
entryPoints: [filePath],
// Inline rawscripts are executed through the standard module wrapper,
+31
View File
@@ -0,0 +1,31 @@
/**
* Unit tests for esbuild_loader pure logic (no backend, no network).
*/
import { expect, test, describe } from "bun:test";
import { resolveTarEntryPath } from "../src/utils/esbuild_loader.ts";
import { sep, resolve } from "node:path";
describe("resolveTarEntryPath", () => {
const dest = resolve("/tmp/cache/esbuild-wasm-0.28.0");
test("strips the leading package/ component and resolves inside dest", () => {
expect(resolveTarEntryPath(dest, "package/lib/main.js")).toBe(
dest + sep + "lib" + sep + "main.js"
);
expect(resolveTarEntryPath(dest, "package/esbuild.wasm")).toBe(
dest + sep + "esbuild.wasm"
);
});
test("rejects tar-slip entries that escape the dest dir", () => {
expect(resolveTarEntryPath(dest, "package/../../etc/passwd")).toBeNull();
expect(resolveTarEntryPath(dest, "package/../../../outside")).toBeNull();
});
test("rejects entries that only share a prefix with dest", () => {
// ".../esbuild-wasm-0.28.0-evil" must not be treated as inside dest
expect(resolveTarEntryPath(dest, "evil/../../esbuild-wasm-0.28.0-evil/x"))
.toBeNull();
});
});