Skip to content

Service worker untuk menyimpan aset game dalam cache

Service worker memungkinkan Anda mengontrol cara aset disimpan dalam cache dan disajikan. Untuk game, ini berarti pemuatan seketika pada kunjungan berikutnya dan dukungan offline.

1) Dasar-dasar service worker

Buat sw.js di direktori root Anda:

js
// 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
})

Daftarkan:

js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(reg => console.log('SW registered'))
    .catch(err => console.error('SW registration failed:', err))
}

2) Strategi cache-first (terbaik untuk aset game)

Muat dari cache, dan hanya ambil jika belum disimpan dalam cache:

js
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) Pra-cache aset penting

Event install sangat cocok untuk melakukan pra-cache:

js
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 untuk konten dinamis

Gunakan network-first untuk hal-hal yang berubah (papan peringkat, data pemain):

js
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

Segera kembalikan versi yang tersimpan dalam cache, lalu perbarui di latar belakang:

js
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) Pembuatan versi dan pembersihan cache

Bersihkan cache lama saat aktivasi:

js
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) Pisahkan cache berdasarkan jenis

Atur cache agar lebih mudah dikelola:

js
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 request untuk audio/video

Satu hal yang perlu diketahui sejak awal: Cache API tidak akan menyimpan respons parsial. Jika respons 206 Partial Content sampai ke cache.put(), metode tersebut akan memunculkan TypeError: Failed to execute 'put' on 'Cache': Partial response (status code 206) is unsupported. Jadi, pola untuk media yang disimpan dalam cache selalu sama: lakukan pra-cache (atau ambil) file lengkap sebagai respons 200 biasa, lalu buat sendiri respons 206 dengan memotong byte yang tersimpan dalam cache setiap kali ada permintaan dengan header Range. Jika tidak ingin membuatnya sendiri, plugin range-requests dari Workbox melakukan pemotongan tersebut untuk Anda ketika dipasangkan dengan strategi CacheFirst.

Tangani range request dengan benar (diperlukan untuk berpindah posisi dalam audio):

js
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) Notifikasi pembaruan

Beri tahu pemain saat versi baru siap:

js
// 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>Versi baru tersedia!</span>
    <button onclick="location.reload()">Perbarui</button>
  `
  document.body.appendChild(banner)
}

10) Service worker game yang lengkap

js
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
}

Terkait

Sumber Eksternal