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(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`, body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js?v=4"></script>`, scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css?v=2"> styles: `<link rel="stylesheet" href="/modules/rcal/cal.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`, <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(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`, 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">`, styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
})); }));
}); });
@ -158,7 +158,7 @@ routes.get("/:room", (c) => {
theme: "dark", theme: "dark",
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`, styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`, 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(), 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> 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>`, <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">`, styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
})); }));
}); });

View File

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

View File

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

View File

@ -400,7 +400,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-work-board space="${space}"></folk-work-board>`, 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">`, styles: `<link rel="stylesheet" href="/modules/rwork/work.css">`,
})); }));
}); });

View File

@ -7,7 +7,7 @@
*/ */
import type { ModuleInfo } from "../shared/module"; 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 { export function renderMainLanding(modules: ModuleInfo[]): string {
const moduleListJSON = JSON.stringify(modules); const moduleListJSON = JSON.stringify(modules);
@ -25,7 +25,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
) )
.join("\n"); .join("\n");
return `<!DOCTYPE html> return versionAssetUrls(`<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@ -34,8 +34,8 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
<title>rSpace Community Platform</title> <title>rSpace Community Platform</title>
<meta name="description" content="A collaborative, local-first community platform with 22+ interoperable tools. Design global, manufacture local."> <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> <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="/theme.css">
<link rel="stylesheet" href="/shell.css?v=7"> <link rel="stylesheet" href="/shell.css">
<style>${MODULE_LANDING_CSS}</style> <style>${MODULE_LANDING_CSS}</style>
<style>${RICH_LANDING_CSS}</style> <style>${RICH_LANDING_CSS}</style>
<style>${MAIN_LANDING_CSS}</style> <style>${MAIN_LANDING_CSS}</style>
@ -220,7 +220,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
</footer> </footer>
<script type="module"> <script type="module">
import '/shell.js?v=7'; import '/shell.js';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON}); document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
// Logged-in users: hide header demo btn, swap hero CTA to "Go to My Space" // 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) {} } catch(e) {}
</script> </script>
</body> </body>
</html>`; </html>`);
} }
// ── Space Dashboard ── // ── Space Dashboard ──
@ -267,7 +267,7 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri
}) })
.join("\n"); .join("\n");
return `<!DOCTYPE html> return versionAssetUrls(`<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <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>"> <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> <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> <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="/theme.css">
<link rel="stylesheet" href="/shell.css?v=7"> <link rel="stylesheet" href="/shell.css">
<style>${MODULE_LANDING_CSS}</style> <style>${MODULE_LANDING_CSS}</style>
<style>${SPACE_DASHBOARD_CSS}</style> <style>${SPACE_DASHBOARD_CSS}</style>
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script> <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>` : ""} </div>` : ""}
<script type="module"> <script type="module">
import '/shell.js?v=7'; import '/shell.js';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON}); document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
// Fix up dashboard links to be subdomain-aware // Fix up dashboard links to be subdomain-aware
@ -324,7 +324,7 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri
} }
</script> </script>
</body> </body>
</html>`; </html>`);
} }
const SPACE_DASHBOARD_CSS = ` const SPACE_DASHBOARD_CSS = `

View File

