diff --git a/frontend/build.ts b/frontend/build.ts index 973580b..be3ec53 100644 --- a/frontend/build.ts +++ b/frontend/build.ts @@ -35,6 +35,26 @@ import { renderNotFound } from "./src/notfound"; const DIST = join(import.meta.dir, "dist"); +// Bundle-scope isolation guard (t-paliad-043). +// +// All client bundles MUST be built with format: "iife" so each bundle's +// top-level `var`/`function` declarations are wrapped in their own scope. +// Without IIFE wrapping, minified identifiers leak to `window` and clobber +// each other across bundles. On Apr 26, app.js's `var d = "patholo-sidebar-pinned"` +// overwrote projects.js's `function d()` (applyTranslations), and the entire +// authenticated surface crashed in initI18n with "TypeError: d is not a function". +// +// The constant below is the single source of truth for the bundle format; +// the post-build inspection further down verifies that every emitted asset +// actually starts with an IIFE prologue, so this guard survives future Bun +// versions, refactors that drop the constant, or anyone trying to silence +// the type system with `as "esm"`. +const BUILD_FORMAT = "iife" as const; + +// Bun emits IIFE bundles as either `(()=>{...})()` (arrow form, what we get +// today with minify: true) or `(function(){...})()`. Match either prologue. +const IIFE_PROLOGUE = /^(\(\(\)\s*=>\s*\{|\(function\s*\(\s*\)\s*\{)/; + async function build() { // Clean dist/ await rm(DIST, { recursive: true, force: true }); @@ -83,14 +103,10 @@ async function build() { outdir: join(DIST, "assets"), naming: "[name].js", minify: true, - // IIFE wraps each bundle's top-level scope so per-page modules don't - // leak globals into each other. Without this, app.js's minified - // `var d = "patholo-sidebar-pinned"` (the legacy sidebar storage key) - // clobbered projects.js's minified `function d()` (applyTranslations) - // on the global object — projects.js then crashed with - // "TypeError: d is not a function" inside its DOMContentLoaded init, - // making /projects appear empty (t-paliad-043). - format: "iife", + // See BUILD_FORMAT comment at top of file — bundle-scope isolation + // depends on IIFE wrapping. Reuses the single-source-of-truth constant + // so the post-build guard below can detect a format swap. + format: BUILD_FORMAT, }); if (!result.success) { @@ -101,6 +117,28 @@ async function build() { process.exit(1); } + // Bundle-scope isolation guard (t-paliad-043) — verify every emitted JS + // bundle starts with an IIFE prologue. This catches the case where + // BUILD_FORMAT is changed to "esm", `format` is dropped from the Bun.build + // call, or a future Bun version emits a non-IIFE wrapper despite the + // option. Without this, top-level identifier collisions between bundles + // can take down the whole authenticated surface (see comment at top). + const emittedAssets = await readdir(join(DIST, "assets")); + for (const f of emittedAssets) { + if (!f.endsWith(".js")) continue; + const head = (await Bun.file(join(DIST, "assets", f)).text()).slice(0, 64); + if (!IIFE_PROLOGUE.test(head)) { + console.error( + `Build aborted: dist/assets/${f} is not IIFE-wrapped ` + + `(starts with ${JSON.stringify(head.slice(0, 32))}). ` + + `All client bundles must be built with Bun.build({ format: "iife" }) — ` + + `per-page bundles' top-level identifiers leak to window and clobber ` + + `each other after minification (see t-paliad-043).`, + ); + process.exit(1); + } + } + // Copy CSS await cp( join(import.meta.dir, "src/styles/global.css"),