Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@kentcdodds/package-app-kit

Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.

starter-remix/public/sw.js

300 lines · 8.2 KB · JavaScript
/**
 * Package-app service worker shipped via kody.app.assets (`public/sw.js`).
 * Precache the fingerprinted client from GET <assetBase>/__version.json — no hashes in source.
 * Brand revision: kody-racer list/PWA mark (v4) — image/* cache guard + handoff-aware precache.
 */
'use strict'

var HTML_PATHS = ['/', '/about', '/notes']

function appBase() {
	try {
		return new URL(self.registration.scope).pathname.replace(/\/$/, '')
	} catch (e) {
		return self.location.pathname
			.replace(/\/_assets\/sw\.js$/, '')
			.replace(/\/sw\.js$/, '')
	}
}

function assetBase(base) {
	return base + '/_assets'
}

function sameOrigin(url) {
	return url.origin === self.location.origin
}

function isApi(url, base) {
	return url.pathname.indexOf(base + '/api/') === 0
}

function isManifest(url) {
	return /manifest\.webmanifest$/.test(url.pathname)
}

function isStatic(url) {
	return !isManifest(url) && /\.(png|svg|ico|webp|jpg|jpeg)$/.test(url.pathname)
}

function isClientAsset(url) {
	return /\/_assets\/client\.[^/]+\.js$/.test(url.pathname)
}

function isImagePath(url) {
	return /\.(png|svg|ico|webp|jpg|jpeg)$/.test(url.pathname)
}

function contentType(response) {
	return String(response && response.headers && response.headers.get('content-type') || '')
		.toLowerCase()
		.split(';')[0]
		.trim()
}

function isImageContentType(response) {
	return contentType(response).indexOf('image/') === 0
}

function isHandoffRequired(response) {
	if (!response) return false
	if (response.status === 403) return true
	try {
		return String(response.headers.get('x-kody-handoff') || '').toLowerCase() === 'required'
	} catch (e) {
		return false
	}
}

function relativePath(url, base) {
	var path = url.pathname
	if (path === base || path === base + '/') return '/'
	if (path.indexOf(base + '/') === 0) {
		var rel = path.slice(base.length)
		if (rel.length > 1 && rel.charAt(rel.length - 1) === '/') rel = rel.slice(0, -1)
		return rel || '/'
	}
	return path
}

function isHtmlPage(url, base) {
	return HTML_PATHS.indexOf(relativePath(url, base)) !== -1
}

function cacheableResponse(response) {
	return Boolean(response && response.ok && response.type !== 'opaque' && response.type !== 'error')
}

/** Never cache 403 handoff HTML (or non-image bodies) as static image assets. */
function cacheableStaticResponse(url, response) {
	if (!cacheableResponse(response) || isHandoffRequired(response)) return false
	if (isImagePath(url)) return isImageContentType(response)
	return true
}

function offlineHtml() {
	return new Response(
		'<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><title>Offline</title><body style="font-family:system-ui;padding:24px"><h1>Offline</h1><p>Re-open this app from Kody when you are back online.</p></body>',
		{ status: 503, headers: { 'content-type': 'text/html; charset=utf-8' } },
	)
}

function matchShell(cache, request) {
	return cache.match(request).then(function (cached) {
		if (cached) return cached
		var url = new URL(request.url)
		var alt = url.pathname.endsWith('/')
			? url.origin + url.pathname.replace(/\/$/, '') + url.search
			: url.origin + url.pathname + '/' + url.search
		return cache.match(alt)
	})
}

function activeCacheName(base) {
	return fetch(assetBase(base) + '/__version.json', {
		cache: 'no-store',
		credentials: 'same-origin',
	})
		.then(function (res) {
			if (!res.ok) throw new Error('version ' + res.status)
			return res.json()
		})
		.then(function (version) {
			return {
				name: version.publishedCommit ? 'pak-' + version.publishedCommit : 'pak-fallback',
				version: version,
			}
		})
		.catch(function () {
			return { name: 'pak-fallback', version: null }
		})
}

function openActiveCache(base) {
	return activeCacheName(base).then(function (info) {
		return caches.open(info.name).then(function (cache) {
			return { cache: cache, info: info }
		})
	})
}