@ -5,9 +5,28 @@
* switchers + identity, <main> with module content, shell script + styles. * switchers + identity, <main> with module content, shell script + styles.
*/ */
import { resolve } from "node:path";
import type { ModuleInfo, SubPageInfo } from "../shared/module"; import type { ModuleInfo, SubPageInfo } from "../shared/module";
import { getDocumentData } from "./community-store"; 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. */ /** Extract enabledModules and encryption status from a loaded space. */
export function getSpaceShellMeta(spaceSlug: string): { enabledModules: string[] | null; spaceEncrypted: boolean } { export function getSpaceShellMeta(spaceSlug: string): { enabledModules: string[] | null; spaceEncrypted: boolean } {
const data = getDocumentData(spaceSlug); const data = getDocumentData(spaceSlug);
@ -75,7 +94,7 @@ export function renderShell(opts: ShellOptions): string {
const moduleListJSON = JSON.stringify(visibleModules); const moduleListJSON = JSON.stringify(visibleModules);
const shellDemoUrl = `https://demo.rspace.online/${escapeAttr(moduleId)}`; const shellDemoUrl = `https://demo.rspace.online/${escapeAttr(moduleId)}`;
return `<!DOCTYPE html> return versionAssetUrls(`<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <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>"> <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> <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> <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="/theme.css">
<link rel="stylesheet" href="/shell.css?v=8"> <link rel="stylesheet" href="/shell.css">
<style> <style>
/* When loaded inside an iframe (e.g. standalone domain redirecting back), /* When loaded inside an iframe (e.g. standalone domain redirecting back),
hide the shell chrome the parent rSpace page already provides it. */ hide the shell chrome the parent rSpace page already provides it. */
@ -128,7 +147,7 @@ export function renderShell(opts: ShellOptions): string {
${renderWelcomeOverlay()} ${renderWelcomeOverlay()}
<script type="module"> <script type="module">
import '/shell.js?v=8'; import '/shell.js';
// ── Settings panel toggle ── // ── Settings panel toggle ──
document.getElementById('settings-btn')?.addEventListener('click', () => { document.getElementById('settings-btn')?.addEventListener('click', () => {
const panel = document.querySelector('rstack-space-settings'); const panel = document.querySelector('rstack-space-settings');
@ -498,7 +517,7 @@ export function renderShell(opts: ShellOptions): string {
</script> </script>
${scripts} ${scripts}
</body> </body>
</html>`; </html>`);
} }
// ── External app iframe shell ── // ── External app iframe shell ──
@ -537,7 +556,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
const moduleListJSON = JSON.stringify(modules); const moduleListJSON = JSON.stringify(modules);
const demoUrl = `?view=demo`; const demoUrl = `?view=demo`;
return `<!DOCTYPE html> return versionAssetUrls(`<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <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>"> <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> <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> <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="/theme.css">
<link rel="stylesheet" href="/shell.css?v=8"> <link rel="stylesheet" href="/shell.css">
<style> <style>
html.rspace-embedded .rstack-header { display: none !important; } html.rspace-embedded .rstack-header { display: none !important; }
html.rspace-embedded .rstack-tab-row { display: none !important; } html.rspace-embedded .rstack-tab-row { display: none !important; }
@ -589,7 +608,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
</div> </div>
<script type="module"> <script type="module">
import '/shell.js?v=8'; import '/shell.js';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON}); document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
const tabBar = document.querySelector('rstack-tab-bar'); const tabBar = document.querySelector('rstack-tab-bar');
@ -621,7 +640,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
} }
</script> </script>
</body> </body>
</html>`; </html>`);
} }
// ── Welcome overlay (quarter-screen popup for first-time visitors on demo) ── // ── 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> <a href="/"> Back to rSpace</a>
</div>`; </div>`;
return `<!DOCTYPE html> return versionAssetUrls(`<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <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>"> <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> <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> <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="/theme.css">
<link rel="stylesheet" href="/shell.css?v=8"> <link rel="stylesheet" href="/shell.css">
${cssBlock} ${cssBlock}
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script> <script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
</head> </head>
@ -821,7 +840,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
</header> </header>
${bodyContent} ${bodyContent}
<script type="module"> <script type="module">
import '/shell.js?v=8'; import '/shell.js';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON}); document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
function _updateDemoBtn() { function _updateDemoBtn() {
var btn = document.querySelector('.rstack-header__demo-btn'); var btn = document.querySelector('.rstack-header__demo-btn');
@ -850,7 +869,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
} catch(e) {} } catch(e) {}
</script> </script>
</body> </body>
</html>`; </html>`);
} }
export const MODULE_LANDING_CSS = ` 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> <a href="/${escapeAttr(mod.id)}"> Back to ${escapeHtml(mod.name)}</a>
</div>`; </div>`;
return `<!DOCTYPE html> return versionAssetUrls(`<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <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>"> <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> <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> <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="/theme.css">
<link rel="stylesheet" href="/shell.css?v=8"> <link rel="stylesheet" href="/shell.css">
<style>${MODULE_LANDING_CSS}</style> <style>${MODULE_LANDING_CSS}</style>
<style>${RICH_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> <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> </header>
${bodyContent} ${bodyContent}
<script type="module"> <script type="module">
import '/shell.js?v=8'; import '/shell.js';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON}); document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
function _updateDemoBtn() { function _updateDemoBtn() {
var btn = document.querySelector('.rstack-header__demo-btn'); var btn = document.querySelector('.rstack-header__demo-btn');
@ -1184,7 +1203,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
} catch(e) {} } catch(e) {}
</script> </script>
</body> </body>
</html>`; </html>`);
} }
// ── Onboarding page (empty rApp state) ── // ── Onboarding page (empty rApp state) ──

View File

@ -935,9 +935,9 @@ export default defineConfig({
} }
} }
// ── Generate precache manifest ── // ── Generate content hashes for cache-busting ──
// Scans dist/ for all cacheable assets and writes precache-manifest.json const { readdirSync, readFileSync, writeFileSync, statSync: statSync2 } = await import("node:fs");
const { readdirSync, writeFileSync, statSync: statSync2 } = await import("node:fs"); const { createHash } = await import("node:crypto");
const distDir = resolve(__dirname, "dist"); const distDir = resolve(__dirname, "dist");
function walkDir(dir: string, prefix = ""): string[] { function walkDir(dir: string, prefix = ""): string[] {
@ -955,6 +955,23 @@ export default defineConfig({
const allFiles = walkDir(distDir); 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) // Core: shell assets + HTML pages (precached at install, ~300KB)
const core = allFiles.filter((f) => const core = allFiles.filter((f) =>
f === "/" || f === "/" ||
@ -966,21 +983,22 @@ export default defineConfig({
f === "/shell.css" || f === "/shell.css" ||
f === "/theme.css" || f === "/theme.css" ||
f === "/favicon.png" f === "/favicon.png"
); ).map((f) => hashes[f] ? `${f}?v=${hashes[f]}` : f);
// Ensure root URL is present // 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) // Modules: all module JS + CSS (lazy-cached after activation)
const modules = allFiles.filter((f) => const modules = allFiles.filter((f) =>
f.startsWith("/modules/") && f.startsWith("/modules/") &&
(f.endsWith(".js") || f.endsWith(".css")) && (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 = { const manifest = {
version: new Date().toISOString(), version: new Date().toISOString(),
core, core,
modules, modules,
hashes,
}; };
writeFileSync( writeFileSync(

View File

@ -17,8 +17,9 @@ const API_CACHE_MAX_AGE_MS = 5 * 60 * 1000;
interface PrecacheManifest { interface PrecacheManifest {
version: string; version: string;
core: string[]; // shell assets + HTML pages — cached at install core: string[]; // shell assets + HTML pages — cached at install (with ?v=hash)
modules: string[]; // module JS/CSS — lazy-cached after activation 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) => { self.addEventListener("install", (event) => {
@ -74,6 +75,22 @@ self.addEventListener("activate", (event) => {
if (!existing) await cache.add(url); if (!existing) await cache.add(url);
} catch { /* skip individual failures */ } } 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 */ } } catch { /* manifest unavailable */ }