Service workers pour la mise en cache des ressources de jeu
Les service workers vous permettent de contrôler la mise en cache et la distribution des ressources. Pour les jeux, cela signifie des chargements instantanés lors des visites suivantes et une prise en charge hors ligne.
1) Principes de base des service workers
Créez sw.js à la racine de votre projet :
// 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
})Enregistrez-le :
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW registered'))
.catch(err => console.error('SW registration failed:', err))
}2) Stratégie cache-first (idéale pour les ressources de jeu)
Chargez depuis le cache et ne récupérez la ressource sur le réseau que si elle n'est pas en cache :
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) Prémettre en cache les ressources critiques
L'événement d'installation est idéal pour la pré-mise en cache :
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) Stratégie network-first pour le contenu dynamique
Utilisez la stratégie network-first pour les éléments qui changent, comme les classements et les données des joueurs :
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
Renvoyez immédiatement la version en cache et mettez-la à jour en arrière-plan :
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) Gestion des versions et nettoyage du cache
Supprimez les anciens caches lors de l'activation :
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) Séparer les caches par type
Organisez les caches pour mieux les gérer :
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) Requêtes de plage pour l'audio et la vidéo
Une chose à savoir dès le départ : l'API Cache ne stocke pas les réponses partielles. Si une réponse 206 Partial Content parvient à cache.put(), celui-ci lève l'erreur TypeError: Failed to execute 'put' on 'Cache': Partial response (status code 206) is unsupported. Le modèle à suivre pour les médias mis en cache est donc toujours le même : prémettre en cache, ou récupérer, le fichier complet sous la forme d'une réponse 200 normale, puis générer vous-même la réponse 206 en découpant les octets mis en cache lorsqu'une requête arrive avec un en-tête Range. Si vous préférez ne pas l'implémenter vous-même, le plug-in range-requests de Workbox effectue précisément ce découpage lorsqu'il est associé à une stratégie CacheFirst.
Gérez correctement les requêtes de plage, ce qui est nécessaire pour naviguer dans un fichier 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) Notification de mise à jour
Prévenez les joueurs lorsqu'une nouvelle version est prête :
// 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>Une nouvelle version est disponible !</span>
<button onclick="location.reload()">Mettre à jour</button>
`
document.body.appendChild(banner)
}10) Service worker complet pour un jeu
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
}Articles associés
- PWA pour les jeux hors ligne
- Chargement progressif des ressources
- Publier un jeu web qui se charge rapidement
- API Web Audio pour les jeux — mettre en cache les ressources audio pour jouer hors ligne
- Sauvegardes de jeu avec IndexedDB — conserver l'état du jeu avec les ressources mises en cache
Ressources externes
- MDN : API Service Worker — référence complète de l'API
- MDN : API Cache — référence de l'interface Cache
- web.dev : Service workers — stratégies et modèles de mise en cache
- Workbox — bibliothèque de Google proposant des outils pour les service workers