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.

src/sw.ts

289 lines · 10.7 KB · TypeScript

/**
 * Service worker snippet builders + registration helpers with zombie protections.
 * Versioned cache names, old-cache cleanup, careful skipWaiting/clients.claim,
 * and optional unregister of stale registrations outside the app scope.
 */

export type ServiceWorkerOptions = {
	/** Versioned Cache Storage name, e.g. `my-app-v3`. */
	cacheName: string
	/** Paths under app base to precache (home, about, app.js, manifest, icons). */
	precachePaths?: string[]
	/** HTML routes treated as app-shell (default `/` and `/about`). */
	htmlPaths?: string[]
	/** Offline HTML title/body. */
	offlineTitle?: string
	offlineBody?: string
	/** When true, install calls skipWaiting immediately (default true). */
	skipWaitingOnInstall?: boolean
	/** When true, activate calls clients.claim (default true). */
	claimClients?: boolean
}

function escapeJs(value: string) {
	return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
}

/**
 * Build a same-origin service worker script with zombie protections.
 * Serve as `/sw.js` from the package app with `Service-Worker-Allowed` for the mount.
 *
 * @example
 * import { renderServiceWorker } from 'kody:@kentcdodds/package-app-kit/sw'
 * const body = renderServiceWorker({ cacheName: 'demo-v1' })
 */
