Files
windmill/cli/src/utils/local_path_scripts.ts
T
Ruben FiszelandClaude Opus 4.8 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

158 lines
4.7 KiB
TypeScript

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";
import { findCodebase } from "../commands/sync/sync.ts";
import type { LocalScriptInfo } from "../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import type { RawScript } from "../../../gen/types.gen.ts";
export class UnsupportedLocalPathScriptPreviewError extends Error {
constructor(message: string) {
super(message);
this.name = "UnsupportedLocalPathScriptPreviewError";
}
}
async function readOptionalLock(scriptPath: string): Promise<string | undefined> {
try {
return await readTextFile(scriptPath + ".script.lock");
} catch {
return undefined;
}
}
function normalizeOptionalLock(lock: string | undefined): string | undefined {
return typeof lock === "string" && lock.trim() === "" ? undefined : lock;
}
async function bundleSingleFileCodebaseScript(
filePath: string,
codebase: SyncCodebase
): Promise<string> {
if (codebase.customBundler) {
// Pass the script path as a positional shell argument so existing shell-based
// custom bundlers still work without interpolating the path into the command.
return execFileSync(
"sh",
["-lc", `${codebase.customBundler} "$1"`, "sh", filePath],
{
maxBuffer: 1024 * 1024 * 50,
}
).toString();
}
const esbuild = await getEsbuild();
const out = await esbuild.build({
entryPoints: [filePath],
// Inline rawscripts are executed through the standard module wrapper,
// so the bundle must expose `main` as an ESM export.
format: "esm",
bundle: true,
write: false,
external: codebase.external,
inject: codebase.inject,
define: codebase.define,
loader: codebase.loader ?? { ".node": "file" },
outdir: "/",
platform: "node",
packages: "bundle",
target: "esnext",
banner: codebase.banner,
});
if (out.outputFiles.length === 0) {
throw new Error(`No output files found for ${filePath}`);
}
if (out.outputFiles.length > 1) {
throw new UnsupportedLocalPathScriptPreviewError(
`Local PathScript ${filePath} requires a multi-file bundle, which flow preview/dev cannot inline yet`
);
}
if (Array.isArray(codebase.assets) && codebase.assets.length > 0) {
throw new UnsupportedLocalPathScriptPreviewError(
`Local PathScript ${filePath} requires codebase assets, which flow preview/dev cannot inline yet`
);
}
return out.outputFiles[0].text;
}
export function createPreviewLocalScriptReader(opts: {
exts: string[];
defaultTs?: "bun" | "deno";
codebases: SyncCodebase[];
}): (scriptPath: string) => Promise<LocalScriptInfo | undefined> {
return async (scriptPath) => {
const localScript = await resolvePreviewLocalScriptState(scriptPath, opts);
if (!localScript) {
return undefined;
}
const content = localScript.codebase
? await bundleSingleFileCodebaseScript(localScript.filePath, localScript.codebase)
: localScript.content;
return {
content,
language: localScript.language,
lock: localScript.lock,
tag: localScript.tag,
};
};
}
export type PreviewLocalScriptState = {
filePath: string;
content: string;
language: RawScript["language"];
lock?: string;
tag?: string;
codebase?: SyncCodebase;
codebaseDigest?: string;
};
export async function resolvePreviewLocalScriptState(
scriptPath: string,
opts: {
exts: string[];
defaultTs?: "bun" | "deno";
codebases: SyncCodebase[];
}
): Promise<PreviewLocalScriptState | undefined> {
for (const ext of opts.exts) {
const filePath = scriptPath + ext;
let fileStat;
try {
fileStat = await stat(filePath);
} catch {
continue;
}
if (!fileStat.isFile()) continue;
const language = inferContentTypeFromFilePath(filePath, opts.defaultTs);
const metadata = await parseMetadataFileIfExists(scriptPath);
const rawLock = metadata?.payload?.lock ?? (await readOptionalLock(scriptPath));
const codebase =
language === "bun" ? findCodebase(filePath, opts.codebases) : undefined;
return {
filePath,
content: await readTextFile(filePath),
language,
lock: normalizeOptionalLock(rawLock),
tag: metadata?.payload?.tag,
codebase,
codebaseDigest: codebase
? await codebase.getDigest(
Array.isArray(codebase.assets) && codebase.assets.length > 0
)
: undefined,
};
}
return undefined;
}