From dcbb4649a24816f33feed0f2b9e397edccc81613 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 18 Feb 2026 22:30:47 -0800 Subject: [PATCH] gateway: prevent stale web UI assets on localhost --- src/gateway/server.test.ts | 3 ++- src/gateway/static.ts | 26 ++++++++++++++++++++++---- src/gateway/ui/lib/pwa.js | 27 +++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index d37a885..6ab2234 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -295,7 +295,7 @@ describe('GatewayServer integration', () => { const res = await fetch(`http://127.0.0.1:${TEST_PORT}/`); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('text/html'); - expect(res.headers.get('cache-control')).toBe('no-cache'); + expect(res.headers.get('cache-control')).toBe('no-store'); const body = await res.text(); expect(body).toContain('Flynn'); }); @@ -307,6 +307,7 @@ describe('GatewayServer integration', () => { const res = await fetch(`http://127.0.0.1:${TEST_PORT}/style.css`); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('text/css'); + expect(res.headers.get('cache-control')).toBe('no-store'); }); it('serves sw.js with no-store cache policy', async () => { diff --git a/src/gateway/static.ts b/src/gateway/static.ts index 2bd0b4d..c58e397 100644 --- a/src/gateway/static.ts +++ b/src/gateway/static.ts @@ -16,6 +16,25 @@ const CONTENT_TYPES: Record = { '.woff2': 'font/woff2', }; +function resolveCacheControl(pathname: string, ext: string): string | null { + if (pathname === '/sw.js') { + // Always fetch the latest worker script so clients can update immediately. + return 'no-store'; + } + + if (pathname === '/index.html' || pathname === '/chat.html') { + // Prevent stale shell HTML from pinning old asset references. + return 'no-store'; + } + + if (ext === '.js' || ext === '.mjs' || ext === '.css') { + // Dashboard/frontend code changes frequently in local deployments. + return 'no-store'; + } + + return null; +} + /** * Serve static files from a directory. * @@ -72,10 +91,9 @@ export async function serveStatic( try { const content = await readFile(filePath); const headers: Record = { 'Content-Type': contentType }; - if (pathname === '/sw.js') { - headers['Cache-Control'] = 'no-store'; - } else if (pathname === '/index.html') { - headers['Cache-Control'] = 'no-cache'; + const cacheControl = resolveCacheControl(pathname, ext); + if (cacheControl) { + headers['Cache-Control'] = cacheControl; } res.writeHead(200, headers); res.end(content); diff --git a/src/gateway/ui/lib/pwa.js b/src/gateway/ui/lib/pwa.js index 9f4197d..ce6bf4a 100644 --- a/src/gateway/ui/lib/pwa.js +++ b/src/gateway/ui/lib/pwa.js @@ -13,6 +13,27 @@ function withToken(path) { return `${path}${separator}token=${encodeURIComponent(token)}`; } +function isLocalhostHost(hostname) { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; +} + +async function unregisterAllServiceWorkers() { + if (!('serviceWorker' in navigator)) { + return; + } + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((registration) => registration.unregister())); +} + +async function clearFlynnUiCaches() { + if (!('caches' in globalThis)) { + return; + } + const cacheKeys = await caches.keys(); + const targets = cacheKeys.filter((key) => key.startsWith('flynn-webchat-')); + await Promise.all(targets.map((key) => caches.delete(key))); +} + async function requestJson(path, options) { const response = await fetch(withToken(path), { ...(options ?? {}), @@ -60,6 +81,12 @@ export async function registerPwaServiceWorker() { if (!('serviceWorker' in navigator)) { return null; } + if (isLocalhostHost(window.location.hostname)) { + // Local dev/runtime should always reflect latest dashboard assets. + await unregisterAllServiceWorkers(); + await clearFlynnUiCaches(); + return null; + } const registration = await navigator.serviceWorker.register(withToken('/sw.js')); // Ask the browser to check for a newer worker on app load. void registration.update().catch(() => undefined);