export function renderServiceWorker(options: ServiceWorkerOptions): string {
	const cacheName = options.cacheName
	const precache =
		options.precachePaths ??
		['/', '/about', '/app.js', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png']
	const htmlPaths = options.htmlPaths ?? ['/', '/about']
	const offlineTitle = options.offlineTitle ?? 'Offline'
	const offlineBody =
		options.offlineBody ?? 'Re-open this app from Kody when you are back online.'
	const skipWaitingOnInstall = options.skipWaitingOnInstall !== false
	const claimClients = options.claimClients !== false

	const installTail = skipWaitingOnInstall
		? ".then(function () { return self.skipWaiting(); })"
		: ''
	const activateTail = claimClients
		? ".then(function () { return self.clients.claim(); })"
		: ''

	return [
		'"use strict";',
		`var CACHE = ${JSON.stringify(cacheName)};`,
		"var BASE = self.location.pathname.replace(/\\/sw\\.js$/, '');",
		`var PRECACHE = ${JSON.stringify(precache)}.map(function (p) {`,
		"  if (p === '/') return BASE + '/';",
		"  if (p.charAt(0) === '/') return BASE + p;",
		'  return BASE + "/" + p;',
		'});',
		'PRECACHE.push(BASE);',
		'PRECACHE.push(BASE + "/");',
		'function sameOrigin(url) { return url.origin === self.location.origin; }',
		"function isApi(url) { 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 cacheableResponse(response) {',
		'  return Boolean(response && response.ok && response.type !== "opaque" && response.type !== "error");',
		'}',
		`var HTML_PATHS = ${JSON.stringify(htmlPaths)};`,
		'function relativePath(url) {',
		'  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) { return HTML_PATHS.indexOf(relativePath(url)) !== -1; }',
		'function offlineHtml() {',
		'  return new Response(',
		`    '<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeJs(offlineTitle)}</title><body style="font-family:system-ui;padding:24px"><h1>${escapeJs(offlineTitle)}</h1><p>${escapeJs(offlineBody)}</p></body>',`,
		"    { status: 503, headers: { 'content-type': 'text/html; charset=utf-8' } }",
		'  );',
		'}',
		'function cacheFirst(request) {',
		'  return caches.match(request).then(function (cached) {',
		'    if (cached && cached.ok) return cached;',
		'    return fetch(request).then(function (response) {',
		'      if (cacheableResponse(response)) {',
		'        var copy = response.clone();',
		'        caches.open(CACHE).then(function (cache) { cache.put(request, copy); });',
		'      }',
		'      return response;',
		'    });',
		'  });',
		'}',
		'function networkFirstValid(request, fetchInit) {',
		'  var req = fetchInit ? new Request(request, fetchInit) : request;',
		'  return fetch(req).then(function (response) {',
		'    if (cacheableResponse(response) && request.method === "GET") {',
		'      var copy = response.clone();',
		'      caches.open(CACHE).then(function (cache) { cache.put(request, copy); });',
		'      return response;',
		'    }',
		'    return caches.match(request).then(function (cached) {',
		'      if (cached && cached.ok) return cached;',
		'      return response;',
		'    });',
		'  }).catch(function () {',
		'    return caches.match(request).then(function (cached) {',
		'      return (cached && cached.ok) ? cached : Response.error();',
		'    });',
		'  });',
		'}',
		'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 staleWhileRevalidate(request) {',
		'  return caches.open(CACHE).then(function (cache) {',
		'    return matchShell(cache, request).then(function (cached) {',
		'      var network = fetch(request).then(function (response) {',
		"        if (cacheableResponse(response) && request.method === 'GET') {",
		'          cache.put(request, response.clone());',
		'          var url = new URL(request.url);',
		'          if (isHtmlPage(url)) {',
		"            var sibling = url.pathname.endsWith('/')",
		"              ? url.origin + url.pathname.replace(/\\/$/, '') + url.search",
		"              : url.origin + url.pathname + '/' + url.search;",
		'            cache.put(sibling, response.clone());',
		'          }',
		'        }',
		'        return response;',
		'      }).catch(function () { return cached || offlineHtml(); });',
		'      return cached || network;',
		'    });',
		'  });',
		'}',
		"self.addEventListener('install', function (event) {",
		'  event.waitUntil(',
		'    caches.open(CACHE).then(function (cache) {',
		'      return Promise.all(PRECACHE.map(function (url) {',
		'        return cache.add(url).catch(function () { return null; });',
		'      }));',
		`    })${installTail}`,
		'  );',
		'});',
		"self.addEventListener('message', function (event) {",
		"  if (event.data === 'SKIP_WAITING') self.skipWaiting();",
		'});',
		"self.addEventListener('activate', function (event) {",
		'  event.waitUntil(',
		'    caches.keys().then(function (keys) {',
		'      return Promise.all(keys.filter(function (key) { return key !== CACHE; }).map(function (key) {',
		'        return caches.delete(key);',
		'      }));',
		`    })${activateTail}`,
		'  );',
		'});',
		"self.addEventListener('fetch', function (event) {",
		'  var request = event.request;',
		"  if (request.method !== 'GET') return;",
		'  var url = new URL(request.url);',
		'  if (!sameOrigin(url) || isApi(url)) return;',
		'  if (isManifest(url)) {',
		'    // Network-first, credentialed, never cache 403/opaque/error. Browser default',
		'    // manifest fetch is CORS without cookies and can 403 on session-gated MIME types.',
		"    event.respondWith(networkFirstValid(request, { credentials: 'same-origin', cache: 'no-store' }));",
		'    return;',
		'  }',
		"  if (request.mode === 'navigate' || isHtmlPage(url)) {",
		'    event.respondWith(staleWhileRevalidate(request));',
		'    return;',
		'  }',
		'  if (isStatic(url)) {',
		'    event.respondWith(cacheFirst(request));',
		'    return;',
		'  }',
		'  event.respondWith(staleWhileRevalidate(request));',
		'});',
		'',
	].join('\n')
}


export type WebManifestDisplay =
	| 'fullscreen'
	| 'standalone'
	| 'minimal-ui'
	| 'browser'
	| (string & {})

export type WebManifestIcon = {
	src: string
	sizes: string
	type?: string
	purpose?: string
}

export type BuildWebManifestInput = {
	appBasePath: string
	name: string
	shortName?: string
	description?: string
	themeColor?: string
	backgroundColor?: string
	/**
	 * Manifest `display`. Default `standalone`.
	 * Gesture-driven apps (e.g. card swipe) often want `fullscreen` so iOS Safari
	 * edge-swipe-back and pull-to-refresh do not fight in-app gestures. iOS Safari
	 * ignores `user-scalable=no`; PWA `standalone` / `fullscreen` is the reliable fix.
	 */
	display?: WebManifestDisplay
	/** Manifest `orientation` (default `any`). */
	orientation?: string
	/** Manifest `categories` (optional). */
	categories?: string[]
	/** Manifest `display_override` list. Defaults from `display`. */
	displayOverride?: string[]
	icons?: WebManifestIcon[]
	/**
	 * Extra Web App Manifest members merged last (id/name/start_url/scope still win
	 * unless you override them here intentionally).
	 */
	extras?: Record<string, unknown>
}

/**
 * Build a web app manifest object using packageContext mount paths.
 * Include 192 + 512 PNG icons (`defaultManifestIcons` / `kitServedManifestIcons`) for Chromium / Brave URL-bar Install.
 *
 * Pass `display: "fullscreen"` when Safari chrome gestures must not steal touch
 * (edge-swipe back, pull-to-refresh). Default remains `standalone`.
 *
 * @example
 * import { buildWebManifest } from 'kody:@kentcdodds/package-app-kit/sw'
 * const manifest = buildWebManifest({
 *   appBasePath: '/packages/demo',
 *   name: 'Demo',
 *   display: 'fullscreen',
 *   orientation: 'portrait',
 *   categories: ['utilities'],
 * })
 */
export function buildWebManifest(input: BuildWebManifestInput) {
	const base = input.appBasePath.replace(/\/$/, '')
	const display = input.display ?? 'standalone'
	const displayOverride =
		input.displayOverride ??
		(display === 'fullscreen'
			? ['fullscreen', 'standalone', 'minimal-ui']
			: display === 'minimal-ui'
				? ['minimal-ui', 'standalone']
				: ['standalone', 'minimal-ui'])
	const body: Record<string, unknown> = {
		id: `${base}/`,
		name: input.name,
		short_name: input.shortName || input.name,
		description: input.description || '',
		lang: 'en',
		display,
		display_override: displayOverride,
		start_url: `${base}/`,
		scope: `${base}/`,
		background_color: input.backgroundColor || '#fafafa',
		theme_color: input.themeColor || '#fafafa',
		orientation: input.orientation ?? 'any',
		icons: input.icons || [],
	}
	if (input.categories?.length) body.categories = input.categories
	if (input.extras) Object.assign(body, input.extras)
	return body
}


/** Primary callable export for this subpath. */
export default renderServiceWorker