diff --git a/next.config.ts b/next.config.ts index 9dff5afa0..35587874b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,8 @@ import { withSentryConfig } from '@sentry/nextjs' import type { NextConfig } from 'next' import type { Configuration as WebpackConfiguration } from 'webpack' +type Header = Awaited>>[number] + const isVercelBuild = process.env.VERCEL === '1' const recaptchaScriptSources = [ @@ -310,25 +312,11 @@ const nextConfig: NextConfig = { async headers() { const isProduction = process.env.NODE_ENV === 'production' - const headers = [ - { - source: '/service-worker.js', - headers: [{ key: 'Cache-Control', value: 'no-store, no-cache, must-revalidate' }], - }, + const headers: Header[] = [ { - source: '/sw-register.js', + source: '/sw.js', headers: [{ key: 'Cache-Control', value: 'no-store, no-cache, must-revalidate' }], }, - // Static assets are immutable in production and uncached in dev. - { - source: '/_next/static/:path*', - headers: isProduction - ? [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }] - : [ - { key: 'Cache-Control', value: 'no-store, no-cache, must-revalidate' }, - { key: 'Pragma', value: 'no-cache' }, - ], - }, // Images and other assets - cache with revalidation { source: '/favicon/:path*', @@ -369,8 +357,8 @@ const nextConfig: NextConfig = { // In dev disable HTML caching to avoid stale content via proxies if (!isProduction) { headers.push({ - // All routes except static assets and API - source: '/((?!_next|api|favicon|service-worker\\.js|sw-register\\.js).*)', + source: '/:path*', + has: [{ type: 'header', key: 'accept', value: '.*text/html.*' }], headers: [ { key: 'Cache-Control', value: 'no-store, no-cache, must-revalidate' }, { key: 'Pragma', value: 'no-cache' }, diff --git a/public/sw-register.js b/public/sw-register.js deleted file mode 100644 index 90d1d34e6..000000000 --- a/public/sw-register.js +++ /dev/null @@ -1,72 +0,0 @@ -const CACHE_NAME = 'emuready_v0.13.2' - -if ('serviceWorker' in navigator) { - window.addEventListener('load', function () { - if ('caches' in window) { - caches.keys().then(function (names) { - for (let name of names) { - if (name.startsWith('emuready') && name !== CACHE_NAME) { - caches - .delete(name) - .then(function () { - console.log('Deleted outdated cache:', name) - }) - .catch(function (err) { - console.error('Error deleting cache:', name, err) - }) - } - } - }) - } - }) - - // Service worker registration - const isDevelopment = - window.location.hostname === 'dev.emuready.com' || - window.location.hostname === 'localhost' || - window.location.hostname === '127.0.0.1' || - window.location.hostname.includes('.local') || - (window.location.protocol === 'http:' && !window.location.hostname.includes('emuready')) - const isServiceWorkerEnabled = window.__EMUREADY_SW_ENABLED__ === true - - if (isDevelopment || !isServiceWorkerEnabled) { - console.log('Service Worker disabled; unregistering any existing SW and clearing caches') - // Proactively unregister any active service workers for this origin - if (navigator.serviceWorker.getRegistrations) { - navigator.serviceWorker - .getRegistrations() - .then((regs) => Promise.all(regs.map((r) => r.unregister()))) - .catch((err) => console.warn('SW unregister failed', err)) - } - if ('caches' in window) { - caches - .keys() - .then((names) => Promise.all(names.map((n) => caches.delete(n)))) - .catch((err) => console.warn('Cache clear failed', err)) - } - } else { - window.addEventListener('load', async function () { - const swUrl = '/service-worker.js' - - if (navigator.serviceWorker.getRegistrations) { - const registrations = await navigator.serviceWorker.getRegistrations() - const currentSwUrl = new URL(swUrl, window.location.href).href - - for (const registration of registrations) { - if (registration.active && registration.active.scriptURL !== currentSwUrl) { - await registration.unregister() - } - } - } - - navigator.serviceWorker - .register(swUrl, { updateViaCache: 'none' }) - .then(function (registration) { - console.log('Service Worker registered with scope:', registration.scope) - }) - .catch(function (error) { - console.error('Service Worker registration failed:', error) - }) - }) - } -} diff --git a/public/service-worker.js b/public/sw.js similarity index 71% rename from public/service-worker.js rename to public/sw.js index d6c24bf8d..f0c106388 100644 --- a/public/service-worker.js +++ b/public/sw.js @@ -128,6 +128,17 @@ function isCacheFresh(response, maxAge) { return age < maxAge } +/** + * Normalize URL pathnames so notification clicks can focus an existing tab even + * if it differs by query params, hash fragments, or a trailing slash. + * @param {string} pathname + * @returns {string} + */ +function normalizePathname(pathname) { + if (pathname === '/') return pathname + return pathname.replace(/\/+$/, '') +} + /** * Fetch event handler with time-based cache expiration. * Implements cache policies based on resource type to prevent stale data. @@ -180,31 +191,38 @@ self.addEventListener( } // Fetch from network - const fetchPromise = fetch(event.request.clone()).then((response) => { - // Cache only successful basic responses - if (!response || response.status !== 200 || response.type !== 'basic') { - return response - } - - // Add timestamp header for cache expiration tracking - const responseToCache = response.clone() - const headers = new Headers(responseToCache.headers) - headers.set('sw-cached-at', Date.now().toString()) + const fetchPromise = fetch(event.request.clone()) + .then((response) => { + // Cache only successful basic responses + if (!response || response.status !== 200 || response.type !== 'basic') { + return response + } + + // Add timestamp header for cache expiration tracking + const responseToCache = response.clone() + const headers = new Headers(responseToCache.headers) + headers.set('sw-cached-at', Date.now().toString()) + + const modifiedResponse = new Response(responseToCache.body, { + status: responseToCache.status, + statusText: responseToCache.statusText, + headers: headers, + }) + + event.waitUntil(cache.put(event.request, modifiedResponse).catch(() => {})) - const modifiedResponse = new Response(responseToCache.body, { - status: responseToCache.status, - statusText: responseToCache.statusText, - headers: headers, + return response + }) + .catch((error) => { + if (cached) return cached + throw error }) - - // Asynchronously update cache - cache.put(event.request, modifiedResponse).catch(() => {}) - - return response - }) // Implement stale-while-revalidate pattern - if (cached) return cached + if (cached) { + event.waitUntil(fetchPromise.catch(() => {})) + return cached + } // Await network response when no cache exists return fetchPromise @@ -222,19 +240,32 @@ self.addEventListener( /** @param {SWPushEvent} event */ (event) => { if (!event.data) return - /** @type {{title: string, body: string, url?: string}} */ - const data = event.data.json() + event.waitUntil( + (async () => { + /** @type {{title?: unknown, body?: unknown, url?: unknown}} */ + let data - /** @type {NotificationOptions} */ - const options = { - body: data.body, - icon: '/favicon/android-chrome-192x192.png', - badge: '/favicon/android-chrome-192x192.png', - vibrate: [100, 50, 100], - data: { url: data.url || '/' }, - } + try { + data = event.data.json() + } catch { + return + } + + if (!data || typeof data !== 'object') return + if (typeof data.title !== 'string' || typeof data.body !== 'string') return + + /** @type {NotificationOptions} */ + const options = { + body: data.body, + icon: '/favicon/android-chrome-192x192.png', + badge: '/favicon/android-chrome-192x192.png', + vibrate: [100, 50, 100], + data: { url: typeof data.url === 'string' ? data.url : '/' }, + } - event.waitUntil(self.registration.showNotification(data.title, options)) + await self.registration.showNotification(data.title, options) + })(), + ) }, ) @@ -249,10 +280,21 @@ self.addEventListener( event.notification.close() event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }).then((wins) => { + const targetUrl = new URL( + typeof event.notification.data?.url === 'string' ? event.notification.data.url : '/', + self.location.origin, + ) + const targetPathname = normalizePathname(targetUrl.pathname) + for (const winClient of wins) { - if (winClient.url === event.notification.data.url) return winClient.focus() + const clientUrl = new URL(winClient.url) + const clientPathname = normalizePathname(clientUrl.pathname) + + if (clientUrl.origin === targetUrl.origin && clientPathname === targetPathname) { + return winClient.focus() + } } - return clients.openWindow(event.notification.data.url) + return clients.openWindow(targetUrl.href) }), ) }, diff --git a/scripts/sync-version.js b/scripts/sync-version.js index 35198a26e..0540a8127 100755 --- a/scripts/sync-version.js +++ b/scripts/sync-version.js @@ -6,50 +6,40 @@ import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const packageJsonPath = path.join(__dirname, '..', 'package.json') -const serviceWorkerPath = path.join(__dirname, '..', 'public', 'service-worker.js') -const swRegisterPath = path.join(__dirname, '..', 'public', 'sw-register.js') +const versionTargets = [ + { + label: 'sw.js', + path: path.join(__dirname, '..', 'public', 'sw.js'), + }, +] const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) const version = packageJson.version -// Simple regex pattern that matches the format: const CACHE_NAME = 'emuready_vX.X.X' -const versionRegex = /const CACHE_NAME = 'emuready_v([\d.]+)'/ - -// Update service-worker.js -const serviceWorkerContent = readFileSync(serviceWorkerPath, 'utf8') -const swMatch = serviceWorkerContent.match(versionRegex) - -let swUpdated = false -if (!swMatch || swMatch[1] !== version) { - const updatedSwContent = serviceWorkerContent.replace( - versionRegex, - `const CACHE_NAME = 'emuready_v${version}'`, - ) - writeFileSync(serviceWorkerPath, updatedSwContent) - swUpdated = true -} +const versionRegex = /const CACHE_NAME = 'emuready_v([^']+)'/ + +const updatedTargets = [] + +for (const target of versionTargets) { + const content = readFileSync(target.path, 'utf8') + const match = content.match(versionRegex) + + if (!match) { + throw new Error(`Could not find CACHE_NAME matching ${versionRegex} in ${target.path}`) + } -// Update sw-register.js (now uses the same format at the top of the file) -const swRegisterContent = readFileSync(swRegisterPath, 'utf8') -const registerMatch = swRegisterContent.match(versionRegex) - -let registerUpdated = false -if (!registerMatch || registerMatch[1] !== version) { - const updatedRegisterContent = swRegisterContent.replace( - versionRegex, - `const CACHE_NAME = 'emuready_v${version}'`, - ) - writeFileSync(swRegisterPath, updatedRegisterContent) - registerUpdated = true + if (match[1] !== version) { + const updatedContent = content.replace( + versionRegex, + `const CACHE_NAME = 'emuready_v${version}'`, + ) + writeFileSync(target.path, updatedContent) + updatedTargets.push(target.label) + } } -// Report results -if (swUpdated && registerUpdated) { - console.log(`✅ Updated both service-worker.js and sw-register.js to v${version}`) -} else if (swUpdated) { - console.log(`✅ Updated service-worker.js to v${version}`) -} else if (registerUpdated) { - console.log(`✅ Updated sw-register.js to v${version}`) +if (updatedTargets.length > 0) { + console.log(`Updated ${updatedTargets.join(', ')} to v${version}`) } else { - console.log(`✓ Both files already have version v${version}`) + console.log(`Service worker cache names already use v${version}`) } diff --git a/src/app/ServiceWorkerRegistrar.test.tsx b/src/app/ServiceWorkerRegistrar.test.tsx new file mode 100644 index 000000000..02aaa4594 --- /dev/null +++ b/src/app/ServiceWorkerRegistrar.test.tsx @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { shouldRegisterServiceWorker, syncServiceWorker } from './ServiceWorkerRegistrar' + +const originalServiceWorker = Object.getOwnPropertyDescriptor(window.navigator, 'serviceWorker') +const originalCaches = Object.getOwnPropertyDescriptor(window, 'caches') + +function restoreProperty( + target: T, + key: keyof T, + descriptor?: PropertyDescriptor, +) { + if (descriptor) { + Object.defineProperty(target, key, descriptor) + return + } + + Reflect.deleteProperty(target, key) +} + +function mockBrowserApis( + registrations: ServiceWorkerRegistration[] = [], + cacheNames: string[] = [], +) { + const register = vi.fn().mockResolvedValue({ scope: '/' }) + const getRegistrations = vi.fn().mockResolvedValue(registrations) + const deleteCache = vi.fn().mockResolvedValue(true) + + Object.defineProperty(window.navigator, 'serviceWorker', { + configurable: true, + value: { register, getRegistrations }, + }) + + Object.defineProperty(window, 'caches', { + configurable: true, + value: { + keys: vi.fn().mockResolvedValue(cacheNames), + delete: deleteCache, + }, + }) + + return { deleteCache, getRegistrations, register } +} + +function mockRegistration(scriptURL: string) { + const unregister = vi.fn().mockResolvedValue(true) + + return { + registration: { + active: { scriptURL }, + unregister, + } as unknown as ServiceWorkerRegistration, + unregister, + } +} + +afterEach(() => { + restoreProperty(window.navigator, 'serviceWorker', originalServiceWorker) + restoreProperty(window, 'caches', originalCaches) + vi.restoreAllMocks() +}) + +describe('shouldRegisterServiceWorker', () => { + it('requires the feature flag', () => { + expect( + shouldRegisterServiceWorker(false, { + hostname: 'www.emuready.com', + protocol: 'https:', + }), + ).toBe(false) + }) + + it.each([ + ['localhost', 'http:'], + ['127.0.0.1', 'http:'], + ['::1', 'http:'], + ['dev.emuready.com', 'https:'], + ['app.local', 'https:'], + ])('does not register on development host %s', (hostname, protocol) => { + expect(shouldRegisterServiceWorker(true, { hostname, protocol })).toBe(false) + }) + + it('registers on production HTTPS hosts', () => { + expect( + shouldRegisterServiceWorker(true, { + hostname: 'www.emuready.com', + protocol: 'https:', + }), + ).toBe(true) + }) +}) + +describe('syncServiceWorker', () => { + it('registers the root service worker without clearing caches when enabled', async () => { + const currentScriptUrl = new URL('/sw.js', window.location.href).href + const stale = mockRegistration('https://www.emuready.com/old-worker.js') + const current = mockRegistration(currentScriptUrl) + + const { deleteCache, register } = mockBrowserApis( + [stale.registration, current.registration], + ['emuready_v0.0.0', 'emuready_v0.13.2', 'third-party-cache'], + ) + + await syncServiceWorker(true, { + hostname: 'www.emuready.com', + protocol: 'https:', + }) + + expect(deleteCache).not.toHaveBeenCalled() + expect(stale.unregister).toHaveBeenCalledTimes(1) + expect(current.unregister).not.toHaveBeenCalled() + expect(register).toHaveBeenCalledWith('/sw.js', { + scope: '/', + updateViaCache: 'none', + }) + }) + + it('unregisters service workers and clears app caches when disabled', async () => { + const first = mockRegistration('https://www.emuready.com/sw.js') + const second = mockRegistration('https://www.emuready.com/old-worker.js') + const { deleteCache, register } = mockBrowserApis( + [first.registration, second.registration], + ['emuready_v0.13.2', 'third-party-cache'], + ) + + await syncServiceWorker(false, { + hostname: 'www.emuready.com', + protocol: 'https:', + }) + + expect(first.unregister).toHaveBeenCalledTimes(1) + expect(second.unregister).toHaveBeenCalledTimes(1) + expect(deleteCache).toHaveBeenCalledWith('emuready_v0.13.2') + expect(deleteCache).not.toHaveBeenCalledWith('third-party-cache') + expect(register).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/ServiceWorkerRegistrar.tsx b/src/app/ServiceWorkerRegistrar.tsx new file mode 100644 index 000000000..cfb3223f1 --- /dev/null +++ b/src/app/ServiceWorkerRegistrar.tsx @@ -0,0 +1,107 @@ +'use client' + +import { useEffect } from 'react' + +const SERVICE_WORKER_URL = '/sw.js' +const SERVICE_WORKER_SCOPE = '/' + +interface Props { + enabled: boolean +} + +interface ServiceWorkerLocation { + hostname: string + protocol: string +} + +export function shouldRegisterServiceWorker(enabled: boolean, location: ServiceWorkerLocation) { + if (!enabled) return false + + const hostname = location.hostname.toLowerCase() + if ( + hostname === 'dev.emuready.com' || + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname === '[::1]' || + hostname.includes('.local') + ) { + return false + } + + return location.protocol === 'https:' +} + +function supportsServiceWorker() { + return typeof navigator !== 'undefined' && 'serviceWorker' in navigator +} + +async function clearAppCaches() { + if (typeof window === 'undefined' || !('caches' in window)) return + + const cacheNames = await window.caches.keys() + const appCacheNames = cacheNames.filter((cacheName) => cacheName.startsWith('emuready')) + + await Promise.all(appCacheNames.map((cacheName) => window.caches.delete(cacheName))) +} + +async function unregisterAllServiceWorkers() { + if (!supportsServiceWorker() || !navigator.serviceWorker.getRegistrations) return + + const registrations = await navigator.serviceWorker.getRegistrations() + await Promise.all(registrations.map((registration) => registration.unregister())) +} + +async function unregisterUnexpectedServiceWorkers() { + if (!supportsServiceWorker() || !navigator.serviceWorker.getRegistrations) return + + const registrations = await navigator.serviceWorker.getRegistrations() + const expectedScriptUrl = new URL(SERVICE_WORKER_URL, window.location.href).href + + await Promise.all( + registrations + .filter((registration) => { + const scriptUrls = [ + registration.active?.scriptURL, + registration.installing?.scriptURL, + registration.waiting?.scriptURL, + ].filter(Boolean) + + return ( + scriptUrls.length > 0 && scriptUrls.some((scriptUrl) => scriptUrl !== expectedScriptUrl) + ) + }) + .map((registration) => registration.unregister()), + ) +} + +export async function syncServiceWorker( + enabled: boolean, + location: ServiceWorkerLocation = window.location, +) { + if (!supportsServiceWorker()) return + + if (!shouldRegisterServiceWorker(enabled, location)) { + await unregisterAllServiceWorkers() + await clearAppCaches() + return + } + + await unregisterUnexpectedServiceWorkers() + await navigator.serviceWorker.register(SERVICE_WORKER_URL, { + scope: SERVICE_WORKER_SCOPE, + updateViaCache: 'none', + }) +} + +export default function ServiceWorkerRegistrar(props: Props) { + useEffect(() => { + syncServiceWorker(props.enabled).catch((error: unknown) => { + if (process.env.NODE_ENV !== 'production') { + console.warn('Service worker setup failed', error) + } + }) + }, [props.enabled]) + + return null +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b8ad31c2a..e0527f2eb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -21,6 +21,7 @@ import { env } from '@/lib/env' import { defaultMetadata } from '@/lib/seo/metadata' import { cn } from '@/lib/utils' import Main from './Main' +import ServiceWorkerRegistrar from './ServiceWorkerRegistrar' const inter = Inter({ subsets: ['latin'] }) @@ -36,11 +37,6 @@ export default function RootLayout(props: PropsWithChildren) { return ( - -