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 <noreply@anthropic.com>
This commit is contained in:
parent
4cc420d0f6
commit
b2b5644f94
|
|
@ -831,6 +831,60 @@ export default defineConfig({
|
||||||
// Demo script not yet created — skip
|
// 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`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
declare const self: ServiceWorkerGlobalScope;
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
const CACHE_VERSION = "rspace-v1";
|
const CACHE_VERSION = "rspace-v2";
|
||||||
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||||
const HTML_CACHE = `${CACHE_VERSION}-html`;
|
const HTML_CACHE = `${CACHE_VERSION}-html`;
|
||||||
const API_CACHE = `${CACHE_VERSION}-api`;
|
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)
|
// Vite-hashed assets are immutable (content hash in filename)
|
||||||
const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
|
const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
|
||||||
|
|
||||||
// App shell to precache on install
|
// Minimal fallback if manifest fetch fails
|
||||||
const PRECACHE_URLS = ["/", "/canvas.html"];
|
const FALLBACK_PRECACHE = ["/", "/canvas.html"];
|
||||||
|
|
||||||
// Max age for cached API GET responses (5 minutes)
|
// Max age for cached API GET responses (5 minutes)
|
||||||
const API_CACHE_MAX_AGE_MS = 5 * 60 * 1000;
|
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) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
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.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener("activate", (event) => {
|
||||||
// Clean up old versioned caches
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches
|
(async () => {
|
||||||
.keys()
|
// Clean up old versioned caches
|
||||||
.then((keys) =>
|
const keys = await caches.keys();
|
||||||
Promise.all(
|
await Promise.all(
|
||||||
keys
|
keys
|
||||||
.filter((key) => !key.startsWith(CACHE_VERSION))
|
.filter((key) => !key.startsWith(CACHE_VERSION))
|
||||||
.map((key) => caches.delete(key))
|
.map((key) => caches.delete(key))
|
||||||
)
|
);
|
||||||
)
|
await self.clients.claim();
|
||||||
.then(() => 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 */ }
|
||||||
|
})()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue