Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 6 additions & 18 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { withSentryConfig } from '@sentry/nextjs'
import type { NextConfig } from 'next'
import type { Configuration as WebpackConfiguration } from 'webpack'

type Header = Awaited<ReturnType<NonNullable<NextConfig['headers']>>>[number]

const isVercelBuild = process.env.VERCEL === '1'

const recaptchaScriptSources = [
Expand Down Expand Up @@ -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*',
Expand Down Expand Up @@ -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' },
Expand Down
72 changes: 0 additions & 72 deletions public/sw-register.js

This file was deleted.

110 changes: 76 additions & 34 deletions public/service-worker.js → public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(() => {}))

Comment thread
Producdevity marked this conversation as resolved.
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
Expand All @@ -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)
})(),
)
},
)

Expand All @@ -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)
}),
)
},
Expand Down
66 changes: 28 additions & 38 deletions scripts/sync-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
}
Loading
Loading