From 7d5f4a6b8d22d4c13aad058f70cbebb927e83c9e Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 7 Mar 2026 23:08:25 +0000 Subject: [PATCH] fix: stale SW chunks serving HTML, PWA cache cleanup, server asset routing - Server SPA fallback now returns 404 for missing .js/.css assets instead of index.html - Prevents "Unexpected token '<'" errors from stale chunk URLs - Serve PWA files (sw.js, workbox, icons) with correct cache headers - Workbox: cleanupOutdatedCaches, skipWaiting, clientsClaim for instant SW updates - navigateFallbackDenylist for /api/ routes - AudioContext comment clarification Co-Authored-By: Claude Opus 4.6 --- frontend/src/game/sounds.ts | 3 +++ frontend/vite.config.ts | 4 ++++ server/src/app.ts | 14 +++++++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts index 263ab40..6ac3ddc 100644 --- a/frontend/src/game/sounds.ts +++ b/frontend/src/game/sounds.ts @@ -5,6 +5,9 @@ let sfxGain: GainNode | null = null let musicPlaying = false let musicTimeout: number | null = null +// Lazily create AudioContext + gain nodes on first use. +// Chrome may warn about autoplay policy but the context will resume once ensureAudioContext() +// is called from a user gesture. All SFX calls before that are queued/silent. function getCtx(): AudioContext { if (!ctx) { ctx = new AudioContext() diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6767bdc..2fca96b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -28,7 +28,11 @@ export default defineConfig({ }, workbox: { globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2}'], + cleanupOutdatedCaches: true, + skipWaiting: true, + clientsClaim: true, navigateFallback: '/index.html', + navigateFallbackDenylist: [/^\/api\//], runtimeCaching: [ { urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, diff --git a/server/src/app.ts b/server/src/app.ts index b2ef69b..50efaae 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -75,9 +75,21 @@ if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) { app.get('/robots.txt', (c) => serveFile(c, '/robots.txt', 'public, max-age=86400')) app.get('/manifest.webmanifest', (c) => serveFile(c, '/manifest.webmanifest', 'public, max-age=86400')) - // SPA fallback: all non-API routes serve index.html + // PWA service worker + related root files + app.get('/sw.js', (c) => serveFile(c, '/sw.js', 'no-cache')) + app.get('/workbox-*.js', (c) => serveFile(c, c.req.path, 'public, max-age=31536000, immutable')) + app.get('/icon-*.png', (c) => serveFile(c, c.req.path, 'public, max-age=86400')) + app.get('/icon.svg', (c) => serveFile(c, '/icon.svg', 'public, max-age=86400')) + app.get('/apple-touch-icon.png', (c) => serveFile(c, '/apple-touch-icon.png', 'public, max-age=86400')) + + // SPA fallback: only for navigation requests (not JS/CSS/asset files) app.get('*', (c) => { if (c.req.path.startsWith('/api/')) return c.notFound() + // Don't serve index.html for asset requests — return 404 so the browser gets a proper error + const ext = c.req.path.split('.').pop() + if (ext && ext !== c.req.path && ['js', 'css', 'map', 'json', 'png', 'jpg', 'svg', 'woff', 'woff2', 'webp', 'ico'].includes(ext)) { + return c.notFound() + } const indexPath = join(publicDir, 'index.html') c.header('Content-Type', 'text/html') c.header('Cache-Control', 'no-cache')