///
///
///
///
import { base, build, files, version } from '$service-worker'
// Bump when a shipped build may have let a script write into the cache (an
// XSS fix, say). Files are only carried over from caches of the same epoch, so
// a bump makes every returning visitor download the whole app again.
const SECURITY_EPOCH = 1
const CACHE_PREFIX = `cache-e${SECURITY_EPOCH}-`
// Create a unique cache name for this deployment
const CACHE = `${CACHE_PREFIX}${version}`
const ASSETS = [
...build, // the app itself
...files, // everything in `static`
]
const ASSET_SET = new Set(ASSETS)
// Vite content-hashes everything under `_app/immutable`, so a path there names
// the same bytes in every deployment that has it. `static/` files keep their
// names across changes and are always fetched fresh.
const IMMUTABLE = new Set(
build.filter((path) => path.includes('/_app/immutable/')),
)
/**
* Copies into `cache` every immutable asset an earlier deployment's cache
* already holds, and returns the assets still to download. `version` changes
* on every deploy, so without this each deploy made every returning visitor
* re-download the whole app, including the files that did not change.
*
* Only immutable paths are carried over: an old cache may still hold a page
* the leaking runtime cache stored, and that must never cross into this one.
* Only successful same-origin responses are, and only from caches of this
* security epoch. A lookup that fails just counts as a miss.
*/
async function reusePreviousAssets(cache: Cache): Promise {
const previous: Cache[] = []
try {
for (const key of await caches.keys()) {
if (key !== CACHE && key.startsWith(CACHE_PREFIX)) {
previous.push(await caches.open(key))
}
}
} catch (err) {
console.warn('[sw] could not read previous caches', err)
}
const missing = await Promise.all(
ASSETS.map(async (asset) => {
if (!IMMUTABLE.has(asset)) return asset
try {
for (const old of previous) {
const hit = await old.match(asset)
if (hit?.ok && hit.type === 'basic') {
await cache.put(asset, hit)
return null
}
}
} catch (err) {
// Fall through to the network.
console.warn('[sw] could not reuse cached asset', asset, err)
}
return asset
}),
)
return missing.filter((asset) => asset !== null)
}
/**
* Chromium's static routing (`InstallEvent.addRoutes`): requests this worker
* only ever declines go to the network without starting the worker at all.
* Otherwise every page load and data request first waited for a possibly
* cold worker, just to be told no. The rules restate what the fetch handler
* below declines; browsers without the API keep using the handler.
*/
interface RoutingInstallEvent extends ExtendableEvent {
addRoutes?: (rules: readonly object[]) => Promise
}
function networkOnlyRoutes(): object[] {
const rules: object[] = [
{ condition: { requestMode: 'navigate' }, source: 'network' },
]
if (typeof URLPattern === 'function') {
for (const pathname of [`${base}/api/*`, '*/__data.json']) {
rules.push({
condition: { urlPattern: new URLPattern({ pathname }) },
source: 'network',
})
}
}
return rules
}
self.addEventListener('install', (event) => {
console.info('[i] Installing service worker')
// Create a new cache and add all files to it
async function addFilesToCache() {
const cache = await caches.open(CACHE)
const missing = await reusePreviousAssets(cache)
try {
await cache.addAll(missing)
} catch (err) {
// `addAll` is all-or-nothing, so none of the missing assets were added;
// only the ones reused from earlier caches are in this one. The throw is
// the point — it fails the install so the browser discards this worker
// instead of activating one with a half-filled precache — but a bare
// rejection surfaces nowhere, and this is the failure that explains a
// deploy where every asset silently misses.
console.error('[sw] precache failed', {
cache: CACHE,
requested: missing.length,
reused: ASSETS.length - missing.length,
err,
})
throw err
}
}
event.waitUntil(addFilesToCache())
// Registered after the precache, and guarded, so that nothing going wrong
// here — `new URLPattern` throwing, say — can keep the precache from running.
try {
const routing = event as RoutingInstallEvent
if (typeof routing.addRoutes === 'function') {
// Best effort: an older implementation that rejects a rule must not fail
// the install, which the handler covers either way.
event.waitUntil(
routing
.addRoutes(networkOnlyRoutes())
.catch((err: unknown) =>
console.warn('[sw] static routes not registered', err),
),
)
}
} catch (err) {
console.warn('[sw] static routes not registered', err)
}
// Skip the wait: the previous worker is the leaking one, and every moment it
// stays in control is a moment it can replay its runtime cache. The usual
// objection — an open page asking the new worker for chunks the old build
// named, which it does not have — does not apply here, because anything
// outside ASSETS is declined to the network rather than answered from cache.
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
console.info('[i] Activating service worker')
// Remove previous cached data from disk
async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key === CACHE) continue
try {
await caches.delete(key)
} catch (err) {
// One undeletable cache must not abort the purge or the claim; the
// rest still go, and nothing is served from them either way.
console.warn('[sw] could not delete old cache', key, err)
}
}
}
// Claim strictly after the purge. Claiming first would put the already-open
// tabs under a worker whose old caches are still on disk; ordering it this
// way means the moment they are ours, the previous deployment's stored pages
// are already gone. Without the claim they keep talking to the leaking
// worker until every last tab is closed.
event.waitUntil(deleteOldCaches().then(() => self.clients.claim()))
})
self.addEventListener('fetch', (e) => {
const event = e as FetchEvent
// ignore POST requests etc
if (event.request.method !== 'GET') return
const url = new URL(event.request.url)
// Cross-origin resources (image proxy host, PDS video) are left to the
// browser. Intercepting them would re-issue the fetch under the policy on
// this script — which is whatever the edge served it with, not the page's
// CSP — and the service worker has no business caching them anyway.
if (url.origin !== self.location.origin) return
// The precached assets are the only thing this worker answers. Everything
// else on this origin is a document, a `__data.json`, an /api/ call, or one
// of Kit's own `/_app/version.json` and `env.js` — either session-bearing or
// not worth storing. A stored copy of the session-bearing ones, replayed
// offline, hands the previous user's session to the next person on the
// device. That replay was the leak; the fix is to store none of it. Nothing
// legitimate is lost: `build` is content-hashed and `static/` is copied into
// `files`, so both are already precached by `install`.
//
// Declining (returning without `respondWith`) rather than proxying leaves the
// request exactly as the browser would have made it — same credentials, same
// redirect and cache semantics — instead of re-issuing it from here.
if (!ASSET_SET.has(url.pathname)) return
event.respondWith(
(async () => {
try {
// Matched by pathname, which is how `install` keyed them. A miss means
// the browser evicted the precache under storage pressure — a failed
// install discards the worker outright, so it can never be a partial
// one.
const cache = await caches.open(CACHE)
const cached = await cache.match(url.pathname)
if (cached) return cached
} catch (err) {
// Private browsing and some enterprise policies reject `caches.open`
// outright. `respondWith` has already committed us to answering, so an
// unhandled rejection here is a broken page rather than a degraded one.
// Only the lookup is guarded: a network failure below is the browser's
// own error to report, exactly as it would be without this worker.
console.warn(
'[sw] precache lookup failed, falling back to network',
url.pathname,
err,
)
}
return fetch(event.request)
})(),
)
})