/** Precache brand/icons only when the network returns a real image/* body. */
function precacheImage(cache, url) {
	return fetch(url, { credentials: 'same-origin', cache: 'no-store' }).then(function (response) {
		if (!cacheableStaticResponse(new URL(url, self.location.origin), response)) {
			console.warn('[pak-sw] skip precache (not image/* or handoff):', url, response && response.status)
			return false
		}
		return cache.put(url, response).then(function () {
			return true
		})
	})
}

function precacheUrl(cache, url) {
	if (isImagePath(new URL(url, self.location.origin))) {
		return precacheImage(cache, url)
	}
	return cache.add(url).then(function () {
		return true
	}).catch(function () {
		return false
	})
}

self.addEventListener('install', function (event) {
	event.waitUntil(
		openActiveCache(appBase()).then(function (opened) {
			var base = appBase()
			var version = opened.info.version
			var urls = [
				base + '/',
				base + '/about',
				base + '/notes',
				base + '/manifest.webmanifest',
				base + '/icons/icon-192.png?v=4',
				base + '/icons/icon-512.png?v=4',
				assetBase(base) + '/sw.js',
				assetBase(base) + '/styles.css',
			]
			if (version && version.clientModuleUrl) urls.push(version.clientModuleUrl)
			return Promise.all(
				urls.map(function (url) {
					return precacheUrl(opened.cache, url)
				}),
			).then(function () {
				return self.skipWaiting()
			})
		}),
	)
})

self.addEventListener('message', function (event) {
	if (event.data === 'SKIP_WAITING') self.skipWaiting()
})

self.addEventListener('activate', function (event) {
	event.waitUntil(
		activeCacheName(appBase())
			.then(function (info) {
				return caches.keys().then(function (keys) {
					return Promise.all(
						keys.map(function (key) {
							if (key === info.name) return null
							if (key.indexOf('pak-') === 0) return caches.delete(key)
							if (key.indexOf('package-app-kit-demo-') === 0) return caches.delete(key)
							return null
						}),
					)
				})
			})
			.then(function () {
				return self.clients.claim()
			}),
	)
})

self.addEventListener('fetch', function (event) {
	var request = event.request
	if (request.method !== 'GET') return
	var url = new URL(request.url)
	var base = appBase()
	if (!sameOrigin(url) || isApi(url, base)) return

	if (isManifest(url)) {
		event.respondWith(
			fetch(request, { credentials: 'same-origin', cache: 'no-store' })
				.then(function (response) {
					if (cacheableResponse(response) && !isHandoffRequired(response)) {
						var copy = response.clone()
						openActiveCache(base).then(function (opened) {
							opened.cache.put(request, copy)
						})
					}
					return response
				})
				.catch(function () {
					return caches.match(request).then(function (cached) {
						return cached && cached.ok ? cached : Response.error()
					})
				}),
		)
		return
	}

	if (request.mode === 'navigate' || isHtmlPage(url, base)) {
		event.respondWith(
			openActiveCache(base).then(function (opened) {
				return matchShell(opened.cache, request).then(function (cached) {
					var network = fetch(request, { credentials: 'same-origin' })
						.then(function (response) {
							if (cacheableResponse(response) && !isHandoffRequired(response)) {
								opened.cache.put(request, response.clone())
								var sibling = url.pathname.endsWith('/')
									? url.origin + url.pathname.replace(/\/$/, '') + url.search
									: url.origin + url.pathname + '/' + url.search
								opened.cache.put(sibling, response.clone())
							}
							return response
						})
						.catch(function () {
							return cached || offlineHtml()
						})
					return cached || network
				})
			}),
		)
		return
	}

	if (isStatic(url) || isClientAsset(url)) {
		event.respondWith(
			fetch(request, { credentials: 'same-origin' })
				.then(function (response) {
					if (cacheableStaticResponse(url, response)) {
						var copy = response.clone()
						openActiveCache(base).then(function (opened) {
							opened.cache.put(request, copy)
						})
					}
					return response
				})
				.catch(function () {
					return caches.match(request).then(function (cached) {
						if (!cached || !cached.ok) return Response.error()
						if (isImagePath(url) && !isImageContentType(cached)) return Response.error()
						return cached
					})
				}),
		)
		return
	}

	event.respondWith(
		fetch(request, { credentials: 'same-origin' }).catch(function () {
			return caches.match(request).then(function (cached) {
				return cached || Response.error()
			})
		}),
	)
})