Service Worker fürs Caching von Spiel-Assets
Mit Service Workern steuerst du, wie Assets gecacht und ausgeliefert werden. Für Spiele heißt das sofortiges Laden bei Wiederbesuchen und Offline-Unterstützung.
1) Grundlagen zum Service Worker
Leg sw.js in deinem Wurzelverzeichnis an:
// sw.js
self.addEventListener('install', (event) => {
console.log('Service worker installing')
})
self.addEventListener('activate', (event) => {
console.log('Service worker activated')
})
self.addEventListener('fetch', (event) => {
// Intercept all network requests
})Registriere ihn:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW registered'))
.catch(err => console.error('SW registration failed:', err))
}2) Cache-first (am besten für Spiel-Assets)
Aus dem Cache laden und nur holen, was nicht gecacht ist:
const CACHE_NAME = 'game-assets-v1'
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) {
return cached
}
return fetch(event.request).then((response) => {
// Don't cache non-ok responses or non-GET requests
if (!response.ok || event.request.method !== 'GET') {
return response
}
// Clone response for caching
const clone = response.clone()
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone)
})
return response
})
})
)
})3) Kritische Assets vorab cachen
Das Install-Event eignet sich perfekt fürs Vorab-Caching:
const CACHE_NAME = 'game-v1'
const PRECACHE = [
'/',
'/index.html',
'/game.js',
'/style.css',
'/assets/sprites.png',
'/assets/tileset.png',
'/assets/player.png',
]
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(PRECACHE))
.then(() => self.skipWaiting())
)
})4) Network-first für dynamische Inhalte
Nimm Network-first für Dinge, die sich ändern (Bestenlisten, Spielerdaten):
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// API calls: network first
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request))
return
}
// Assets: cache first
event.respondWith(cacheFirst(event.request))
})
async function networkFirst(request) {
try {
const response = await fetch(request)
const cache = await caches.open('api-cache')
cache.put(request, response.clone())
return response
} catch {
return caches.match(request)
}
}
async function cacheFirst(request) {
const cached = await caches.match(request)
if (cached) return cached
const response = await fetch(request)
const cache = await caches.open(CACHE_NAME)
cache.put(request, response.clone())
return response
}5) Stale-while-revalidate
Sofort aus dem Cache antworten und im Hintergrund aktualisieren:
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME)
const cached = await cache.match(request)
const fetchPromise = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone())
}
return response
})
return cached || fetchPromise
}6) Cache-Versionierung und Aufräumen
Alte Caches beim Aktivieren aufräumen:
const CACHE_VERSION = 'v2'
const CACHE_NAME = `game-${CACHE_VERSION}`
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter(key => key.startsWith('game-') && key !== CACHE_NAME)
.map(key => {
console.log('Deleting old cache:', key)
return caches.delete(key)
})
)
}).then(() => self.clients.claim())
)
})7) Getrennte Caches nach Typ
Caches ordnen macht sie leichter handhabbar:
const CACHES = {
static: 'static-v1', // HTML, JS, CSS
images: 'images-v1', // Sprites, textures
audio: 'audio-v1', // Music, SFX
api: 'api-v1', // API responses
}
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
if (url.pathname.match(/\.(png|jpg|webp)$/)) {
event.respondWith(cacheFirst(event.request, CACHES.images))
} else if (url.pathname.match(/\.(mp3|ogg|wav)$/)) {
event.respondWith(cacheFirst(event.request, CACHES.audio))
} else if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request, CACHES.api))
} else {
event.respondWith(cacheFirst(event.request, CACHES.static))
}
})8) Range-Requests für Audio und Video
Eines vorab: Die Cache-API speichert keine Teilantworten. Erreicht jemals eine 206-Partial-Content-Antwort cache.put(), wirft sie TypeError: Failed to execute 'put' on 'Cache': Partial response (status code 206) is unsupported. Das Muster für gecachte Medien ist deshalb immer dasselbe: Die vollständige Datei als normale 200-Antwort vorab cachen (oder holen) und die 206 dann selbst bauen, indem du die gecachten Bytes schneidest, sobald eine Anfrage mit Range-Header ankommt. Wer das nicht von Hand schreiben will, bekommt genau dieses Schneiden vom range-requests-Plugin von Workbox, kombiniert mit einer CacheFirst-Strategie.
Range-Requests sauber behandeln (nötig fürs Spulen in Audio):
self.addEventListener('fetch', (event) => {
if (event.request.headers.has('range')) {
event.respondWith(handleRangeRequest(event.request))
return
}
// ... normal handling
})
async function handleRangeRequest(request) {
const cache = await caches.open(CACHES.audio)
// Match the full resource, ignoring the Range header (which would never match a cached full file)
let cached = await cache.match(request.url)
if (!cached) {
// Fetch the FULL file (string URL drops the Range header) and cache it as a 200
cached = await fetch(request.url)
if (cached.ok) cache.put(request.url, cached.clone()) // cache.put rejects 206, so only store full 200s
}
// Build a 206 Partial Content response from the cached full body
const rangeHeader = request.headers.get('range') // e.g. "bytes=200-1000"
const buffer = await cached.arrayBuffer()
const total = buffer.byteLength
const [startStr, endStr] = rangeHeader.replace(/bytes=/, '').split('-')
const start = Number(startStr)
const end = endStr ? Number(endStr) : total - 1
const slice = buffer.slice(start, end + 1)
return new Response(slice, {
status: 206,
statusText: 'Partial Content',
headers: {
'Content-Type': cached.headers.get('Content-Type') || 'application/octet-stream',
'Content-Range': `bytes ${start}-${end}/${total}`,
'Content-Length': String(slice.byteLength),
'Accept-Ranges': 'bytes',
},
})
}9) Update-Hinweis
Sag Spielern, wenn eine neue Version bereitsteht:
// In main app
let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) return
refreshing = true
showUpdateBanner()
})
function showUpdateBanner() {
const banner = document.createElement('div')
banner.className = 'update-banner'
banner.innerHTML = `
<span>New version available!</span>
<button onclick="location.reload()">Update</button>
`
document.body.appendChild(banner)
}10) Ein vollständiger Service Worker fürs Spiel
const VERSION = 'v1'
const STATIC_CACHE = `static-${VERSION}`
const ASSET_CACHE = `assets-${VERSION}`
const PRECACHE_URLS = [
'/',
'/index.html',
'/game.js',
'/style.css',
]
// Install: precache critical resources
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE)
.then(cache => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
)
})
// Activate: clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys
.filter(key => !key.endsWith(VERSION))
.map(key => caches.delete(key))
)).then(() => self.clients.claim())
)
})
// Fetch: route requests to appropriate strategy
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// Same-origin only
if (url.origin !== location.origin) {
return
}
// API: network first with cache fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request, 'api-cache'))
return
}
// Assets: cache first
if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirst(event.request, ASSET_CACHE))
return
}
// HTML/JS/CSS: stale while revalidate
event.respondWith(staleWhileRevalidate(event.request, STATIC_CACHE))
})
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request)
if (cached) return cached
try {
const response = await fetch(request)
if (response.ok) {
const cache = await caches.open(cacheName)
cache.put(request, response.clone())
}
return response
} catch {
return new Response('Offline', { status: 503 })
}
}
async function networkFirst(request, cacheName) {
try {
const response = await fetch(request)
if (response.ok) {
const cache = await caches.open(cacheName)
cache.put(request, response.clone())
}
return response
} catch {
return caches.match(request) || new Response('Offline', { status: 503 })
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName)
const cached = await cache.match(request)
const fetchPromise = fetch(request).then(response => {
if (response.ok) {
cache.put(request, response.clone())
}
return response
}).catch(() => cached)
return cached || fetchPromise
}Verwandt
- PWA für Offline-Spiele
- Streaming von Assets
- Ein Web-Spiel ausliefern, das schnell lädt
- Web-Audio-API für Spiele — Audio-Assets fürs Offline-Spielen cachen
- Spielstände in IndexedDB — Spielzustand neben gecachten Assets sichern
Externe Ressourcen
- MDN: Service Worker API — vollständige API-Referenz
- MDN: Cache API — Referenz zur Cache-Schnittstelle
- web.dev: Service workers — Caching-Strategien und Muster
- Workbox — Googles Bibliothek für Service-Worker-Tooling