From b2b5644f949cbf50a3dbb5b5c8aa4c36e31e6d57 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 4 Mar 2026 19:12:05 -0800 Subject: [PATCH] feat: add service worker precaching for cold-start offline support Generate precache-manifest.json at build time by scanning dist/ for all cacheable assets. SW fetches the manifest during install and precaches core shell assets (shell.js, shell.css, theme.css, HTML pages) immediately. Module JS/CSS bundles are lazy-cached in the background after activation. Bumps CACHE_VERSION to rspace-v2 to trigger SW update and cache cleanup. The app can now load fully offline even after browser restart with no network. Co-Authored-By: Claude Opus 4.6 --- vite.config.ts | 54 +++++++++++++++++++++++++++++++++++++ website/sw.ts | 73 +++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 112 insertions(+), 15 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 53e3994..be1ec93 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -831,6 +831,60 @@ export default defineConfig({ // Demo script not yet created — skip } } + + // ── Generate precache manifest ── + // Scans dist/ for all cacheable assets and writes precache-manifest.json + const { readdirSync, writeFileSync, statSync: statSync2 } = await import("node:fs"); + const distDir = resolve(__dirname, "dist"); + + function walkDir(dir: string, prefix = ""): string[] { + const entries: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + entries.push(...walkDir(resolve(dir, entry.name), rel)); + } else { + entries.push(`/${rel}`); + } + } + return entries; + } + + const allFiles = walkDir(distDir); + + // Core: shell assets + HTML pages (precached at install, ~300KB) + const core = allFiles.filter((f) => + f === "/" || + f === "/index.html" || + f === "/canvas.html" || + f === "/create-space.html" || + f === "/admin.html" || + f === "/shell.js" || + f === "/shell.css" || + f === "/theme.css" || + f === "/favicon.png" + ); + // Ensure root URL is present + if (!core.includes("/")) 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 + ); + + const manifest = { + version: new Date().toISOString(), + core, + modules, + }; + + writeFileSync( + resolve(distDir, "precache-manifest.json"), + JSON.stringify(manifest, null, "\t"), + ); + console.log(`[precache] Generated manifest: ${core.length} core + ${modules.length} module assets`); }, }, }, diff --git a/website/sw.ts b/website/sw.ts index dd60d23..26a9a4e 100644 --- a/website/sw.ts +++ b/website/sw.ts @@ -1,7 +1,7 @@ /// declare const self: ServiceWorkerGlobalScope; -const CACHE_VERSION = "rspace-v1"; +const CACHE_VERSION = "rspace-v2"; const STATIC_CACHE = `${CACHE_VERSION}-static`; const HTML_CACHE = `${CACHE_VERSION}-html`; const API_CACHE = `${CACHE_VERSION}-api`; @@ -9,32 +9,75 @@ const API_CACHE = `${CACHE_VERSION}-api`; // Vite-hashed assets are immutable (content hash in filename) const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/; -// App shell to precache on install -const PRECACHE_URLS = ["/", "/canvas.html"]; +// Minimal fallback if manifest fetch fails +const FALLBACK_PRECACHE = ["/", "/canvas.html"]; // Max age for cached API GET responses (5 minutes) 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 +} + self.addEventListener("install", (event) => { event.waitUntil( - caches.open(HTML_CACHE).then((cache) => cache.addAll(PRECACHE_URLS)) + (async () => { + let manifest: PrecacheManifest | null = null; + try { + const res = await fetch(`/precache-manifest.json?v=${CACHE_VERSION}`); + if (res.ok) manifest = await res.json(); + } catch { /* manifest unavailable — use fallback */ } + + const coreUrls = manifest?.core ?? FALLBACK_PRECACHE; + + // Precache core shell assets (blocking — required for cold offline start) + const htmlCache = await caches.open(HTML_CACHE); + const staticCache = await caches.open(STATIC_CACHE); + + const htmlUrls = coreUrls.filter((u) => u.endsWith(".html") || u === "/"); + const staticUrls = coreUrls.filter((u) => !u.endsWith(".html") && u !== "/"); + + await Promise.all([ + htmlCache.addAll(htmlUrls), + staticCache.addAll(staticUrls), + ]); + })() ); self.skipWaiting(); }); self.addEventListener("activate", (event) => { - // Clean up old versioned caches event.waitUntil( - caches - .keys() - .then((keys) => - Promise.all( - keys - .filter((key) => !key.startsWith(CACHE_VERSION)) - .map((key) => caches.delete(key)) - ) - ) - .then(() => self.clients.claim()) + (async () => { + // Clean up old versioned caches + const keys = await caches.keys(); + await Promise.all( + keys + .filter((key) => !key.startsWith(CACHE_VERSION)) + .map((key) => caches.delete(key)) + ); + await self.clients.claim(); + + // Lazy-cache module bundles in background (non-blocking) + try { + const res = await fetch(`/precache-manifest.json?v=${CACHE_VERSION}`); + if (res.ok) { + const manifest: PrecacheManifest = await res.json(); + if (manifest.modules?.length) { + const cache = await caches.open(STATIC_CACHE); + // Cache modules one-by-one to avoid overwhelming bandwidth + for (const url of manifest.modules) { + try { + const existing = await cache.match(url); + if (!existing) await cache.add(url); + } catch { /* skip individual failures */ } + } + } + } + } catch { /* manifest unavailable */ } + })() ); });