Merge: build-time IIFE guard (t-paliad-053)

This commit is contained in:
m
2026-04-27 18:33:41 +02:00

View File

@@ -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"),