andy-stack_vaultkeeper-ai/esbuild.config.mjs
Andrew Beal 7e13e00fde refactor: add .wasm filename neutralisation to build process
Extends the build neutralisation plugin to rewrite the dead qcms_bg.wasm
reference from unpdf's vendored PDF.js. The reference is unreachable (code
path never entered, loader gated, file not shipped) but trips Obsidian's
.wasm scanner. Build now fails if .wasm reference count drifts, preventing
undocumented changes from slipping through.
2026-05-31 20:35:12 +01:00

321 lines
No EOL
12 KiB
JavaScript

import esbuild from "esbuild";
import process from "process";
import { builtinModules as builtins } from "node:module";
import { copyFileSync, mkdirSync, existsSync, readdirSync, statSync, readFileSync, writeFileSync, unlinkSync, watch as fsWatch } from "fs";
import { join } from "path";
import esbuildSvelte from "esbuild-svelte";
import { sveltePreprocess } from "svelte-preprocess";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
// Clean up build artifacts that aren"t needed (main.css, main.js, font files)
const CLEANUP_BUILD_ARTIFACTS = true; // Set to false if you need to debug these files
// Function to copy directory recursively
function copyDir(src, dest) {
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
}
const files = readdirSync(src);
for (const file of files) {
const srcPath = join(src, file);
const destPath = join(dest, file);
if (statSync(srcPath).isDirectory()) {
copyDir(srcPath, destPath);
} else {
copyFileSync(srcPath, destPath);
console.log(`📁 Copied: ${srcPath}${destPath}`);
}
}
}
// Neutralises `new Function(...)` literals in the bundled output.
//
// All such literals come from dependencies, never from our own source:
// - unpdf/PDF.js: 3x feature-detection probes `new Function("")` wrapped in
// try/catch (a throw makes them return `false` = "eval not supported", the
// safe answer), plus 1x PostScript JIT compiler gated behind
// `isEvalSupported` (we pass `false`, so it is never entered).
// - diff2html/@profoundlogic/hogan: 2x template compilers reachable only via
// Hogan.compile(), which diff2html invokes only for runtime `rawTemplates`.
// We use diff2html's precompiled defaults, so these never run.
//
// Every occurrence is therefore dead code in our usage. We rewrite them to a
// throwing stub so the bundle contains no `new Function(` literal, which clears
// the Obsidian scanner's "Dynamic Code Execution" warning. The build FAILS if
// the count drifts from EXPECTED_NEW_FUNCTION_COUNT, so a dependency change can
// never silently reintroduce a live (or un-neutralised) dynamic-eval path.
const EXPECTED_NEW_FUNCTION_COUNT = 6;
const NEW_FUNCTION_STUB = "VKBlockedDynamicFn";
function neutraliseDynamicEval(outfile) {
if (!existsSync(outfile)) return;
let contents = readFileSync(outfile, "utf-8");
const matches = contents.match(/new Function\s*\(/g) || [];
if (matches.length !== EXPECTED_NEW_FUNCTION_COUNT) {
throw new Error(
`neutraliseDynamicEval: expected ${EXPECTED_NEW_FUNCTION_COUNT} ` +
`\`new Function(\` literals in ${outfile} but found ${matches.length}. ` +
`A dependency changed — re-audit which library introduced/removed the ` +
`call and confirm it is unreachable before updating EXPECTED_NEW_FUNCTION_COUNT.`,
);
}
// Throwing stub: callable as `new VKBlockedDynamicFn(...)` with any args.
const stubDecl =
`function ${NEW_FUNCTION_STUB}(){throw new Error("Dynamic code execution is disabled in this build.")}`;
contents = contents.replace(/new Function\s*\(/g, `new ${NEW_FUNCTION_STUB}(`);
// Insert the stub right after the generated banner comment (a hoisted function
// declaration is in scope for the whole module regardless of position).
const bannerEnd = contents.indexOf("*/");
if (bannerEnd !== -1) {
const insertAt = contents.indexOf("\n", bannerEnd) + 1;
contents = contents.slice(0, insertAt) + stubDecl + "\n" + contents.slice(insertAt);
} else {
contents = `${stubDecl}\n${contents}`;
}
writeFileSync(outfile, contents);
console.log(
`🛡️ Neutralised ${matches.length} \`new Function(\` literal(s) in ${outfile}`,
);
}
// Neutralises the lone `.wasm` filename literal in the bundled output.
//
// The reference comes from unpdf's vendored PDF.js: `${this.#x}qcms_bg.wasm`,
// where qcms is PDF.js's WebAssembly colour-management module (ICC profile
// transforms, used only when *rendering* colour-accurate images). Our PDF code
// (Helpers/DocumentHelper.ts) only calls extractText for text, never renders,
// so the qcms loader is never entered. The loader is also gated behind two
// private-field checks and would resolve the file via a relative URL we do not
// ship, so even if reached it falls back to PDF.js's JS path.
//
// The literal is therefore dead, but its `.wasm` filename trips Obsidian's
// "unrecognised .wasm" scanner (native binary that can't be statically
// reviewed). We rewrite the `qcms_bg.wasm` filename to a non-.wasm name so no
// `.wasm` literal remains; the surrounding (unreachable) code stays intact. The
// build FAILS if the count drifts from EXPECTED_WASM_REF_COUNT, so a dependency
// change can never silently reintroduce an undocumented .wasm reference.
const EXPECTED_WASM_REF_COUNT = 1;
const WASM_REF_FROM = "qcms_bg.wasm";
const WASM_REF_TO = "qcms_bg.disabled-wasm";
function neutraliseWasmRef(outfile) {
if (!existsSync(outfile)) return;
let contents = readFileSync(outfile, "utf-8");
const wasmLiterals = contents.match(/['"`][^'"`]*\.wasm['"`]/g) || [];
if (wasmLiterals.length !== EXPECTED_WASM_REF_COUNT) {
throw new Error(
`neutraliseWasmRef: expected ${EXPECTED_WASM_REF_COUNT} \`.wasm\` ` +
`string literal(s) in ${outfile} but found ${wasmLiterals.length} ` +
`(${wasmLiterals.join(", ")}). A dependency changed — re-audit which ` +
`library introduced/removed the reference and confirm it is unreachable ` +
`before updating EXPECTED_WASM_REF_COUNT.`,
);
}
if (!contents.includes(WASM_REF_FROM)) {
throw new Error(
`neutraliseWasmRef: expected to find \`${WASM_REF_FROM}\` in ${outfile} ` +
`but it was absent — the .wasm reference changed shape. Re-audit before shipping.`,
);
}
contents = contents.split(WASM_REF_FROM).join(WASM_REF_TO);
writeFileSync(outfile, contents);
console.log(
`🛡️ Neutralised \`.wasm\` reference (${WASM_REF_FROM}${WASM_REF_TO}) in ${outfile}`,
);
}
// Runs the dynamic-eval and .wasm neutralisation on every build and rebuild, for
// both dev and production, so the dev bundle matches what ships and the post-build
// step is exercised continuously rather than only at release time. Registered last
// so it transforms the finalised main.js.
const dynamicEvalPlugin = {
name: "neutralise-dynamic-eval",
setup(build) {
build.onEnd(() => {
neutraliseDynamicEval(build.initialOptions.outfile);
neutraliseWasmRef(build.initialOptions.outfile);
});
},
};
// Plugin to merge CSS files into styles.css and cleanup build artifacts
const cssMergerPlugin = {
name: "css-merger",
setup(build) {
build.onEnd(() => {
// esbuild outputs CSS as main.css (based on outfile: main.js)
const generatedCss = "main.css";
const customCssDir = "Styles";
const outputCss = "styles.css";
let mergedCss = "";
// Read generated CSS from dependencies if it exists
if (existsSync(generatedCss)) {
mergedCss += readFileSync(generatedCss, "utf-8");
mergedCss += "\n\n/* Custom Styles */\n\n";
}
// Append custom CSS files from styles directory
if (existsSync(customCssDir)) {
const cssFiles = readdirSync(customCssDir)
.filter(file => file.endsWith(".css"))
.sort();
for (const cssFile of cssFiles) {
const cssPath = join(customCssDir, cssFile);
mergedCss += readFileSync(cssPath, "utf-8");
mergedCss += "\n\n";
console.log(`📦 Merged: ${cssPath}`);
}
}
// Write merged CSS to styles.css
writeFileSync(outputCss, mergedCss);
console.log(`✅ Generated: ${outputCss}`);
// Clean up build artifacts if enabled
if (CLEANUP_BUILD_ARTIFACTS) {
// Remove main.css (intermediate CSS file)
if (existsSync(generatedCss)) {
unlinkSync(generatedCss);
console.log(`🗑️ Removed: ${generatedCss}`);
}
// Remove KaTeX font files
let anyRemoved = false;
const fontExtensions = [".woff", ".woff2", ".ttf"];
const files = readdirSync(".");
for (const file of files) {
const ext = file.substring(file.lastIndexOf("."));
if (fontExtensions.includes(ext)) {
unlinkSync(file);
anyRemoved = true;
}
}
if (anyRemoved) {
console.log("🗑️ Removed KaTeX Font Files");
}
}
});
}
};
// officeparser's browser bundle is an IIFE (var officeParser = (()=> { ... })()) that doesn't
// set module.exports, so esbuild can't resolve its exports. This plugin intercepts the resolve
// and appends a CJS export line so imports work correctly.
//
// The browser bundle contains a dynamic-require shim (from its own esbuild bundling) that our
// esbuild would otherwise resolve against the `external` list, emitting require("fs") etc. in
// the final output. On Obsidian mobile there is no "fs" module, so we replace that shim with a
// stub that always throws, keeping the bundle self-contained.
//
// The shim's variable name is minified and churns between releases (Au in v5, Vm in v6, Gs in
// v7.1.0), so the regex captures whatever identifier is used rather than hardcoding it. If the
// shim shape changes such that nothing matches, the build FAILS loudly — a silent no-op here
// previously let the stale v6 `Vm` regex match nothing on v7.
const officeParserPlugin = {
name: "officeparser-cjs-shim",
setup(build) {
build.onResolve({ filter: /^officeparser$/ }, () => ({
path: join(process.cwd(), 'node_modules', 'officeparser', 'dist', 'officeparser.browser.iife.js'),
namespace: 'officeparser-shim',
}));
build.onLoad({ filter: /.*/, namespace: 'officeparser-shim' }, async (args) => {
let contents = readFileSync(args.path, 'utf-8');
// Capture the (minified) shim identifier in group 1 and reuse it in the replacement.
const shimPattern =
/var ([A-Za-z_$][\w$]*)=\(r=>typeof require<"u"\?require:typeof Proxy<"u"\?new Proxy\(r,\{get:\(t,e\)=>\(typeof require<"u"\?require:t\)\[e\]\}\):r\)\(function\(r\)\{if\(typeof require<"u"\)return require\.apply\(this,arguments\);throw Error\('Dynamic require of "'\+r\+'" is not supported'\)\}\)/;
if (!shimPattern.test(contents)) {
throw new Error(
"officeparser-cjs-shim: dynamic-require shim not found in officeparser browser bundle. " +
"officeparser's bundling likely changed shape — re-audit the IIFE and update shimPattern " +
"before shipping, or require(\"fs\") may leak into the mobile bundle.",
);
}
contents = contents.replace(
shimPattern,
'var $1=(function(r){throw Error(\'Dynamic require of "\'+r+\'" is not supported\')})',
);
return {
contents: contents + '\nmodule.exports = officeParser;\n',
loader: 'js',
};
});
},
};
const buildOptions = {
plugins: [
officeParserPlugin,
esbuildSvelte({
compilerOptions: { css: "injected" },
preprocess: sveltePreprocess(),
}),
cssMergerPlugin,
dynamicEvalPlugin,
],
banner: {
js: banner,
},
entryPoints: ["main.ts"],
bundle: true,
define: {
"process.env.NODE_ENV": JSON.stringify(prod ? "production" : "development"),
},
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2022",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
loader: {
".css": "css",
".ttf": "file",
".woff": "file",
".woff2": "file",
},
};
if (prod) {
await esbuild.build(buildOptions);
console.log("✅ Production build complete!");
} else {
const ctx = await esbuild.context(buildOptions);
await ctx.watch();
// Watch Styles directory for CSS changes
if (existsSync("Styles")) {
fsWatch("Styles", { recursive: true }, (_eventType, filename) => {
if (filename && filename.endsWith(".css")) {
console.log(`🔄 CSS file changed: ${filename} - Rebuilding...`);
ctx.rebuild();
}
});
}
}