mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014bq66fSmaPvLtjnDPfMYcj
333 lines
10 KiB
JavaScript
333 lines
10 KiB
JavaScript
// Node's ESM loader only exposes the named exports of a CommonJS package that
|
|
// cjs-module-lexer can detect statically. lodash & co. build `module.exports` at
|
|
// runtime, so `import { chunk } from "lodash"` fails at instantiation and
|
|
// `import * as _ from "lodash"` yields a namespace holding nothing but `default`.
|
|
// npm packages stay external to the bundle, so Bun emits those import statements
|
|
// verbatim and node hits the limitation; rewrite them to a namespace import plus
|
|
// a lookup that falls back to `default` (i.e. `module.exports`).
|
|
//
|
|
// Only packages node loads as CommonJS are rewritten: the rewrite turns named
|
|
// imports into snapshots, which would freeze an ESM export its package mutates
|
|
// after evaluation.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { dirname, extname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const WM_IDENT = "[A-Za-z_$][A-Za-z0-9_$]*";
|
|
const WM_NS_CLAUSE = `\\*\\s*as\\s+${WM_IDENT}`;
|
|
const WM_NAMED_CLAUSE = "\\{[^{}]*\\}";
|
|
const WM_CLAUSE = `(?:${WM_NS_CLAUSE}|${WM_NAMED_CLAUSE}|${WM_IDENT}(?:\\s*,\\s*(?:${WM_NS_CLAUSE}|${WM_NAMED_CLAUSE}))?)`;
|
|
const WM_ATTRS = "\\s*(?:with|assert)\\s*\\{[^{}]*\\}";
|
|
const WM_IMPORT = `(^|[;}\\n])import\\s*(?:(${WM_CLAUSE})\\s*from\\s*)?(?:"([^"\\n]*)"|'([^'\\n]*)')(${WM_ATTRS})?`;
|
|
|
|
function wmRewriteExternalImports(code, externals, jobDir, nodePath) {
|
|
if (!externals || externals.length === 0) {
|
|
return code;
|
|
}
|
|
const isExternal = (spec) =>
|
|
externals.some((name) => spec === name || spec.startsWith(name + "/"));
|
|
|
|
// Import statements are found on a copy whose literals are blanked out, so
|
|
// that generated code holding an import statement in a string is not touched.
|
|
// Blanking preserves offsets, but mis-pairing a literal's boundaries can still
|
|
// expose a string's contents as code, so the parser cross-check below is what
|
|
// makes the rewrite safe — it is load-bearing, not a belt-and-braces extra.
|
|
const masked = wmMaskLiterals(code);
|
|
const anchored = new RegExp(WM_IMPORT);
|
|
const matches = [];
|
|
for (const hit of masked.matchAll(new RegExp(WM_IMPORT, "g"))) {
|
|
const m = code.slice(hit.index, hit.index + hit[0].length).match(anchored);
|
|
if (m === null || m.index !== 0) {
|
|
continue;
|
|
}
|
|
const spec = m[3] !== undefined ? m[3] : m[4];
|
|
if (isExternal(spec)) {
|
|
matches.push({
|
|
at: hit.index,
|
|
len: hit[0].length,
|
|
lead: m[1],
|
|
clause: m[2],
|
|
spec,
|
|
attrs: m[5] ?? "",
|
|
});
|
|
}
|
|
}
|
|
if (matches.length === 0) {
|
|
return code;
|
|
}
|
|
|
|
const commonjs = wmCommonJsSpecs(
|
|
[...new Set(matches.map((m) => m.spec))],
|
|
jobDir,
|
|
nodePath
|
|
);
|
|
const found = matches.filter((m) => commonjs.has(m.spec));
|
|
if (found.length === 0) {
|
|
return code;
|
|
}
|
|
|
|
// If the statements found are not the ones the parser sees, the masking
|
|
// mis-paired a literal somewhere: leave the bundle alone rather than corrupt it.
|
|
const counts = new Map();
|
|
for (const f of found) {
|
|
counts.set(f.spec, (counts.get(f.spec) ?? 0) + 1);
|
|
}
|
|
for (const imp of new Bun.Transpiler({ loader: "js" }).scanImports(code)) {
|
|
if (imp.kind === "import-statement" && counts.has(imp.path)) {
|
|
counts.set(imp.path, counts.get(imp.path) - 1);
|
|
}
|
|
}
|
|
for (const [spec, left] of counts) {
|
|
if (left !== 0) {
|
|
console.log(
|
|
`Skipping CommonJS interop rewrite of "${spec}": import statements found do not match the parsed ones`
|
|
);
|
|
return code;
|
|
}
|
|
}
|
|
|
|
let prefix = "__wm_ext";
|
|
while (code.includes(prefix)) {
|
|
prefix += "_";
|
|
}
|
|
const getHelper = `${prefix}Get`;
|
|
const nsHelper = `${prefix}Ns`;
|
|
let out = "";
|
|
let cursor = 0;
|
|
let count = 0;
|
|
for (const f of found) {
|
|
const parts = f.clause === undefined ? null : wmParseImportClause(f.clause);
|
|
// A default-only import already resolves to `module.exports` under node.
|
|
if (parts === null || (parts.named.length === 0 && parts.ns === null)) {
|
|
continue;
|
|
}
|
|
const ns = `${prefix}${count++}`;
|
|
const decls = [];
|
|
if (parts.def !== null) {
|
|
decls.push(`${parts.def}=${ns}.default`);
|
|
}
|
|
if (parts.ns !== null) {
|
|
decls.push(`${parts.ns}=${nsHelper}(${ns})`);
|
|
}
|
|
for (const [imported, local] of parts.named) {
|
|
decls.push(
|
|
`${local}=${getHelper}(${ns},${JSON.stringify(imported)},${JSON.stringify(f.spec)})`
|
|
);
|
|
}
|
|
out +=
|
|
code.slice(cursor, f.at) +
|
|
`${f.lead}import*as ${ns} from${JSON.stringify(f.spec)}${f.attrs};const ${decls.join(",")};`;
|
|
cursor = f.at + f.len;
|
|
}
|
|
if (count === 0) {
|
|
return code;
|
|
}
|
|
|
|
return (
|
|
`var ${getHelper}=(n,k,s)=>{if(k in n)return n[k];let d=Object(n.default);if(k in d)return d[k];` +
|
|
"throw new SyntaxError(`The requested module '${s}' does not provide an export named '${k}'`)};" +
|
|
`var ${nsHelper}=(n)=>{let d=n.default;if(d==null||typeof d!=="object"&&typeof d!=="function")return n;` +
|
|
`let t={},a=(o,k)=>Object.defineProperty(t,k,{get:()=>o[k],enumerable:!0,configurable:!0});` +
|
|
`for(let k of Object.keys(d))a(d,k);for(let k of Object.keys(n))a(n,k);` +
|
|
`return new Proxy(t,{get:(x,k,r)=>k in x?Reflect.get(x,k,r):d[k],has:(x,k)=>k in x||k in d})};` +
|
|
out +
|
|
code.slice(cursor)
|
|
);
|
|
}
|
|
|
|
// Node runs the bundle, and conditional exports can hand it a different file
|
|
// than they hand bun, so ask node itself where each specifier resolves. Bun's
|
|
// resolver is only the fallback for a node too old for `import.meta.resolve`.
|
|
function wmCommonJsSpecs(specs, jobDir, nodePath) {
|
|
const commonjs = new Set();
|
|
let resolved = null;
|
|
const probe =
|
|
"if(typeof import.meta.resolve!=='function')process.exit(3);const out={};" +
|
|
"for(const s of JSON.parse(process.argv[1])){try{out[s]=import.meta.resolve(s)}catch(e){out[s]=null}}" +
|
|
"console.log(JSON.stringify(out))";
|
|
try {
|
|
const run = Bun.spawnSync([nodePath, "--input-type=module", "-e", probe, JSON.stringify(specs)], {
|
|
cwd: jobDir,
|
|
});
|
|
if (run.success) {
|
|
resolved = JSON.parse(run.stdout.toString());
|
|
}
|
|
} catch (err) {}
|
|
for (const spec of specs) {
|
|
let file;
|
|
if (resolved === null) {
|
|
try {
|
|
file = Bun.resolveSync(spec, jobDir);
|
|
} catch (err) {
|
|
continue;
|
|
}
|
|
} else if (resolved[spec]?.startsWith("file:")) {
|
|
file = fileURLToPath(resolved[spec]);
|
|
} else {
|
|
continue;
|
|
}
|
|
if (wmIsCommonJsFile(file)) {
|
|
commonjs.add(spec);
|
|
}
|
|
}
|
|
return commonjs;
|
|
}
|
|
|
|
// Node's own rule for a file's format: the extension decides, and `.js` — like an
|
|
// extensionless entry — follows the `type` of the closest package.json. Only the
|
|
// formats node loads through CommonJS qualify; a JSON module exposes `default`
|
|
// alone and must keep that shape.
|
|
function wmIsCommonJsFile(file) {
|
|
const ext = extname(file);
|
|
if (ext === ".cjs" || ext === ".node") {
|
|
return true;
|
|
}
|
|
if (ext !== ".js" && ext !== "") {
|
|
return false;
|
|
}
|
|
let dir = dirname(file);
|
|
for (;;) {
|
|
let pkg = null;
|
|
try {
|
|
pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
} catch (err) {}
|
|
if (pkg !== null) {
|
|
return pkg.type !== "module";
|
|
}
|
|
const parent = dirname(dir);
|
|
if (parent === dir) {
|
|
return true;
|
|
}
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
function wmParseImportClause(clause) {
|
|
let def = null;
|
|
let ns = null;
|
|
const named = [];
|
|
let rest = clause.trim();
|
|
|
|
if (!rest.startsWith("{") && !rest.startsWith("*")) {
|
|
const m = rest.match(new RegExp(`^(${WM_IDENT})\\s*(?:,([\\s\\S]*))?$`));
|
|
if (m === null) {
|
|
return null;
|
|
}
|
|
def = m[1];
|
|
rest = (m[2] ?? "").trim();
|
|
}
|
|
|
|
if (rest.startsWith("*")) {
|
|
const m = rest.match(new RegExp(`^\\*\\s*as\\s+(${WM_IDENT})$`));
|
|
if (m === null) {
|
|
return null;
|
|
}
|
|
ns = m[1];
|
|
} else if (rest.startsWith("{")) {
|
|
const specifier = new RegExp(
|
|
`^(${WM_IDENT}|"[^"]*"|'[^']*')(?:\\s+as\\s+(${WM_IDENT}))?$`
|
|
);
|
|
for (const part of rest.slice(1, rest.lastIndexOf("}")).split(",")) {
|
|
const trimmed = part.trim();
|
|
if (trimmed === "") {
|
|
continue;
|
|
}
|
|
const m = trimmed.match(specifier);
|
|
if (m === null) {
|
|
return null;
|
|
}
|
|
const quoted = m[1].startsWith('"') || m[1].startsWith("'");
|
|
if (quoted && m[2] === undefined) {
|
|
return null;
|
|
}
|
|
named.push([quoted ? m[1].slice(1, -1) : m[1], m[2] ?? m[1]]);
|
|
}
|
|
} else if (rest !== "") {
|
|
return null;
|
|
}
|
|
|
|
return { def, ns, named };
|
|
}
|
|
|
|
// Replaces the contents of every string, template, comment and regex literal
|
|
// with `x`, keeping the delimiters, every newline and the total length.
|
|
function wmMaskLiterals(code) {
|
|
let out = "";
|
|
let i = 0;
|
|
let cursor = 0;
|
|
let prev = "";
|
|
const blank = (from, to) => {
|
|
out += code.slice(cursor, from);
|
|
for (const c of code.slice(from, to)) {
|
|
out += c === "\n" ? "\n" : "x";
|
|
}
|
|
cursor = to;
|
|
};
|
|
while (i < code.length) {
|
|
const c = code[i];
|
|
if (c === '"' || c === "'" || c === "`") {
|
|
const start = ++i;
|
|
while (i < code.length) {
|
|
if (code[i] === "\\") {
|
|
i += 2;
|
|
} else if (code[i] === c) {
|
|
break;
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
blank(start, Math.min(i, code.length));
|
|
i++;
|
|
prev = "'";
|
|
continue;
|
|
}
|
|
if (c === "/" && code[i + 1] === "/") {
|
|
const start = i + 2;
|
|
while (i < code.length && code[i] !== "\n") {
|
|
i++;
|
|
}
|
|
blank(start, i);
|
|
continue;
|
|
}
|
|
if (c === "/" && code[i + 1] === "*") {
|
|
const end = code.indexOf("*/", i + 2);
|
|
const start = i + 2;
|
|
i = end === -1 ? code.length : end + 2;
|
|
blank(start, Math.max(start, i - 2));
|
|
continue;
|
|
}
|
|
// `/` only starts a regex where an operand cannot stand; a wrong guess can
|
|
// only make the masking hide more than it should, which the parser
|
|
// cross-check catches.
|
|
if (c === "/" && !/[A-Za-z0-9_$)\]]/.test(prev)) {
|
|
const start = ++i;
|
|
let inClass = false;
|
|
while (i < code.length) {
|
|
if (code[i] === "\\") {
|
|
i += 2;
|
|
} else if (code[i] === "[") {
|
|
inClass = true;
|
|
i++;
|
|
} else if (code[i] === "]") {
|
|
inClass = false;
|
|
i++;
|
|
} else if (code[i] === "\n" || (code[i] === "/" && !inClass)) {
|
|
break;
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
blank(start, Math.min(i, code.length));
|
|
i++;
|
|
prev = "'";
|
|
continue;
|
|
}
|
|
if (!/\s/.test(c)) {
|
|
prev = c;
|
|
}
|
|
i++;
|
|
}
|
|
return out + code.slice(cursor);
|
|
}
|