Refactor(cache): drop service worker, add explicit Cache-Control on static assets

Service-worker offline caching had no real value (every detection in this
app needs the network) and conflicted with Cloudflare challenge pages —
the SW would NetworkFirst-timeout and serve a stale index.html, then the
hashed chunk requests came back as a wall of 403s from CF.

Replace with conventional HTTP cache headers in frontend-server.js:
  - dist/assets/** + dist/fonts/**  → 1y immutable (content-addressed)
  - top-level images                → 24h
  - index.html + manifest.webmanifest → no-store (so deploys propagate
                                                  cleanly to hashed chunks)
  - everything else                 → 1h

Existing prod clients still have the old SW + ~5 MB precache installed,
so unregister-service-worker.js does a one-shot cleanup on first load
after this deploy: unregister all SW registrations, drop every cache.
Safe to delete the file and its main.js call after a few release cycles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Opus 4.7 committed 2026-05-07 09:25:22 +08:00
1 parent 9c37dbcd87
commit 9c5b655f4d
9 files changed
+51 -97

No files matched your search

+1 -1
View File
@@ -31,7 +31,7 @@ Single repo, two halves: a Vue 3 SPA front-end and an Express 5 back-end API, se
| Backend | Express 5 |
| Logger | `pino` + `pino-pretty` (dev) + `pino-http` (request logs) — singleton at `common/logger.js` |
| Auth | Firebase Auth (optional, env-gated) |
| PWA | Serwist |
| PWA install | `manifest.webmanifest` only (installable but online-only — no service worker) |
| Tests | Node built-in test runner (`node --test`) |
| Runtime libs | chart.js · svgmap · @cloudflare/speedtest · maxmind · whoiser · thumbmarkjs · ua-parser-js · detect-gpu · circle-progress.vue · @vueuse/core (used by shadcn-vue primitives) |
+27 -2
View File
@@ -21,8 +21,33 @@ frontendApp.use('/api', createProxyMiddleware({
changeOrigin: true
}));
// Set static file directory
frontendApp.use(express.static(path.join(__dirname, './dist')));
// Set static file directory.
// Cache-Control is set per-asset class so the static layer behaves well
// even when no CDN sits in front of it (CF in production sets its own
// Browser TTL on top of these, so the longer values are upper bounds):
// - dist/assets/** Vite-hashed JS/CSS/images — content-addressed → 1y immutable
// - dist/fonts/** non-hashed but essentially never change → 1y immutable
// - top-level images favicon / logos / achievements / … → 24h
// - index.html + manifest never cache — otherwise stale HTML keeps pointing
// at a hashed chunk that no longer exists post-deploy
// - everything else (robots.txt, …) → 1h
const distDir = path.join(__dirname, './dist');
function setStaticHeaders(res, filePath) {
const rel = path.relative(distDir, filePath).replaceAll(path.sep, '/');
if (rel.startsWith('assets/') || rel.startsWith('fonts/')) {
res.setHeader('Cache-Control', `public, max-age=${24 * 60 * 60 * 365}, immutable`);
} else if (/\.(png|jpg|jpeg|webp|svg|ico)$/i.test(rel)) {
res.setHeader('Cache-Control', `public, max-age=${24 * 60 * 60}`);
} else if (rel.endsWith('.html') || rel === 'manifest.webmanifest') {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
} else {
res.setHeader('Cache-Control', `public, max-age=${60 * 60}`);
}
}
frontendApp.use(express.static(distDir, { setHeaders: setStaticHeaders }));
// Start static file server
frontendApp.listen(frontEndPort, () => {
-1
View File
@@ -15,7 +15,6 @@ frontend/
├── main.js ← app bootstrap + global init
├── store.js ← Pinia main store
├── firebase-init.js ← Firebase Auth env-gated init
├── sw.js ← Serwist service worker
├── router/ ← Advanced Tools subroutes (open inside a Drawer)
├── locales/ ← i18n copy (en / zh / fr / tr) + security-checklist data
├── style/style.css ← Tailwind v4 entry + design tokens
+2 -2
View File
@@ -5,7 +5,7 @@ import App from './App.vue'
import i18n from './locales/i18n';
import router from './router';
import { analytics } from './utils/use-analytics';
import { registerServiceWorker } from './utils/register-service-worker';
import { unregisterLegacyServiceWorker } from './utils/unregister-service-worker';
import { detectOS } from './utils/system-detect';
import './style/style.css'
@@ -45,7 +45,7 @@ window.addEventListener('resize', handleResize);
// Start Google Analytics
analytics.page();
registerServiceWorker();
unregisterLegacyServiceWorker();
// Check Firebase environment
store.checkFirebaseEnv();
-65
View File
@@ -1,65 +0,0 @@
// Service Worker configuration
import { CacheFirst, ExpirationPlugin, NetworkFirst, Serwist, StaleWhileRevalidate } from 'serwist';
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
precacheOptions: {
cleanupOutdatedCaches: true,
},
skipWaiting: true,
clientsClaim: true,
runtimeCaching: [
{
matcher: ({ request, url }) => request.mode === 'navigate' || url.pathname.endsWith('.html'),
handler: new NetworkFirst({
cacheName: 'html-cache',
networkTimeoutSeconds: 3,
plugins: [
new ExpirationPlugin({
maxEntries: 5,
maxAgeSeconds: 60 * 60,
}),
],
}),
},
{
matcher: /\/(sw\.js|manifest\.webmanifest)$/,
handler: new NetworkFirst({
cacheName: 'critical-assets',
plugins: [
new ExpirationPlugin({
maxEntries: 3,
maxAgeSeconds: 4 * 60 * 60,
}),
],
}),
},
{
matcher: /\.(?:png|jpg|jpeg|svg|webp|woff|woff2)$/,
handler: new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 7 * 24 * 60 * 60,
}),
],
}),
},
{
matcher: /\.(?:js|css)$/,
handler: new StaleWhileRevalidate({
cacheName: 'assets',
plugins: [
new ExpirationPlugin({
maxEntries: 30,
maxAgeSeconds: 3 * 24 * 60 * 60,
}),
],
}),
},
],
});
serwist.addEventListeners();
-14
View File
@@ -1,14 +0,0 @@
export function registerServiceWorker() {
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) {
return;
}
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
await registration.update();
} catch (error) {
console.warn('Service worker registration failed:', error);
}
});
}
@@ -0,0 +1,21 @@
// One-shot cleanup for the legacy Serwist service worker.
// Existing prod clients still have it registered and a ~5 MB precache cached,
// so first visit after this deploy unregisters the worker and drops every
// cache it ever owned. Safe to delete this file (and its main.js call) after
// a few release cycles, once stale clients have rotated.
export function unregisterLegacyServiceWorker() {
if (!import.meta.env.PROD || typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
return;
}
navigator.serviceWorker.getRegistrations()
.then((regs) => Promise.all(regs.map((r) => r.unregister())))
.catch(() => {});
if (typeof caches !== 'undefined') {
caches.keys()
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
.catch(() => {});
}
}
-2
View File
@@ -55,12 +55,10 @@
"whoiser": "^1.18.0"
},
"devDependencies": {
"@serwist/vite": "^9.5.7",
"@tailwindcss/vite": "^4.2.2",
"@vitejs/plugin-vue": "^6.0.6",
"code-inspector-plugin": "^1.5.1",
"nodemon": "^3.1.14",
"serwist": "^9.5.7",
"tailwindcss": "^4.2.2",
"vite": "^8.0.8"
}
-10
View File
@@ -2,7 +2,6 @@ import dotenv, { parse } from 'dotenv';
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { serwist } from '@serwist/vite'
import { CodeInspectorPlugin } from 'code-inspector-plugin';
dotenv.config();
@@ -76,15 +75,6 @@ export default defineConfig({
}
}),
tailwindcss(),
serwist({
swSrc: 'frontend/sw.js',
swDest: 'sw.js',
globDirectory: 'dist',
globPatterns: [
'**/*.{js,css,woff,woff2}',
'*.{js,css,png,svg,jpg,webp}',
],
}),
CodeInspectorPlugin({
bundler: 'vite',
hideDomPathAttr: true,