feat: content-hash cache busting for module/shell assets

Build generates SHA-256 content hashes per asset file and appends
?v=<hash> to all module/shell URLs in rendered HTML automatically.
Eliminates manual ?v=N bumping in mod.ts files. Versioned assets
get Cache-Control: immutable headers, and the service worker cleans
stale entries on activation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-05 11:50:57 -08:00
parent feabf89137
commit aff16e647a
10 changed files with 104 additions and 50 deletions

View File

@ -714,8 +714,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js?v=4"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css?v=2">
scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`,
}));
});

View File

@ -141,7 +141,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=3"></script>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
}));
});
@ -158,7 +158,7 @@ routes.get("/:room", (c) => {
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=3"></script>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js"></script>`,
}));
});

View File

@ -305,7 +305,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js?v=4"></script>`,
scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
}));
});

View File

@ -764,8 +764,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-schedule-app space="${space}"></folk-schedule-app>`,
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-app.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rschedule/schedule.css?v=1">`,
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-app.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rschedule/schedule.css">`,
}),
);
});

View File

@ -237,8 +237,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-swag-designer space="${space}"></folk-swag-designer>`,
scripts: `<script type="module" src="/modules/rswag/folk-swag-designer.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rswag/swag.css?v=2">`,
scripts: `<script type="module" src="/modules/rswag/folk-swag-designer.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rswag/swag.css">`,
}));
});

View File

@ -400,7 +400,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-work-board space="${space}"></folk-work-board>`,
scripts: `<script type="module" src="/modules/rwork/folk-work-board.js?v=3"></script>`,
scripts: `<script type="module" src="/modules/rwork/folk-work-board.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rwork/work.css">`,
}));
});

View File

@ -7,7 +7,7 @@
*/
import type { ModuleInfo } from "../shared/module";
import { escapeHtml, escapeAttr, MODULE_LANDING_CSS, RICH_LANDING_CSS } from "./shell";
import { escapeHtml, escapeAttr, MODULE_LANDING_CSS, RICH_LANDING_CSS, versionAssetUrls } from "./shell";
export function renderMainLanding(modules: ModuleInfo[]): string {
const moduleListJSON = JSON.stringify(modules);
@ -25,7 +25,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
)
.join("\n");
return `<!DOCTYPE html>
return versionAssetUrls(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
@ -34,8 +34,8 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
<title>rSpace Community Platform</title>
<meta name="description" content="A collaborative, local-first community platform with 22+ interoperable tools. Design global, manufacture local.">
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=7">
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
<style>${MODULE_LANDING_CSS}</style>
<style>${RICH_LANDING_CSS}</style>
<style>${MAIN_LANDING_CSS}</style>
@ -220,7 +220,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
</footer>
<script type="module">
import '/shell.js?v=7';
import '/shell.js';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
// Logged-in users: hide header demo btn, swap hero CTA to "Go to My Space"
@ -242,7 +242,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
} catch(e) {}
</script>
</body>
</html>`;
</html>`);
}
// ── Space Dashboard ──
@ -267,7 +267,7 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri
})
.join("\n");
return `<!DOCTYPE html>
return versionAssetUrls(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
@ -275,8 +275,8 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
<title>${escapeHtml(displayName)} | rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=7">
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
<style>${MODULE_LANDING_CSS}</style>
<style>${SPACE_DASHBOARD_CSS}</style>
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
@ -313,7 +313,7 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri
</div>` : ""}
<script type="module">
import '/shell.js?v=7';
import '/shell.js';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
// Fix up dashboard links to be subdomain-aware
@ -324,7 +324,7 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri
}
</script>
</body>
</html>`;
</html>`);
}
const SPACE_DASHBOARD_CSS = `

View File

@ -5,9 +5,28 @@
* switchers + identity, <main> with module content, shell script + styles.
*/
import { resolve } from "node:path";
import type { ModuleInfo, SubPageInfo } from "../shared/module";
import { getDocumentData } from "./community-store";
// ── Content-hash cache busting ──
let moduleHashes: Record<string, string> = {};
try {
const hashFile = resolve(import.meta.dir, "../dist/module-hashes.json");
moduleHashes = JSON.parse(await Bun.file(hashFile).text());
} catch { /* dev mode or first run — no hashes yet */ }
/** Append ?v=<content-hash> to module/shell asset URLs in rendered HTML. */
export function versionAssetUrls(html: string): string {
return html.replace(
/(["'])(\/(?:modules\/[^"'?]+\.(?:js|css)|shell\.js|shell\.css|theme\.css))(?:\?v=[^"']*)?(\1)/g,
(_, q, path, qEnd) => {
const hash = moduleHashes[path];
return hash ? `${q}${path}?v=${hash}${qEnd}` : `${q}${path}${qEnd}`;
}
);
}
/** Extract enabledModules and encryption status from a loaded space. */
export function getSpaceShellMeta(spaceSlug: string): { enabledModules: string[] | null; spaceEncrypted: boolean } {
const data = getDocumentData(spaceSlug);
@ -75,7 +94,7 @@ export function renderShell(opts: ShellOptions): string {
const moduleListJSON = JSON.stringify(visibleModules);
const shellDemoUrl = `https://demo.rspace.online/${escapeAttr(moduleId)}`;
return `<!DOCTYPE html>
return versionAssetUrls(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
@ -83,8 +102,8 @@ export function renderShell(opts: ShellOptions): string {
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
<title>${escapeHtml(title)}</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=8">
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
<style>
/* When loaded inside an iframe (e.g. standalone domain redirecting back),
hide the shell chrome the parent rSpace page already provides it. */
@ -128,7 +147,7 @@ export function renderShell(opts: ShellOptions): string {
${renderWelcomeOverlay()}
<script type="module">
import '/shell.js?v=8';
import '/shell.js';
// ── Settings panel toggle ──
document.getElementById('settings-btn')?.addEventListener('click', () => {
const panel = document.querySelector('rstack-space-settings');
@ -498,7 +517,7 @@ export function renderShell(opts: ShellOptions): string {
</script>
${scripts}
</body>
</html>`;
</html>`);
}
// ── External app iframe shell ──
@ -537,7 +556,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
const moduleListJSON = JSON.stringify(modules);
const demoUrl = `?view=demo`;
return `<!DOCTYPE html>
return versionAssetUrls(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
@ -545,8 +564,8 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
<title>${escapeHtml(title)}</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=8">
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
<style>
html.rspace-embedded .rstack-header { display: none !important; }
html.rspace-embedded .rstack-tab-row { display: none !important; }
@ -589,7 +608,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
</div>
<script type="module">
import '/shell.js?v=8';
import '/shell.js';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
const tabBar = document.querySelector('rstack-tab-bar');
@ -621,7 +640,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
}
</script>
</body>
</html>`;
</html>`);
}
// ── Welcome overlay (quarter-screen popup for first-time visitors on demo) ──
@ -791,7 +810,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
<a href="/"> Back to rSpace</a>
</div>`;
return `<!DOCTYPE html>
return versionAssetUrls(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
@ -799,8 +818,8 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>${mod.icon}</text></svg>">
<title>${escapeHtml(mod.name)} rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=8">
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
${cssBlock}
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
</head>
@ -821,7 +840,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
</header>
${bodyContent}
<script type="module">
import '/shell.js?v=8';
import '/shell.js';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
function _updateDemoBtn() {
var btn = document.querySelector('.rstack-header__demo-btn');
@ -850,7 +869,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
} catch(e) {}
</script>
</body>
</html>`;
</html>`);
}
export const MODULE_LANDING_CSS = `
@ -1124,7 +1143,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
<a href="/${escapeAttr(mod.id)}"> Back to ${escapeHtml(mod.name)}</a>
</div>`;
return `<!DOCTYPE html>
return versionAssetUrls(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
@ -1132,8 +1151,8 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>${subPage.icon}</text></svg>">
<title>${escapeHtml(subPage.title)} ${escapeHtml(mod.name)} | rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=8">
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
<style>${MODULE_LANDING_CSS}</style>
<style>${RICH_LANDING_CSS}</style>
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
@ -1155,7 +1174,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
</header>
${bodyContent}
<script type="module">
import '/shell.js?v=8';
import '/shell.js';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
function _updateDemoBtn() {
var btn = document.querySelector('.rstack-header__demo-btn');
@ -1184,7 +1203,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
} catch(e) {}
</script>
</body>
</html>`;
</html>`);
}
// ── Onboarding page (empty rApp state) ──

View File

@ -935,9 +935,9 @@ export default defineConfig({
}
}
// ── Generate precache manifest ──
// Scans dist/ for all cacheable assets and writes precache-manifest.json
const { readdirSync, writeFileSync, statSync: statSync2 } = await import("node:fs");
// ── Generate content hashes for cache-busting ──
const { readdirSync, readFileSync, writeFileSync, statSync: statSync2 } = await import("node:fs");
const { createHash } = await import("node:crypto");
const distDir = resolve(__dirname, "dist");
function walkDir(dir: string, prefix = ""): string[] {
@ -955,6 +955,23 @@ export default defineConfig({
const allFiles = walkDir(distDir);
// Compute 8-char SHA-256 content hashes for module + shell assets
const hashableFiles = allFiles.filter((f) =>
(f.startsWith("/modules/") && (f.endsWith(".js") || f.endsWith(".css")) && !f.includes("-demo.js")) ||
f === "/shell.js" || f === "/shell.css" || f === "/theme.css"
);
const hashes: Record<string, string> = {};
for (const file of hashableFiles) {
const content = readFileSync(resolve(distDir, file.slice(1)));
hashes[file] = createHash("sha256").update(content).digest("hex").slice(0, 8);
}
writeFileSync(
resolve(distDir, "module-hashes.json"),
JSON.stringify(hashes, null, "\t"),
);
console.log(`[hashes] Generated content hashes for ${Object.keys(hashes).length} assets`);
// ── Generate precache manifest ──
// Core: shell assets + HTML pages (precached at install, ~300KB)
const core = allFiles.filter((f) =>
f === "/" ||
@ -966,21 +983,22 @@ export default defineConfig({
f === "/shell.css" ||
f === "/theme.css" ||
f === "/favicon.png"
);
).map((f) => hashes[f] ? `${f}?v=${hashes[f]}` : f);
// Ensure root URL is present
if (!core.includes("/")) core.unshift("/");
if (!core.some((f) => f === "/" || f.startsWith("/?v="))) core.unshift("/");
// Modules: all module JS + CSS (lazy-cached after activation)
const modules = allFiles.filter((f) =>
f.startsWith("/modules/") &&
(f.endsWith(".js") || f.endsWith(".css")) &&
!f.includes("-demo.js") // skip demo scripts
);
!f.includes("-demo.js")
).map((f) => hashes[f] ? `${f}?v=${hashes[f]}` : f);
const manifest = {
version: new Date().toISOString(),
core,
modules,
hashes,
};
writeFileSync(

View File

@ -17,8 +17,9 @@ const API_CACHE_MAX_AGE_MS = 5 * 60 * 1000;
interface PrecacheManifest {
version: string;
core: string[]; // shell assets + HTML pages — cached at install
modules: string[]; // module JS/CSS — lazy-cached after activation
core: string[]; // shell assets + HTML pages — cached at install (with ?v=hash)
modules: string[]; // module JS/CSS — lazy-cached after activation (with ?v=hash)
hashes?: Record<string, string>; // content hashes for cache-busting
}
self.addEventListener("install", (event) => {
@ -74,6 +75,22 @@ self.addEventListener("activate", (event) => {
if (!existing) await cache.add(url);
} catch { /* skip individual failures */ }
}
// Clean stale versioned entries (old ?v=oldhash URLs)
const currentUrls = new Set([...manifest.core, ...manifest.modules]);
const cachedRequests = await cache.keys();
for (const req of cachedRequests) {
const u = new URL(req.url);
const isVersioned = u.searchParams.has("v") && (
u.pathname.startsWith("/modules/") ||
u.pathname === "/shell.js" ||
u.pathname === "/shell.css" ||
u.pathname === "/theme.css"
);
if (isVersioned && !currentUrls.has(u.pathname + u.search)) {
await cache.delete(req);
}
}
}
}
} catch { /* manifest unavailable */ }