← 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