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:
parent
feabf89137
commit
aff16e647a
|
|
@ -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="">`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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">`,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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">`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 = `
|
||||||
|
|
|
||||||
|
|
@ -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) ──
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 */ }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue