Cache-Control: no-cache on /assets/* (step 3) only applies to NEW
responses — cached entries from before the deploy are still served
without revalidation under heuristic freshness, which is exactly the
window that kept users stuck on the broken bundle.
The robust fix is to change the cache key on every deploy:
- frontend/build.ts now post-processes every dist/*.html and appends
`?v=<buildVersion>` to every /assets/*.js and /assets/*.css URL.
Same buildVersion the SW already uses, so the SW cache, the asset
URL, and the HTML reference all rotate together.
- internal/handlers/handlers.go wraps the protected mux (and the
public /login, /logout, /{$} pages) in a noCachePages middleware.
HTML pages now revalidate on every navigation; combined with the
versioned asset URLs, a deploy reaches users on their next request:
new HTML → new ?v= → fresh script load, every time.
178 lines
8.6 KiB
TypeScript
178 lines
8.6 KiB
TypeScript
import { mkdir, cp, rm, readdir } from "fs/promises";
|
|
import { join } from "path";
|
|
import { renderIndex } from "./src/index";
|
|
import { renderLogin } from "./src/login";
|
|
import { renderKostenrechner } from "./src/kostenrechner";
|
|
import { renderFristenrechner } from "./src/fristenrechner";
|
|
import { renderDownloads } from "./src/downloads";
|
|
import { renderLinks } from "./src/links";
|
|
import { renderGlossary } from "./src/glossary";
|
|
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
|
|
import { renderChecklists } from "./src/checklists";
|
|
import { renderChecklistsDetail } from "./src/checklists-detail";
|
|
import { renderChecklistsInstance } from "./src/checklists-instance";
|
|
import { renderCourts } from "./src/courts";
|
|
import { renderProjects } from "./src/projects";
|
|
import { renderProjectsNew } from "./src/projects-new";
|
|
import { renderProjectsDetail } from "./src/projects-detail";
|
|
import { renderDeadlines } from "./src/deadlines";
|
|
import { renderDeadlinesNew } from "./src/deadlines-new";
|
|
import { renderDeadlinesDetail } from "./src/deadlines-detail";
|
|
import { renderDeadlinesCalendar } from "./src/deadlines-calendar";
|
|
import { renderAppointments } from "./src/appointments";
|
|
import { renderAppointmentsNew } from "./src/appointments-new";
|
|
import { renderAppointmentsDetail } from "./src/appointments-detail";
|
|
import { renderAppointmentsCalendar } from "./src/appointments-calendar";
|
|
import { renderSettings } from "./src/settings";
|
|
import { renderDashboard } from "./src/dashboard";
|
|
import { renderAgenda } from "./src/agenda";
|
|
import { renderOnboarding } from "./src/onboarding";
|
|
import { renderChangelog } from "./src/changelog";
|
|
import { renderTeam } from "./src/team";
|
|
import { renderNotFound } from "./src/notfound";
|
|
|
|
const DIST = join(import.meta.dir, "dist");
|
|
|
|
async function build() {
|
|
// Clean dist/
|
|
await rm(DIST, { recursive: true, force: true });
|
|
await mkdir(join(DIST, "assets"), { recursive: true });
|
|
|
|
// Bundle client-side JS
|
|
const result = await Bun.build({
|
|
entrypoints: [
|
|
// app.ts is loaded on every page (SW registration + bottom-nav init +
|
|
// install prompt). Keep it ahead of per-page bundles so name collisions
|
|
// surface fast.
|
|
join(import.meta.dir, "src/client/app.ts"),
|
|
join(import.meta.dir, "src/client/index.ts"),
|
|
join(import.meta.dir, "src/client/login.ts"),
|
|
join(import.meta.dir, "src/client/kostenrechner.ts"),
|
|
join(import.meta.dir, "src/client/fristenrechner.ts"),
|
|
join(import.meta.dir, "src/client/downloads.ts"),
|
|
join(import.meta.dir, "src/client/links.ts"),
|
|
join(import.meta.dir, "src/client/glossary.ts"),
|
|
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
|
|
join(import.meta.dir, "src/client/checklists.ts"),
|
|
join(import.meta.dir, "src/client/checklists-detail.ts"),
|
|
join(import.meta.dir, "src/client/checklists-instance.ts"),
|
|
join(import.meta.dir, "src/client/courts.ts"),
|
|
join(import.meta.dir, "src/client/projects.ts"),
|
|
join(import.meta.dir, "src/client/projects-new.ts"),
|
|
join(import.meta.dir, "src/client/projects-detail.ts"),
|
|
join(import.meta.dir, "src/client/deadlines.ts"),
|
|
join(import.meta.dir, "src/client/deadlines-new.ts"),
|
|
join(import.meta.dir, "src/client/deadlines-detail.ts"),
|
|
join(import.meta.dir, "src/client/deadlines-calendar.ts"),
|
|
join(import.meta.dir, "src/client/appointments.ts"),
|
|
join(import.meta.dir, "src/client/appointments-new.ts"),
|
|
join(import.meta.dir, "src/client/appointments-detail.ts"),
|
|
join(import.meta.dir, "src/client/appointments-calendar.ts"),
|
|
join(import.meta.dir, "src/client/settings.ts"),
|
|
join(import.meta.dir, "src/client/dashboard.ts"),
|
|
join(import.meta.dir, "src/client/agenda.ts"),
|
|
join(import.meta.dir, "src/client/onboarding.ts"),
|
|
join(import.meta.dir, "src/client/changelog.ts"),
|
|
join(import.meta.dir, "src/client/team.ts"),
|
|
join(import.meta.dir, "src/client/notfound.ts"),
|
|
],
|
|
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",
|
|
});
|
|
|
|
if (!result.success) {
|
|
console.error("JS build failed:");
|
|
for (const log of result.logs) {
|
|
console.error(log);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
// Copy CSS
|
|
await cp(
|
|
join(import.meta.dir, "src/styles/global.css"),
|
|
join(DIST, "assets/global.css"),
|
|
);
|
|
|
|
// Copy public/ → dist/ (manifest.json, sw.js, icons/) — served at the
|
|
// application root so the service worker can claim scope=/ and so the
|
|
// manifest is reachable at /manifest.json without a sub-path rewrite.
|
|
await cp(
|
|
join(import.meta.dir, "public"),
|
|
DIST,
|
|
{ recursive: true },
|
|
);
|
|
|
|
// Stamp a unique version into sw.js so each deploy opens a fresh cache.
|
|
// Activate-time eviction (in sw.js) deletes any prior cache, including
|
|
// pre-versioning names like paliad-v1-static — that's what stops a stale
|
|
// /assets/projects.js from a previous deploy lingering on a user's device.
|
|
const swPath = join(DIST, "sw.js");
|
|
const swSrc = await Bun.file(swPath).text();
|
|
const buildVersion = `v${Date.now()}`;
|
|
await Bun.write(swPath, swSrc.replace("__PALIAD_BUILD_VERSION__", buildVersion));
|
|
|
|
// Render HTML pages
|
|
await Bun.write(join(DIST, "index.html"), renderIndex());
|
|
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
|
|
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
|
|
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
|
|
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
|
|
await Bun.write(join(DIST, "links.html"), renderLinks());
|
|
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
|
|
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
|
|
await Bun.write(join(DIST, "checklists.html"), renderChecklists());
|
|
await Bun.write(join(DIST, "checklists-detail.html"), renderChecklistsDetail());
|
|
await Bun.write(join(DIST, "checklists-instance.html"), renderChecklistsInstance());
|
|
await Bun.write(join(DIST, "courts.html"), renderCourts());
|
|
await Bun.write(join(DIST, "projects.html"), renderProjects());
|
|
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
|
|
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
|
await Bun.write(join(DIST, "deadlines.html"), renderDeadlines());
|
|
await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew());
|
|
await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail());
|
|
await Bun.write(join(DIST, "deadlines-calendar.html"), renderDeadlinesCalendar());
|
|
await Bun.write(join(DIST, "appointments.html"), renderAppointments());
|
|
await Bun.write(join(DIST, "appointments-new.html"), renderAppointmentsNew());
|
|
await Bun.write(join(DIST, "appointments-detail.html"), renderAppointmentsDetail());
|
|
await Bun.write(join(DIST, "appointments-calendar.html"), renderAppointmentsCalendar());
|
|
await Bun.write(join(DIST, "settings.html"), renderSettings());
|
|
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
|
|
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
|
|
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
|
|
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
|
|
await Bun.write(join(DIST, "team.html"), renderTeam());
|
|
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
|
|
|
|
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in
|
|
// every emitted HTML file. Cache-Control alone isn't enough: a browser that
|
|
// cached a script in a previous deploy keeps serving it from disk because
|
|
// the cache entry was stored without the no-cache directive. Versioning the
|
|
// URL changes the cache key, so the next page load fetches a fresh bundle
|
|
// unconditionally \u2014 this is what guarantees t-paliad-043's IIFE wrap fix
|
|
// actually reaches users on their next visit even without a SW.
|
|
const htmlFiles = (await readdir(DIST)).filter((f) => f.endsWith(".html"));
|
|
for (const f of htmlFiles) {
|
|
const path = join(DIST, f);
|
|
const html = await Bun.file(path).text();
|
|
const stamped = html.replace(
|
|
/(\/assets\/[\w-]+\.(?:js|css))/g,
|
|
`$1?v=${buildVersion}`,
|
|
);
|
|
await Bun.write(path, stamped);
|
|
}
|
|
|
|
console.log("Build complete \u2192 dist/");
|
|
}
|
|
|
|
build();
|