const fs = require("fs"); const path = require("path"); const { execSync } = require("child_process"); const esbuild = require("esbuild"); const DIST = path.join(__dirname, "dist"); const DIST_CHROME = path.join(DIST, "chrome"); const DIST_FIREFOX = path.join(DIST, "firefox"); const SRC = path.join(__dirname, "src"); // The module whose compiled DEBUG state script/verify-build asserts, and the // manifest naming every emitted bundle that ends up containing it. The // manifest is derived from esbuild's own dependency graph rather than from a // hardcoded list, so it tracks the bundle layout instead of rotting with it. const AUDITED_MODULE = "src/shared/constants.js"; const BUNDLE_MANIFEST = path.join(DIST, "constants-bundles.txt"); function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } // Repo-relative, forward-slashed, so the manifest reads the same on every // platform and can be consumed by a POSIX shell script without further work. function repoRelative(p) { return path.relative(__dirname, p).split(path.sep).join("/"); } // Collect the outputs of one esbuild run that bundle AUDITED_MODULE. esbuild // reports every input that contributed to an output in the metafile, which is // the authoritative answer to "is constants.js in this bundle" — unlike // searching the minified text, it does not depend on what survived minification. // // The ".js" filter below is the only place that assumption lives: // script/verify-build searches every file and symlink under dist/ for a // marker, without filtering by extension, and hard-fails if it cannot walk the // whole tree, so a bundle emitted under some other extension fails there as // unlisted rather than escaping both checks at once. function outputsContainingAuditedModule(metafile) { return Object.entries(metafile.outputs) .filter(([outFile, info]) => { if (!outFile.endsWith(".js")) return false; return Object.keys(info.inputs).some( (input) => repoRelative(input) === AUDITED_MODULE, ); }) .map(([outFile]) => repoRelative(outFile)); } // DEBUG is a build-time flag, off unless explicitly requested. It is the only // thing that makes the hardcoded test mnemonic reachable, so the opt-in must be // exact: anything other than the literal "1" (unset, empty, "true", a typo) // produces a release build. Failing towards the safe mode is deliberate. function isDebugBuild() { return process.env.AUTISTMASK_DEBUG === "1"; } function getBuildInfo() { const pkg = JSON.parse( fs.readFileSync(path.join(__dirname, "package.json"), "utf8"), ); let commitHash = "unknown"; try { commitHash = execSync("git rev-parse --short HEAD", { encoding: "utf8", }).trim(); } catch { // not a git repo or git not available } let commitHashFull = "unknown"; try { commitHashFull = execSync("git rev-parse HEAD", { encoding: "utf8", }).trim(); } catch { // not a git repo or git not available } return { version: pkg.version, license: pkg.license, author: pkg.author, commitHash, commitHashFull, buildDate: new Date().toISOString().slice(0, 10), }; } async function build() { console.log("Building AutistMask extension..."); const buildInfo = getBuildInfo(); console.log("Build info:", buildInfo); const debugBuild = isDebugBuild(); console.log( debugBuild ? "Build mode: DEBUG (INSECURE - hardcoded test mnemonic, do not ship)" : "Build mode: release (DEBUG off)", ); const define = { __BUILD_DEBUG__: JSON.stringify(debugBuild), __BUILD_VERSION__: JSON.stringify(buildInfo.version), __BUILD_LICENSE__: JSON.stringify(buildInfo.license), __BUILD_AUTHOR__: JSON.stringify(buildInfo.author), __BUILD_COMMIT__: JSON.stringify(buildInfo.commitHash), __BUILD_COMMIT_FULL__: JSON.stringify(buildInfo.commitHashFull), __BUILD_DATE__: JSON.stringify(buildInfo.buildDate), }; // Emitted bundles that contain constants.js, accumulated across every // esbuild run below and written out for script/verify-build. const auditedBundles = []; // compile tailwind CSS console.log("Compiling Tailwind CSS..."); const tailwindInput = path.join(SRC, "popup", "styles", "main.css"); const tailwindOutput = path.join(DIST, "styles.css"); ensureDir(DIST); // Drop any manifest from a previous build before emitting anything, so a // build that never gets around to writing one cannot be verified against // a stale list. fs.rmSync(BUNDLE_MANIFEST, { force: true }); // The locally installed binary, not `npx` — npx silently fetches from the // registry when the binary is absent, which is an unpinned network fetch // in the middle of a build. const tailwindBin = path.join( __dirname, "node_modules", ".bin", "tailwindcss", ); execSync( `"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`, { stdio: "inherit" }, ); // Every bundle goes through here, so metafile collection cannot be // forgotten when a new entry point is added. async function bundle(entryPoint, outfile) { const result = await esbuild.build({ entryPoints: [entryPoint], bundle: true, format: "iife", outfile, platform: "browser", target: ["chrome110", "firefox110"], minify: true, metafile: true, define, }); auditedBundles.push(...outputsContainingAuditedModule(result.metafile)); } for (const distDir of [DIST_CHROME, DIST_FIREFOX]) { ensureDir(path.join(distDir, "src", "popup")); ensureDir(path.join(distDir, "src", "background")); ensureDir(path.join(distDir, "src", "content")); // bundle popup JS with esbuild (inlines ethers, libsodium, etc.) await bundle( path.join(SRC, "popup", "index.js"), path.join(distDir, "src", "popup", "index.js"), ); // bundle background script await bundle( path.join(SRC, "background", "index.js"), path.join(distDir, "src", "background", "index.js"), ); // bundle content script await bundle( path.join(SRC, "content", "index.js"), path.join(distDir, "src", "content", "index.js"), ); // bundle inpage script (injected into page context, separate file) await bundle( path.join(SRC, "content", "inpage.js"), path.join(distDir, "src", "content", "inpage.js"), ); // copy popup HTML fs.copyFileSync( path.join(SRC, "popup", "index.html"), path.join(distDir, "src", "popup", "index.html"), ); // place compiled CSS next to popup HTML fs.copyFileSync( tailwindOutput, path.join(distDir, "src", "popup", "styles.css"), ); } // copy manifests fs.copyFileSync( path.join(__dirname, "manifest", "chrome.json"), path.join(DIST_CHROME, "manifest.json"), ); fs.copyFileSync( path.join(__dirname, "manifest", "firefox.json"), path.join(DIST_FIREFOX, "manifest.json"), ); // Written last so a build that died partway through leaves no manifest // at all, which script/verify-build treats as a hard failure rather than // as "nothing to check". const manifest = [...new Set(auditedBundles)].sort(); fs.writeFileSync(BUNDLE_MANIFEST, manifest.map((p) => `${p}\n`).join("")); console.log( `Bundles containing ${AUDITED_MODULE}: ${manifest.length} ` + `(listed in ${repoRelative(BUNDLE_MANIFEST)})`, ); console.log("Build complete: dist/chrome/ and dist/firefox/"); } build();