diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index a7c8285..803ac43 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -714,8 +714,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ` + scripts: ``, + styles: ` `, })); }); diff --git a/modules/rmaps/mod.ts b/modules/rmaps/mod.ts index fca53a2..06ab2f1 100644 --- a/modules/rmaps/mod.ts +++ b/modules/rmaps/mod.ts @@ -141,7 +141,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, })); }); @@ -158,7 +158,7 @@ routes.get("/:room", (c) => { theme: "dark", styles: ``, body: ``, - scripts: ``, + scripts: ``, })); }); diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index de83cb8..a0830c0 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -305,7 +305,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), body: `
Open Full App
`, - scripts: ``, + scripts: ``, styles: ``, })); }); diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts index 2c56a65..82a74b4 100644 --- a/modules/rschedule/mod.ts +++ b/modules/rschedule/mod.ts @@ -764,8 +764,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, }), ); }); diff --git a/modules/rswag/mod.ts b/modules/rswag/mod.ts index 0d08039..1674251 100644 --- a/modules/rswag/mod.ts +++ b/modules/rswag/mod.ts @@ -237,8 +237,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); diff --git a/modules/rwork/mod.ts b/modules/rwork/mod.ts index 45fa09f..50b4e02 100644 --- a/modules/rwork/mod.ts +++ b/modules/rwork/mod.ts @@ -400,7 +400,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, })); }); diff --git a/server/landing.ts b/server/landing.ts index 5111640..84c9210 100644 --- a/server/landing.ts +++ b/server/landing.ts @@ -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 ` + return versionAssetUrls(` @@ -34,8 +34,8 @@ export function renderMainLanding(modules: ModuleInfo[]): string { rSpace — Community Platform - - + + @@ -220,7 +220,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string { -`; +`); } // ── Space Dashboard ── @@ -267,7 +267,7 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri }) .join("\n"); - return ` + return versionAssetUrls(` @@ -275,8 +275,8 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri ${escapeHtml(displayName)} | rSpace - - + + @@ -313,7 +313,7 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri ` : ""} -`; +`); } const SPACE_DASHBOARD_CSS = ` diff --git a/server/shell.ts b/server/shell.ts index 4b7c7bc..73e3a4a 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -5,9 +5,28 @@ * switchers + identity,
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 = {}; +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= 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 ` + return versionAssetUrls(` @@ -83,8 +102,8 @@ export function renderShell(opts: ShellOptions): string { ${escapeHtml(title)} - - + + @@ -1155,7 +1174,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string { ${bodyContent} -`; +`); } // ── Onboarding page (empty rApp state) ── diff --git a/vite.config.ts b/vite.config.ts index 68cbb08..2ffb7ba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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 = {}; + 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( diff --git a/website/sw.ts b/website/sw.ts index 26a9a4e..c5fdc1e 100644 --- a/website/sw.ts +++ b/website/sw.ts @@ -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; // 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 */ }