andy-stack_vaultkeeper-ai/esbuild.config.mjs
Andrew Beal 72bf43a8ea refactor: clarify binary file attachment handling for AI agents
Add explicit instructions across all agent prompts explaining that binary
files (PDFs, images, documents) return content as attachments in the
message following the tool result, not as text in the result itself.

Update tool response messages to clearly state the attachment delivery
mechanism and prevent agents from re-reading the same file expecting
different output.

Improve build safety by neutralizing dynamic eval constructs and
hardening the officeparser plugin against bundle shape changes.
2026-05-31 17:58:23 +01:00

275 lines
No EOL
10 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}`,
);
}
// Runs the dynamic-eval 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);
});
},
};
// 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();
}
});
}
}