13 changed files with 834 additions and 48 deletions
@ -1,11 +1,326 @@ |
|||||||
import { Controller } from '@hotwired/stimulus' |
import { Controller } from '@hotwired/stimulus' |
||||||
|
|
||||||
export default class extends Controller { |
export default class extends Controller { |
||||||
|
static targets = ['status'] |
||||||
|
static values = { |
||||||
|
staticUrls: Array, |
||||||
|
cacheName: String |
||||||
|
} |
||||||
|
|
||||||
connect() { |
connect() { |
||||||
|
// Check if we've shown an update prompt recently (within 1 hour)
|
||||||
|
const lastPrompt = localStorage.getItem('sw-update-prompt-shown'); |
||||||
|
const oneHourAgo = Date.now() - (60 * 60 * 1000); |
||||||
|
|
||||||
|
if (lastPrompt && parseInt(lastPrompt) > oneHourAgo) { |
||||||
|
this.updatePromptShown = true; |
||||||
|
} else { |
||||||
|
this.updatePromptShown = false; |
||||||
|
} |
||||||
|
|
||||||
if ('serviceWorker' in navigator) { |
if ('serviceWorker' in navigator) { |
||||||
navigator.serviceWorker.register('/service-worker.js') |
this.loadStaticRoutes().then(() => { |
||||||
.then(reg => console.log('SW registered:', reg)) |
this.registerServiceWorker(); |
||||||
.catch(err => console.error('SW failed:', err)); |
this.setupServiceWorkerEventListeners(); |
||||||
|
}); |
||||||
|
} else { |
||||||
|
this.updateStatus('Service Worker not supported'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async loadStaticRoutes() { |
||||||
|
try { |
||||||
|
const response = await fetch('/api/static-routes'); |
||||||
|
if (response.ok) { |
||||||
|
const data = await response.json(); |
||||||
|
this.staticUrlsValue = data.routes; |
||||||
|
this.cacheNameValue = data.cacheName; |
||||||
|
console.log('Loaded static routes for controller:', this.staticUrlsValue); |
||||||
|
} else { |
||||||
|
console.warn('Failed to load static routes from API'); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading static routes:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async registerServiceWorker() { |
||||||
|
try { |
||||||
|
const registration = await navigator.serviceWorker.register('/service-worker.js'); |
||||||
|
console.log('SW registered:', registration); |
||||||
|
|
||||||
|
// Check if there's actually a waiting service worker with new content
|
||||||
|
if (registration.waiting && !this.updatePromptShown) { |
||||||
|
this.showUpdateAvailable(registration); |
||||||
|
} |
||||||
|
|
||||||
|
// Listen for service worker updates, but be more selective
|
||||||
|
registration.addEventListener('updatefound', () => { |
||||||
|
const newWorker = registration.installing; |
||||||
|
|
||||||
|
newWorker.addEventListener('statechange', () => { |
||||||
|
// Only show update if:
|
||||||
|
// 1. The worker is installed
|
||||||
|
// 2. We have an active controller (not first install)
|
||||||
|
// 3. Haven't shown prompt recently
|
||||||
|
if (newWorker.state === 'installed' && |
||||||
|
navigator.serviceWorker.controller && |
||||||
|
!this.updatePromptShown) { |
||||||
|
|
||||||
|
// Double-check we haven't prompted recently
|
||||||
|
const lastPrompt = localStorage.getItem('sw-update-prompt-shown'); |
||||||
|
const thirtyMinutesAgo = Date.now() - (30 * 60 * 1000); |
||||||
|
|
||||||
|
if (!lastPrompt || parseInt(lastPrompt) < thirtyMinutesAgo) { |
||||||
|
// Add a delay to avoid immediate prompts during development
|
||||||
|
setTimeout(() => { |
||||||
|
if (!this.updatePromptShown && registration.waiting) { |
||||||
|
this.showUpdateAvailable(registration); |
||||||
|
} |
||||||
|
}, 3000); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
this.updateStatus('Service Worker registered successfully'); |
||||||
|
await this.checkCacheStatus(); |
||||||
|
} catch (error) { |
||||||
|
console.error('SW failed:', error); |
||||||
|
this.updateStatus('Service Worker registration failed'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
setupServiceWorkerEventListeners() { |
||||||
|
// Listen for messages from the service worker
|
||||||
|
navigator.serviceWorker.addEventListener('message', (event) => { |
||||||
|
if (event.data && event.data.type) { |
||||||
|
switch (event.data.type) { |
||||||
|
case 'CACHE_UPDATED': |
||||||
|
this.updateStatus('Cache updated successfully'); |
||||||
|
break; |
||||||
|
case 'CACHE_ERROR': |
||||||
|
this.updateStatus('Cache update failed'); |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
async checkCacheStatus() { |
||||||
|
if ('caches' in window) { |
||||||
|
try { |
||||||
|
const cacheNames = await caches.keys(); |
||||||
|
const cacheName = this.cacheNameValue || 'newsroom-static'; |
||||||
|
const hasStaticCache = cacheNames.some(name => name.includes(cacheName.split('-')[0] + '-' + cacheName.split('-')[1])); |
||||||
|
|
||||||
|
if (hasStaticCache) { |
||||||
|
const cache = await caches.open(cacheName); |
||||||
|
const cachedRequests = await cache.keys(); |
||||||
|
this.updateStatus(`${cachedRequests.length} static pages cached`); |
||||||
|
} else { |
||||||
|
this.updateStatus('Static pages not yet cached'); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Cache status check failed:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
showUpdateAvailable(registration) { |
||||||
|
// Prevent multiple prompts
|
||||||
|
if (this.updatePromptShown) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
this.updatePromptShown = true; |
||||||
|
|
||||||
|
// Store the timestamp to avoid showing again too soon
|
||||||
|
localStorage.setItem('sw-update-prompt-shown', Date.now().toString()); |
||||||
|
|
||||||
|
// Use a more user-friendly notification
|
||||||
|
const shouldUpdate = confirm('A new version of the app is available. Would you like to update now?'); |
||||||
|
|
||||||
|
if (shouldUpdate) { |
||||||
|
if (registration.waiting) { |
||||||
|
registration.waiting.postMessage({ type: 'SKIP_WAITING' }); |
||||||
|
registration.waiting.addEventListener('statechange', (e) => { |
||||||
|
if (e.target.state === 'activated') { |
||||||
|
window.location.reload(); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// If user declines, don't ask again for 2 hours
|
||||||
|
localStorage.setItem('sw-update-prompt-shown', (Date.now() + (2 * 60 * 60 * 1000)).toString()); |
||||||
|
|
||||||
|
// Reset the flag after 2 hours
|
||||||
|
setTimeout(() => { |
||||||
|
this.updatePromptShown = false; |
||||||
|
}, 2 * 60 * 60 * 1000); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async clearCache() { |
||||||
|
try { |
||||||
|
const cacheNames = await caches.keys(); |
||||||
|
await Promise.all( |
||||||
|
cacheNames.map(cacheName => caches.delete(cacheName)) |
||||||
|
); |
||||||
|
this.updateStatus('All caches cleared'); |
||||||
|
console.log('All caches cleared'); |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to clear caches:', error); |
||||||
|
this.updateStatus('Failed to clear caches'); |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
|
async refreshCache() { |
||||||
|
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { |
||||||
|
navigator.serviceWorker.controller.postMessage({ |
||||||
|
type: 'REFRESH_CACHE' |
||||||
|
}); |
||||||
|
this.updateStatus('Cache refresh requested'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async getCacheStatus() { |
||||||
|
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { |
||||||
|
return new Promise((resolve) => { |
||||||
|
const messageChannel = new MessageChannel(); |
||||||
|
messageChannel.port1.onmessage = (event) => { |
||||||
|
if (event.data.type === 'CACHE_STATUS') { |
||||||
|
resolve(event.data.status); |
||||||
|
} else { |
||||||
|
resolve(null); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
navigator.serviceWorker.controller.postMessage( |
||||||
|
{ type: 'GET_CACHE_STATUS' }, |
||||||
|
[messageChannel.port2] |
||||||
|
); |
||||||
|
}); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
async displayCacheInfo() { |
||||||
|
const status = await this.getCacheStatus(); |
||||||
|
if (status) { |
||||||
|
console.log('Cache Status:', status); |
||||||
|
let message = 'Cache Status:\n'; |
||||||
|
for (const [cacheName, count] of Object.entries(status)) { |
||||||
|
message += `${cacheName}: ${count} items\n`; |
||||||
|
} |
||||||
|
this.updateStatus(message.trim()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async preloadCriticalAssets() { |
||||||
|
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { |
||||||
|
// Get the manifest to find critical assets
|
||||||
|
try { |
||||||
|
const manifestResponse = await fetch('/assets/manifest.json'); |
||||||
|
if (manifestResponse.ok) { |
||||||
|
const manifest = await manifestResponse.json(); |
||||||
|
|
||||||
|
// Critical assets that should be preloaded
|
||||||
|
const criticalAssets = [ |
||||||
|
'app.js', |
||||||
|
'bootstrap.js', |
||||||
|
'styles/app.css', |
||||||
|
'styles/theme.css', |
||||||
|
'styles/layout.css', |
||||||
|
'styles/fonts.css' |
||||||
|
]; |
||||||
|
|
||||||
|
const assetsToPreload = criticalAssets |
||||||
|
.map(asset => manifest[asset]) |
||||||
|
.filter(Boolean); |
||||||
|
|
||||||
|
// Trigger preloading by making requests
|
||||||
|
const preloadPromises = assetsToPreload.map(assetUrl => |
||||||
|
fetch(assetUrl, { mode: 'no-cors' }).catch(() => { |
||||||
|
console.warn('Failed to preload:', assetUrl); |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
await Promise.all(preloadPromises); |
||||||
|
this.updateStatus(`Preloaded ${assetsToPreload.length} critical assets`); |
||||||
|
console.log('Preloaded assets:', assetsToPreload); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to preload critical assets:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Enhanced cache management methods
|
||||||
|
async clearAssetsCache() { |
||||||
|
if ('caches' in window) { |
||||||
|
try { |
||||||
|
const deleted = await caches.delete('newsroom-assets-v1'); |
||||||
|
if (deleted) { |
||||||
|
this.updateStatus('Assets cache cleared'); |
||||||
|
console.log('Assets cache cleared'); |
||||||
|
} else { |
||||||
|
this.updateStatus('Assets cache not found'); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to clear assets cache:', error); |
||||||
|
this.updateStatus('Failed to clear assets cache'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async clearStaticCache() { |
||||||
|
if ('caches' in window) { |
||||||
|
try { |
||||||
|
const deleted = await caches.delete('newsroom-static-v1'); |
||||||
|
if (deleted) { |
||||||
|
this.updateStatus('Static cache cleared'); |
||||||
|
console.log('Static cache cleared'); |
||||||
|
} else { |
||||||
|
this.updateStatus('Static cache not found'); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to clear static cache:', error); |
||||||
|
this.updateStatus('Failed to clear static cache'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
updateStatus(message) { |
||||||
|
if (this.hasStatusTarget) { |
||||||
|
this.statusTarget.textContent = message; |
||||||
|
console.log('SW Status:', message); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Action methods that can be called from the template
|
||||||
|
clearCacheAction() { |
||||||
|
this.clearCache(); |
||||||
|
} |
||||||
|
|
||||||
|
refreshCacheAction() { |
||||||
|
this.refreshCache(); |
||||||
|
} |
||||||
|
|
||||||
|
// Enhanced action methods
|
||||||
|
clearAssetsCacheAction() { |
||||||
|
this.clearAssetsCache(); |
||||||
|
} |
||||||
|
|
||||||
|
clearStaticCacheAction() { |
||||||
|
this.clearStaticCache(); |
||||||
|
} |
||||||
|
|
||||||
|
displayCacheInfoAction() { |
||||||
|
this.displayCacheInfo(); |
||||||
|
} |
||||||
|
|
||||||
|
preloadCriticalAssetsAction() { |
||||||
|
this.preloadCriticalAssets(); |
||||||
|
} |
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,16 @@ |
|||||||
|
# Asset caching configuration for better browser caching |
||||||
|
framework: |
||||||
|
assets: |
||||||
|
# AssetMapper already handles versioning via manifest.json |
||||||
|
# No need to set version_strategy as it conflicts with json_manifest_path |
||||||
|
json_manifest_path: '%kernel.project_dir%/public/assets/manifest.json' |
||||||
|
|
||||||
|
# Configure HTTP cache headers for static assets |
||||||
|
when@prod: |
||||||
|
framework: |
||||||
|
http_cache: |
||||||
|
# Cache static assets for longer periods |
||||||
|
default_ttl: 3600 # 1 hour default |
||||||
|
private_headers: ['authorization', 'cookie'] |
||||||
|
allow_reload: false |
||||||
|
allow_revalidate: false |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
# Robots.txt |
||||||
|
|
||||||
|
# Disallow all by default |
||||||
|
User-agent: * |
||||||
|
Disallow: / |
||||||
|
|
||||||
|
# Allow only specific public content |
||||||
|
Allow: /assets/ |
||||||
|
Allow: /icons/ |
||||||
|
Allow: /$ |
||||||
|
Allow: /unfold |
||||||
|
Allow: /about |
||||||
|
Allow: /mag/ |
||||||
|
Allow: /p/ |
||||||
|
Allow: /list/ |
||||||
|
|
||||||
|
# Crawl delay |
||||||
|
Crawl-delay: 1 |
||||||
@ -1,11 +1,309 @@ |
|||||||
|
// Define cache names and versions
|
||||||
|
const CACHE_NAME = 'newsroom-v1'; |
||||||
|
const STATIC_CACHE = 'newsroom-static-v1'; |
||||||
|
const ASSETS_CACHE = 'newsroom-assets-v1'; |
||||||
|
const RUNTIME_CACHE = 'newsroom-runtime-v1'; |
||||||
|
|
||||||
|
// Assets to cache immediately on install
|
||||||
|
const PRECACHE_ASSETS = [ |
||||||
|
'/', |
||||||
|
'/assets/app.js', |
||||||
|
'/assets/bootstrap.js', |
||||||
|
'/assets/styles/app.css', |
||||||
|
'/assets/styles/theme.css', |
||||||
|
'/assets/styles/layout.css', |
||||||
|
'/assets/styles/fonts.css', |
||||||
|
'/assets/icons/favicon.ico', |
||||||
|
'/assets/icons/web-app-manifest-192x192.png', |
||||||
|
'/assets/icons/web-app-manifest-512x512.png' |
||||||
|
]; |
||||||
|
|
||||||
|
// Define what should be cached with different strategies
|
||||||
|
const CACHE_STRATEGIES = { |
||||||
|
// CSS files - cache first (they change infrequently)
|
||||||
|
css: { |
||||||
|
pattern: /\.css$/, |
||||||
|
strategy: 'cacheFirst', |
||||||
|
cacheName: ASSETS_CACHE, |
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||||
|
}, |
||||||
|
// JS files - cache first with network fallback
|
||||||
|
js: { |
||||||
|
pattern: /\.js$/, |
||||||
|
strategy: 'cacheFirst', |
||||||
|
cacheName: ASSETS_CACHE, |
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||||
|
}, |
||||||
|
// Fonts - cache first (rarely change)
|
||||||
|
fonts: { |
||||||
|
pattern: /\.(woff2?|ttf|eot)$/, |
||||||
|
strategy: 'cacheFirst', |
||||||
|
cacheName: ASSETS_CACHE, |
||||||
|
maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year
|
||||||
|
}, |
||||||
|
// Images and icons - cache first
|
||||||
|
images: { |
||||||
|
pattern: /\.(png|jpg|jpeg|gif|svg|ico)$/, |
||||||
|
strategy: 'cacheFirst', |
||||||
|
cacheName: ASSETS_CACHE, |
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||||
|
}, |
||||||
|
// Static pages - network first with cache fallback
|
||||||
|
pages: { |
||||||
|
pattern: /^https?.*\/(about|roadmap|tos|landing|unfold)$/, |
||||||
|
strategy: 'networkFirst', |
||||||
|
cacheName: STATIC_CACHE, |
||||||
|
maxAge: 24 * 60 * 60 * 1000 // 1 day
|
||||||
|
}, |
||||||
|
// API calls - network first
|
||||||
|
api: { |
||||||
|
pattern: /\/api\//, |
||||||
|
strategy: 'networkFirst', |
||||||
|
cacheName: RUNTIME_CACHE, |
||||||
|
maxAge: 5 * 60 * 1000 // 5 minutes
|
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
self.addEventListener('install', async (event) => { |
self.addEventListener('install', async (event) => { |
||||||
// Activate the service worker immediately after install
|
console.log('Service Worker installing...'); |
||||||
|
|
||||||
|
event.waitUntil( |
||||||
|
(async () => { |
||||||
|
try { |
||||||
|
// Cache core assets immediately
|
||||||
|
const cache = await caches.open(ASSETS_CACHE); |
||||||
|
console.log('Precaching core assets...'); |
||||||
|
|
||||||
|
// Get the current asset manifest to cache the actual versioned files
|
||||||
|
const manifestResponse = await fetch('/assets/manifest.json'); |
||||||
|
if (manifestResponse.ok) { |
||||||
|
const manifest = await manifestResponse.json(); |
||||||
|
const versionedAssets = PRECACHE_ASSETS.map(asset => { |
||||||
|
const logicalPath = asset.replace('/assets/', ''); |
||||||
|
return manifest[logicalPath] || asset; |
||||||
|
}); |
||||||
|
|
||||||
|
await cache.addAll(versionedAssets); |
||||||
|
console.log('Precached assets:', versionedAssets); |
||||||
|
} else { |
||||||
|
// Fallback to original assets if manifest not available
|
||||||
|
await cache.addAll(PRECACHE_ASSETS); |
||||||
|
} |
||||||
|
|
||||||
|
// Activate immediately
|
||||||
await self.skipWaiting(); |
await self.skipWaiting(); |
||||||
|
} catch (error) { |
||||||
|
console.error('Precaching failed:', error); |
||||||
|
} |
||||||
|
})() |
||||||
|
); |
||||||
}); |
}); |
||||||
|
|
||||||
self.addEventListener('activate', (event) => { |
self.addEventListener('activate', (event) => { |
||||||
// Take control of all clients immediately
|
console.log('Service Worker activating...'); |
||||||
event.waitUntil(self.clients.claim()); |
|
||||||
|
event.waitUntil( |
||||||
|
(async () => { |
||||||
|
// Clean up old caches
|
||||||
|
const cacheNames = await caches.keys(); |
||||||
|
const oldCaches = cacheNames.filter(name => |
||||||
|
name.startsWith('newsroom-') && |
||||||
|
![CACHE_NAME, STATIC_CACHE, ASSETS_CACHE, RUNTIME_CACHE].includes(name) |
||||||
|
); |
||||||
|
|
||||||
|
await Promise.all(oldCaches.map(name => caches.delete(name))); |
||||||
|
console.log('Cleaned up old caches:', oldCaches); |
||||||
|
|
||||||
|
// Take control of all clients
|
||||||
|
await self.clients.claim(); |
||||||
|
})() |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => { |
||||||
|
const request = event.request; |
||||||
|
const url = new URL(request.url); |
||||||
|
|
||||||
|
// Skip non-GET requests
|
||||||
|
if (request.method !== 'GET') return; |
||||||
|
|
||||||
|
// Skip chrome-extension and other non-http requests
|
||||||
|
if (!url.protocol.startsWith('http')) return; |
||||||
|
|
||||||
|
// Find matching cache strategy
|
||||||
|
const strategy = findCacheStrategy(request.url); |
||||||
|
|
||||||
|
if (strategy) { |
||||||
|
event.respondWith(handleRequest(request, strategy)); |
||||||
|
} |
||||||
}); |
}); |
||||||
|
|
||||||
// No fetch or cache handlers — fully fall-through
|
self.addEventListener('message', (event) => { |
||||||
|
const { type, data } = event.data || {}; |
||||||
|
|
||||||
|
switch (type) { |
||||||
|
case 'SKIP_WAITING': |
||||||
|
self.skipWaiting(); |
||||||
|
break; |
||||||
|
|
||||||
|
case 'REFRESH_CACHE': |
||||||
|
handleCacheRefresh(); |
||||||
|
break; |
||||||
|
|
||||||
|
case 'GET_CACHE_STATUS': |
||||||
|
handleCacheStatus(event); |
||||||
|
break; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
function findCacheStrategy(url) { |
||||||
|
for (const [name, config] of Object.entries(CACHE_STRATEGIES)) { |
||||||
|
if (config.pattern.test(url)) { |
||||||
|
return config; |
||||||
|
} |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleRequest(request, strategy) { |
||||||
|
const cache = await caches.open(strategy.cacheName); |
||||||
|
|
||||||
|
switch (strategy.strategy) { |
||||||
|
case 'cacheFirst': |
||||||
|
return cacheFirst(request, cache, strategy); |
||||||
|
case 'networkFirst': |
||||||
|
return networkFirst(request, cache, strategy); |
||||||
|
case 'staleWhileRevalidate': |
||||||
|
return staleWhileRevalidate(request, cache, strategy); |
||||||
|
default: |
||||||
|
return fetch(request); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function cacheFirst(request, cache, strategy) { |
||||||
|
try { |
||||||
|
// Check cache first
|
||||||
|
const cachedResponse = await cache.match(request); |
||||||
|
if (cachedResponse && !isExpired(cachedResponse, strategy.maxAge)) { |
||||||
|
return cachedResponse; |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch from network
|
||||||
|
const networkResponse = await fetch(request); |
||||||
|
|
||||||
|
if (networkResponse.ok) { |
||||||
|
// Clone and cache the response
|
||||||
|
const responseToCache = networkResponse.clone(); |
||||||
|
await cache.put(request, responseToCache); |
||||||
|
} |
||||||
|
|
||||||
|
return networkResponse; |
||||||
|
} catch (error) { |
||||||
|
console.error('Cache first strategy failed:', error); |
||||||
|
// Return cached version even if expired as fallback
|
||||||
|
const cachedResponse = await cache.match(request); |
||||||
|
if (cachedResponse) { |
||||||
|
return cachedResponse; |
||||||
|
} |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function networkFirst(request, cache, strategy) { |
||||||
|
try { |
||||||
|
// Try network first
|
||||||
|
const networkResponse = await fetch(request); |
||||||
|
|
||||||
|
if (networkResponse.ok) { |
||||||
|
// Cache successful responses
|
||||||
|
const responseToCache = networkResponse.clone(); |
||||||
|
await cache.put(request, responseToCache); |
||||||
|
} |
||||||
|
|
||||||
|
return networkResponse; |
||||||
|
} catch (error) { |
||||||
|
console.log('Network failed, trying cache:', error.message); |
||||||
|
// Fallback to cache
|
||||||
|
const cachedResponse = await cache.match(request); |
||||||
|
if (cachedResponse) { |
||||||
|
return cachedResponse; |
||||||
|
} |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function staleWhileRevalidate(request, cache, strategy) { |
||||||
|
const cachedResponse = await cache.match(request); |
||||||
|
|
||||||
|
// Always fetch in background to update cache
|
||||||
|
const fetchPromise = fetch(request).then(networkResponse => { |
||||||
|
if (networkResponse.ok) { |
||||||
|
cache.put(request, networkResponse.clone()); |
||||||
|
} |
||||||
|
return networkResponse; |
||||||
|
}).catch(() => { |
||||||
|
// Ignore background fetch failures
|
||||||
|
}); |
||||||
|
|
||||||
|
// Return cached version immediately if available
|
||||||
|
if (cachedResponse && !isExpired(cachedResponse, strategy.maxAge)) { |
||||||
|
return cachedResponse; |
||||||
|
} |
||||||
|
|
||||||
|
// Wait for network if no cache or expired
|
||||||
|
return fetchPromise; |
||||||
|
} |
||||||
|
|
||||||
|
function isExpired(response, maxAge) { |
||||||
|
if (!maxAge) return false; |
||||||
|
|
||||||
|
const dateHeader = response.headers.get('date'); |
||||||
|
if (!dateHeader) return false; |
||||||
|
|
||||||
|
const responseTime = new Date(dateHeader).getTime(); |
||||||
|
return (Date.now() - responseTime) > maxAge; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleCacheRefresh() { |
||||||
|
try { |
||||||
|
// Clear all caches
|
||||||
|
const cacheNames = await caches.keys(); |
||||||
|
await Promise.all(cacheNames.map(name => caches.delete(name))); |
||||||
|
|
||||||
|
// Reinstall with fresh cache
|
||||||
|
const cache = await caches.open(ASSETS_CACHE); |
||||||
|
await cache.addAll(PRECACHE_ASSETS); |
||||||
|
|
||||||
|
// Notify all clients
|
||||||
|
const clients = await self.clients.matchAll(); |
||||||
|
clients.forEach(client => { |
||||||
|
client.postMessage({ type: 'CACHE_UPDATED' }); |
||||||
|
}); |
||||||
|
|
||||||
|
console.log('Cache refreshed successfully'); |
||||||
|
} catch (error) { |
||||||
|
console.error('Cache refresh failed:', error); |
||||||
|
// Notify clients of error
|
||||||
|
const clients = await self.clients.matchAll(); |
||||||
|
clients.forEach(client => { |
||||||
|
client.postMessage({ type: 'CACHE_ERROR', error: error.message }); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function handleCacheStatus(event) { |
||||||
|
try { |
||||||
|
const cacheNames = await caches.keys(); |
||||||
|
const status = {}; |
||||||
|
|
||||||
|
for (const cacheName of cacheNames) { |
||||||
|
const cache = await caches.open(cacheName); |
||||||
|
const keys = await cache.keys(); |
||||||
|
status[cacheName] = keys.length; |
||||||
|
} |
||||||
|
|
||||||
|
event.ports[0].postMessage({ type: 'CACHE_STATUS', status }); |
||||||
|
} catch (error) { |
||||||
|
event.ports[0].postMessage({ type: 'CACHE_STATUS_ERROR', error: error.message }); |
||||||
|
} |
||||||
|
} |
||||||
|
|||||||
@ -0,0 +1,154 @@ |
|||||||
|
{% extends 'base.html.twig' %} |
||||||
|
|
||||||
|
{% block title %}Cache Management - {{ parent() }}{% endblock %} |
||||||
|
|
||||||
|
{% block stylesheets %} |
||||||
|
{{ parent() }} |
||||||
|
<style> |
||||||
|
.cache-management { |
||||||
|
max-width: 800px; |
||||||
|
margin: 2rem auto; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
.cache-status { |
||||||
|
background: #f8f9fa; |
||||||
|
border: 1px solid #dee2e6; |
||||||
|
border-radius: 0.5rem; |
||||||
|
padding: 1rem; |
||||||
|
margin: 1rem 0; |
||||||
|
font-family: monospace; |
||||||
|
white-space: pre-wrap; |
||||||
|
} |
||||||
|
.cache-actions { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
||||||
|
gap: 1rem; |
||||||
|
margin: 2rem 0; |
||||||
|
} |
||||||
|
.cache-action-btn { |
||||||
|
padding: 0.75rem 1rem; |
||||||
|
border: none; |
||||||
|
border-radius: 0.5rem; |
||||||
|
background: #007bff; |
||||||
|
color: white; |
||||||
|
cursor: pointer; |
||||||
|
transition: background-color 0.2s; |
||||||
|
} |
||||||
|
.cache-action-btn:hover { |
||||||
|
background: #0056b3; |
||||||
|
} |
||||||
|
.cache-action-btn.danger { |
||||||
|
background: #dc3545; |
||||||
|
} |
||||||
|
.cache-action-btn.danger:hover { |
||||||
|
background: #c82333; |
||||||
|
} |
||||||
|
.cache-info { |
||||||
|
background: #d4edda; |
||||||
|
border: 1px solid #c3e6cb; |
||||||
|
border-radius: 0.5rem; |
||||||
|
padding: 1rem; |
||||||
|
margin: 1rem 0; |
||||||
|
} |
||||||
|
</style> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block layout %} |
||||||
|
<div class="cache-management" data-controller="service-worker"> |
||||||
|
<h1>Cache Management</h1> |
||||||
|
|
||||||
|
<div class="cache-info"> |
||||||
|
<h3>Caching Strategy Overview</h3> |
||||||
|
<p>Your newsroom application uses a multi-layered caching strategy:</p> |
||||||
|
<ul> |
||||||
|
<li><strong>Service Worker Cache</strong> - Caches JS, CSS, fonts, and images for offline access</li> |
||||||
|
<li><strong>Browser Cache</strong> - HTTP cache headers for optimal browser caching</li> |
||||||
|
<li><strong>Asset Versioning</strong> - Content-hashed filenames for cache busting</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="cache-status" data-service-worker-target="status"> |
||||||
|
Loading cache status... |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="cache-actions"> |
||||||
|
<button |
||||||
|
class="cache-action-btn" |
||||||
|
data-action="click->service-worker#displayCacheInfoAction" |
||||||
|
> |
||||||
|
Show Cache Status |
||||||
|
</button> |
||||||
|
|
||||||
|
<button |
||||||
|
class="cache-action-btn" |
||||||
|
data-action="click->service-worker#preloadCriticalAssetsAction" |
||||||
|
> |
||||||
|
Preload Critical Assets |
||||||
|
</button> |
||||||
|
|
||||||
|
<button |
||||||
|
class="cache-action-btn" |
||||||
|
data-action="click->service-worker#refreshCacheAction" |
||||||
|
> |
||||||
|
Refresh All Caches |
||||||
|
</button> |
||||||
|
|
||||||
|
<button |
||||||
|
class="cache-action-btn" |
||||||
|
data-action="click->service-worker#clearAssetsCacheAction" |
||||||
|
> |
||||||
|
Clear Assets Cache |
||||||
|
</button> |
||||||
|
|
||||||
|
<button |
||||||
|
class="cache-action-btn" |
||||||
|
data-action="click->service-worker#clearStaticCacheAction" |
||||||
|
> |
||||||
|
Clear Static Cache |
||||||
|
</button> |
||||||
|
|
||||||
|
<button |
||||||
|
class="cache-action-btn danger" |
||||||
|
data-action="click->service-worker#clearCacheAction" |
||||||
|
> |
||||||
|
Clear All Caches |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="cache-info"> |
||||||
|
<h3>Cache Types Explained</h3> |
||||||
|
<dl> |
||||||
|
<dt><strong>Assets Cache (newsroom-assets-v1)</strong></dt> |
||||||
|
<dd>Stores JS files, CSS files, fonts, and images. Uses "cache-first" strategy for fast loading.</dd> |
||||||
|
|
||||||
|
<dt><strong>Static Cache (newsroom-static-v1)</strong></dt> |
||||||
|
<dd>Stores static pages like About, Roadmap, etc. Uses "network-first" strategy for fresh content.</dd> |
||||||
|
|
||||||
|
<dt><strong>Runtime Cache (newsroom-runtime-v1)</strong></dt> |
||||||
|
<dd>Stores API responses and dynamic content with short expiration times.</dd> |
||||||
|
</dl> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="cache-info"> |
||||||
|
<h3>Asset Caching Details</h3> |
||||||
|
<p>The following asset types are automatically cached:</p> |
||||||
|
<ul> |
||||||
|
<li><strong>JavaScript files (.js)</strong> - Cached for 30 days</li> |
||||||
|
<li><strong>CSS files (.css)</strong> - Cached for 30 days</li> |
||||||
|
<li><strong>Font files (.woff2, .woff, .ttf)</strong> - Cached for 1 year</li> |
||||||
|
<li><strong>Images (.png, .jpg, .svg, .ico)</strong> - Cached for 30 days</li> |
||||||
|
<li><strong>Static pages</strong> - Cached for 1 day with network-first strategy</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="cache-info"> |
||||||
|
<h3>Performance Benefits</h3> |
||||||
|
<ul> |
||||||
|
<li>Faster page loads after first visit</li> |
||||||
|
<li>Reduced bandwidth usage</li> |
||||||
|
<li>Better offline experience</li> |
||||||
|
<li>Automatic cache invalidation when assets change</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
Loading…
Reference in new issue