From 2bb12c2563c752b37203a12104109692be13873c Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sun, 5 Jul 2026 20:33:33 -0700 Subject: [PATCH] Add static PWA manifest example Adds a hand-rolled static PWA example that uses the unstable Domstack manifest preview to precache selected static routes and serve an offline fallback. --- README.md | 5 +- examples/pwa/README.md | 62 +++ examples/pwa/package.json | 30 ++ examples/pwa/src/README.md | 79 +++ examples/pwa/src/admin/README.md | 11 + examples/pwa/src/blog/README.md | 13 + examples/pwa/src/blog/first-post/README.md | 9 + examples/pwa/src/docs/README.md | 23 + .../pwa/src/domstack-manifest.settings.js | 22 + examples/pwa/src/global.client.js | 5 + examples/pwa/src/global.css | 176 +++++++ examples/pwa/src/global.vars.js | 6 + examples/pwa/src/legal/README.md | 9 + examples/pwa/src/login/README.md | 20 + examples/pwa/src/manifest.webmanifest | 11 + examples/pwa/src/offline/README.md | 9 + examples/pwa/src/private/README.md | 9 + examples/pwa/src/pwa/cache-policy.js | 63 +++ examples/pwa/src/pwa/runtime.js | 493 ++++++++++++++++++ examples/pwa/src/service-worker.js | 1 + examples/pwa/src/sw/cache.js | 122 +++++ examples/pwa/src/sw/clients.js | 22 + examples/pwa/src/sw/context.js | 32 ++ examples/pwa/src/sw/domstack-manifest.js | 83 +++ examples/pwa/src/sw/events.js | 65 +++ examples/pwa/src/sw/fetch.js | 77 +++ examples/pwa/src/sw/messages.js | 92 ++++ examples/pwa/src/sw/precache.js | 182 +++++++ package.json | 1 + 29 files changed, 1731 insertions(+), 1 deletion(-) create mode 100644 examples/pwa/README.md create mode 100644 examples/pwa/package.json create mode 100644 examples/pwa/src/README.md create mode 100644 examples/pwa/src/admin/README.md create mode 100644 examples/pwa/src/blog/README.md create mode 100644 examples/pwa/src/blog/first-post/README.md create mode 100644 examples/pwa/src/docs/README.md create mode 100644 examples/pwa/src/domstack-manifest.settings.js create mode 100644 examples/pwa/src/global.client.js create mode 100644 examples/pwa/src/global.css create mode 100644 examples/pwa/src/global.vars.js create mode 100644 examples/pwa/src/legal/README.md create mode 100644 examples/pwa/src/login/README.md create mode 100644 examples/pwa/src/manifest.webmanifest create mode 100644 examples/pwa/src/offline/README.md create mode 100644 examples/pwa/src/private/README.md create mode 100644 examples/pwa/src/pwa/cache-policy.js create mode 100644 examples/pwa/src/pwa/runtime.js create mode 100644 examples/pwa/src/service-worker.js create mode 100644 examples/pwa/src/sw/cache.js create mode 100644 examples/pwa/src/sw/clients.js create mode 100644 examples/pwa/src/sw/context.js create mode 100644 examples/pwa/src/sw/domstack-manifest.js create mode 100644 examples/pwa/src/sw/events.js create mode 100644 examples/pwa/src/sw/fetch.js create mode 100644 examples/pwa/src/sw/messages.js create mode 100644 examples/pwa/src/sw/precache.js diff --git a/README.md b/README.md index f081e7d9..fa7a5902 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,8 @@ $ npm --workspace @domstack/basic-example run build Each example is an npm workspace under `examples/*`, so a root `npm install` links the local `@domstack/static` package into example builds. Example packages keep `@domstack/static` declared as `file:../../.` so the local dependency target remains explicit. +The [`examples/pwa`](./examples/pwa) workspace shows a hand-rolled static PWA that consumes the unstable domstack manifest preview directly. + ### Additional examples Here are some additional external examples of larger domstack projects. @@ -1087,7 +1089,8 @@ const html = renderCache.get(page.pageInfo.path) ?? '' Every programmatic build returns a `domstackManifest` object in the build results. The CLI also writes `domstack-manifest.json` into the destination directory by default. Applications can use this manifest as the source of truth for service-worker precaching, deployment metadata, or other static output -integrations. +integrations. See [`examples/pwa`](./examples/pwa) for a hand-rolled static PWA implementation that +consumes the domstack manifest directly. The manifest is a normalized list of files that domstack emitted: diff --git a/examples/pwa/README.md b/examples/pwa/README.md new file mode 100644 index 00000000..e0f232b1 --- /dev/null +++ b/examples/pwa/README.md @@ -0,0 +1,62 @@ +# DOMStack PWA Example + +This example shows a production-style static PWA using Domstack's domstack manifest and first-class service-worker build support. + +> [!WARNING] +> The domstack manifest and first-class service-worker support shown here are unstable preview features. +> The option names, manifest schema, generated outputs, and browser defines may change outside of a major version while the API is validated. +> Pin `@domstack/static` to an exact version before building long-lived integrations on this preview contract. + +It demonstrates: + +- A `service-worker.js` source that Domstack bundles to `/service-worker.js`. +- A `domstack-manifest.settings.js` file that filters the generated domstack manifest. +- A global client runtime that registers the worker, handles update prompts, and disables sticky caches during local watch development. +- Offline precaching for app, docs, legal-style pages, static assets, shared chunks, and the web app manifest. +- Excluding `/blog/**`, `/admin/**`, source maps, metadata files, and pages with `precache: false` or `offline: false`. +- Verbose console logging in the window runtime and service worker so the lifecycle is easy to inspect while learning or testing. + +## Running + +```bash +cd examples/pwa +npm install +npm run serve +``` + +Service workers require a secure origin. `localhost` is allowed by browsers, and `npm run serve` +runs a manifest-enabled build so the PWA path works there. It also serves without live-reload HTML +injection so fetched files match the domstack manifest revisions. Wait until the UI says `Offline +cache current` before testing an offline refresh. Use `npm run watch` for development without a +sticky service worker cache. To clear all example workers and caches: + +```txt +/?reset-sw=1 +``` + +## Files + +```txt +src/ + global.client.js # Registers the service worker through pwa/runtime.js + global.css # Site styles + global.vars.js # Shared page variables + domstack-manifest.settings.js # Filters the written domstack manifest + manifest.webmanifest # Web app manifest copied as a static asset + service-worker.js # Site service worker entry + pwa/ + cache-policy.js # Shared domstack manifest filtering and constants + runtime.js # Browser registration/update/recovery behavior + sw/ + *.js # Service-worker install, update, cache, and fetch helpers +``` + +## Production Pattern + +The worker fetches `/domstack-manifest.json` during installation and uses the revisioned URLs in that file as its cache plan. The manifest is generated after Domstack reconciles pages, bundles, chunks, worker output, copied static assets, and templates. Application policy stays in app code: + +- `domstack-manifest.settings.js` decides what can ever enter the manifest. +- `service-worker.js` decides how to install, activate, update, and serve cached responses. +- `global.client.js` decides when to register, prompt, apply updates, or recover from a bad cache. + +Watch mode does not write the domstack manifest, so use `npm run serve` when testing the offline lifecycle. diff --git a/examples/pwa/package.json b/examples/pwa/package.json new file mode 100644 index 00000000..137105c6 --- /dev/null +++ b/examples/pwa/package.json @@ -0,0 +1,30 @@ +{ + "name": "@domstack/pwa-example", + "version": "0.0.0", + "description": "DOMStack PWA offline cache example", + "type": "module", + "scripts": { + "start": "npm run serve", + "build": "npm run clean && domstack", + "clean": "rm -rf public && mkdir -p public", + "serve": "npm run clean && domstack --serve", + "watch": "npm run clean && dom --watch" + }, + "keywords": [ + "domstack", + "pwa", + "service-worker", + "offline" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@domstack/static": "file:../../." + }, + "devDependencies": { + "npm-run-all2": "^9.0.0" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" + } +} diff --git a/examples/pwa/src/README.md b/examples/pwa/src/README.md new file mode 100644 index 00000000..77ac7fab --- /dev/null +++ b/examples/pwa/src/README.md @@ -0,0 +1,79 @@ +--- +title: App Shell +--- + +
+
+
+

Static PWA shell

+

This page is built as ordinary static HTML, then the service worker uses Domstack's domstack manifest to cache the static shell for offline launches.

+
+ Pages + Bundles + Static assets + No API cache +
+
+
+
+ Worker + Not registered +
+
+ Cache version + Waiting for domstack manifest +
+
+ Network + Checking +
+
+
+ + + + +
diff --git a/examples/pwa/src/admin/README.md b/examples/pwa/src/admin/README.md new file mode 100644 index 00000000..1be29f24 --- /dev/null +++ b/examples/pwa/src/admin/README.md @@ -0,0 +1,11 @@ +--- +title: Admin +precache: false +offline: false +--- + +# Admin + +This protected route is intentionally excluded from the PWA precache by both path policy and page vars. + +Admin traffic should stay network-only in this example. diff --git a/examples/pwa/src/blog/README.md b/examples/pwa/src/blog/README.md new file mode 100644 index 00000000..ecea6c11 --- /dev/null +++ b/examples/pwa/src/blog/README.md @@ -0,0 +1,13 @@ +--- +title: Blog +precache: false +offline: false +--- + +# Blog + +This route is present in the static site but filtered out of the first PWA cache by `domstack-manifest.settings.js`. + +Its page vars also set `precache: false` and `offline: false`, which lets the cache policy reject it without relying only on path names. + +- [First post](/blog/first-post/) diff --git a/examples/pwa/src/blog/first-post/README.md b/examples/pwa/src/blog/first-post/README.md new file mode 100644 index 00000000..856bfde0 --- /dev/null +++ b/examples/pwa/src/blog/first-post/README.md @@ -0,0 +1,9 @@ +--- +title: First Post +precache: false +offline: false +--- + +# First Post + +Blog content exists in the generated site, but the example cache policy excludes `/blog/**` from the first install cache. diff --git a/examples/pwa/src/docs/README.md b/examples/pwa/src/docs/README.md new file mode 100644 index 00000000..5dd70fc7 --- /dev/null +++ b/examples/pwa/src/docs/README.md @@ -0,0 +1,23 @@ +--- +title: Docs +--- + +# Docs + +This route is intentionally included in the PWA cache. It represents static documentation, legal pages, help pages, or other public content that should remain available after the first online visit. + +The service worker gets this page from the generated domstack manifest instead of a handwritten list. + +## Cache Inputs + +- Page HTML, including `/docs/` +- Global CSS and JavaScript bundles +- Shared chunks +- Static icons and the web app manifest + +## Cache Exclusions + +- `/api/**` requests +- `/admin/**` routes +- `/blog/**` routes +- Source maps and Domstack metadata diff --git a/examples/pwa/src/domstack-manifest.settings.js b/examples/pwa/src/domstack-manifest.settings.js new file mode 100644 index 00000000..c2508884 --- /dev/null +++ b/examples/pwa/src/domstack-manifest.settings.js @@ -0,0 +1,22 @@ +/** + * @import { DomstackManifestEntry } from '@domstack/static' + */ + +import { pwaManifestExclude, shouldIncludePwaOutput } from './pwa/cache-policy.js' + +const origin = 'https://example.com' + +export default { + exclude: pwaManifestExclude, + includeEntry, +} + +/** + * Keep PWA cache policy close to the application while still letting Domstack + * emit a normal domstack manifest for the service worker to consume. + * + * @param {DomstackManifestEntry} entry + */ +function includeEntry (entry) { + return shouldIncludePwaOutput(entry, origin) +} diff --git a/examples/pwa/src/global.client.js b/examples/pwa/src/global.client.js new file mode 100644 index 00000000..c2b2c428 --- /dev/null +++ b/examples/pwa/src/global.client.js @@ -0,0 +1,5 @@ +import { initializePwa } from './pwa/runtime.js' + +initializePwa().catch(err => { + console.warn('Unable to initialize the Domstack PWA example runtime', err) +}) diff --git a/examples/pwa/src/global.css b/examples/pwa/src/global.css new file mode 100644 index 00000000..78a78b9a --- /dev/null +++ b/examples/pwa/src/global.css @@ -0,0 +1,176 @@ +:root { + color-scheme: light; + --bg: #f7f4ed; + --surface: #fffaf0; + --ink: #1f261f; + --muted: #637064; + --accent: #3f5f46; + --accent-ink: #ffffff; + --border: #d9d1bd; + --ok: #dff3df; + --warn: #f8e9b6; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--ink); + background: var(--bg); + font-family: system-ui, sans-serif; + line-height: 1.5; +} + +main, +body > article, +body > section { + width: min(72rem, calc(100% - 2rem)); + margin: 0 auto; + padding: 2rem 0 4rem; +} + +a { + color: var(--accent); +} + +button, +.button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid var(--accent); + border-radius: 999px; + padding: 0.55rem 0.9rem; + color: var(--accent-ink); + background: var(--accent); + font: inherit; + font-weight: 700; + text-decoration: none; + cursor: pointer; +} + +button.secondary, +.button.secondary { + color: var(--accent); + background: transparent; +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +label { + display: grid; + gap: 0.25rem; + margin-block: 0.75rem; + font-weight: 700; +} + +input { + width: min(100%, 28rem); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.6rem; + font: inherit; +} + +.app-layout { + display: grid; + gap: 2rem; +} + +.app-summary, +.route-card, +.pwa-update { + border: 1px solid var(--border); + border-radius: 1rem; + background: var(--surface); + box-shadow: 0 0.5rem 1.5rem rgb(31 38 31 / 0.08); +} + +.app-summary { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem); + gap: 1.5rem; + padding: 1.5rem; +} + +.status-list { + display: grid; + gap: 0.75rem; +} + +.status-row { + display: flex; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid var(--border); + padding-block: 0.5rem; +} + +.route-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; +} + +.route-card { + display: grid; + gap: 0.75rem; + align-content: start; + padding: 1rem; +} + +.route-card h2, +.route-card p { + margin: 0; +} + +.pill-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.pill { + border-radius: 999px; + padding: 0.2rem 0.55rem; + color: var(--ink); + background: #ece7da; + font-size: 0.85rem; + font-weight: 700; +} + +.pill.ok { + background: var(--ok); +} + +.pill.warn { + background: var(--warn); +} + +.pwa-update { + position: fixed; + inset-inline: 1rem; + bottom: 1rem; + max-width: 34rem; + margin-inline: auto; + padding: 1rem; +} + +.pwa-update__actions { + display: flex; + gap: 0.75rem; +} + +@media (max-width: 48rem) { + .app-summary { + grid-template-columns: 1fr; + } +} diff --git a/examples/pwa/src/global.vars.js b/examples/pwa/src/global.vars.js new file mode 100644 index 00000000..148f6f3a --- /dev/null +++ b/examples/pwa/src/global.vars.js @@ -0,0 +1,6 @@ +export default { + siteName: 'Domstack PWA Example', + lang: 'en', + precache: true, + offline: true, +} diff --git a/examples/pwa/src/legal/README.md b/examples/pwa/src/legal/README.md new file mode 100644 index 00000000..bf823ef9 --- /dev/null +++ b/examples/pwa/src/legal/README.md @@ -0,0 +1,9 @@ +--- +title: Legal +--- + +# Legal + +This page represents static legal, policy, help, or support content that can be safely included in the first offline cache. + +The service worker discovers it through `/domstack-manifest.json`; the route is not hard-coded in the worker. diff --git a/examples/pwa/src/login/README.md b/examples/pwa/src/login/README.md new file mode 100644 index 00000000..5cf6910d --- /dev/null +++ b/examples/pwa/src/login/README.md @@ -0,0 +1,20 @@ +--- +title: Login +--- + +# Login + +Auth shells can be precached so the interface opens offline, while form submissions remain network-only. + +
+ + +

Checking network state.

+ +
diff --git a/examples/pwa/src/manifest.webmanifest b/examples/pwa/src/manifest.webmanifest new file mode 100644 index 00000000..e489d8d5 --- /dev/null +++ b/examples/pwa/src/manifest.webmanifest @@ -0,0 +1,11 @@ +{ + "name": "Domstack PWA Example", + "short_name": "Domstack PWA", + "description": "A Domstack static PWA example using the unstable domstack manifest preview.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f7f4ed", + "theme_color": "#3f5f46", + "icons": [] +} diff --git a/examples/pwa/src/offline/README.md b/examples/pwa/src/offline/README.md new file mode 100644 index 00000000..740cc579 --- /dev/null +++ b/examples/pwa/src/offline/README.md @@ -0,0 +1,9 @@ +--- +title: Offline +--- + +# Offline + +You are offline, and the requested page was not in the static cache. + +Return to the [app shell](/) or reconnect to continue browsing network-only routes. diff --git a/examples/pwa/src/private/README.md b/examples/pwa/src/private/README.md new file mode 100644 index 00000000..513fddd2 --- /dev/null +++ b/examples/pwa/src/private/README.md @@ -0,0 +1,9 @@ +--- +title: Private +precache: false +offline: false +--- + +# Private + +This route demonstrates page-level cache policy. The path is static, but `precache: false` and `offline: false` keep it out of the generated PWA cache plan. diff --git a/examples/pwa/src/pwa/cache-policy.js b/examples/pwa/src/pwa/cache-policy.js new file mode 100644 index 00000000..8a03c91a --- /dev/null +++ b/examples/pwa/src/pwa/cache-policy.js @@ -0,0 +1,63 @@ +export const CACHE_PREFIX = 'domstack-pwa-example' +export const STATIC_CACHE_PREFIX = `${CACHE_PREFIX}-static-` +export const INSTALL_CACHE_PREFIX = `${CACHE_PREFIX}-install-` +export const META_CACHE = `${CACHE_PREFIX}-meta` +export const ACTIVE_VERSION_URL = '/__domstack-pwa-example/active-version' +export const PENDING_VERSION_URL = '/__domstack-pwa-example/pending-version' +export const OFFLINE_FALLBACK_URL = '/offline/' +export const DOMSTACK_MANIFEST_URL = process.env.DOMSTACK_MANIFEST_URL ?? '/domstack-manifest.json' +export const DOMSTACK_MANIFEST_ENABLED = process.env.DOMSTACK_MANIFEST_ENABLED === 'true' +export const DOMSTACK_SERVICE_WORKER_URL = process.env.DOMSTACK_SERVICE_WORKER_URL ?? '/service-worker.js' +export const DOMSTACK_SERVICE_WORKER_SCOPE = process.env.DOMSTACK_SERVICE_WORKER_SCOPE ?? '/' + +export const pwaManifestExclude = [ + 'admin/**', + 'blog/**', + '**/*.map', + 'domstack-esbuild-meta.json', + 'domstack-manifest.json', + 'service-worker.js', +] + +const excludedPrefixes = [ + '/admin/', + '/api/', + '/blog/', +] + +const excludedKinds = new Set([ + 'metadata', + 'sourcemap', + 'service-worker', +]) + +/** + * Shared application policy for deciding which Domstack output entries may be + * precached. Node runs this through domstack-manifest.settings.js; the service worker + * also runs it defensively when reading the emitted manifest. + * + * @param {{ url: string, revision?: string | null, kind?: string, page?: { vars?: { precache?: unknown, offline?: unknown } } }} entry + * @param {string | URL} origin + */ +export function shouldIncludePwaOutput (entry, origin) { + if (!entry.revision) return false + if (entry.kind && excludedKinds.has(entry.kind)) return false + if (entry.page?.vars?.precache === false || entry.page?.vars?.offline === false) return false + + const url = new URL(entry.url, origin) + if (url.origin !== new URL(origin).origin) return false + + return !excludedPrefixes.some(prefix => url.pathname.startsWith(prefix)) +} + +/** + * @param {Request} request + */ +export function shouldHandleRequest (request) { + if (request.method !== 'GET') return false + + const url = new URL(request.url) + if (url.origin !== location.origin) return false + + return !excludedPrefixes.some(prefix => url.pathname.startsWith(prefix)) +} diff --git a/examples/pwa/src/pwa/runtime.js b/examples/pwa/src/pwa/runtime.js new file mode 100644 index 00000000..45844e32 --- /dev/null +++ b/examples/pwa/src/pwa/runtime.js @@ -0,0 +1,493 @@ +import { + CACHE_PREFIX, + DOMSTACK_MANIFEST_ENABLED, + DOMSTACK_MANIFEST_URL, + DOMSTACK_SERVICE_WORKER_SCOPE, + DOMSTACK_SERVICE_WORKER_URL, +} from './cache-policy.js' + +const RESET_PARAM = 'reset-sw' +const UPDATE_CHECK_INTERVAL = 15 * 60 * 1000 +const WORKER_MESSAGE_TIMEOUT = 15 * 1000 +const LOG_PREFIX = '[domstack-pwa]' + +let lastUpdateCheck = 0 +let applyingUpdate = false +let formIsDirty = false + +/** + * Register the site service worker, wire update prompts, and keep local watch + * builds from inheriting stale production caches unless a developer opts in. + */ +export async function initializePwa () { + log('initializing runtime', { + manifestEnabled: DOMSTACK_MANIFEST_ENABLED, + serviceWorkerScope: DOMSTACK_SERVICE_WORKER_SCOPE, + serviceWorkerUrl: DOMSTACK_SERVICE_WORKER_URL, + }) + + trackOnlineState() + trackFormDirtyState() + trackNetworkForms() + + if (!('serviceWorker' in navigator)) { + log('service workers are unavailable in this browser') + setStatus('Service workers are unavailable') + return + } + + if (new URLSearchParams(location.search).has(RESET_PARAM)) { + log('reset parameter found, unregistering workers and clearing caches') + await resetServiceWorkers() + location.replace(location.pathname || '/') + return + } + + if (!DOMSTACK_SERVICE_WORKER_URL || !DOMSTACK_SERVICE_WORKER_SCOPE || !DOMSTACK_MANIFEST_ENABLED) { + log('pwa disabled for this build, clearing local caches when applicable') + setStatus('PWA disabled for this build') + await resetLocalServiceWorkers() + return + } + + if (isLocalOrigin()) { + logLocalServeInstructions() + } + + log('registering service worker') + const registration = await navigator.serviceWorker.register(DOMSTACK_SERVICE_WORKER_URL, { + scope: DOMSTACK_SERVICE_WORKER_SCOPE, + updateViaCache: 'none', + }) + log('service worker registered', { + active: Boolean(registration.active), + controlled: Boolean(navigator.serviceWorker.controller), + installing: Boolean(registration.installing), + scope: registration.scope, + waiting: Boolean(registration.waiting), + }) + + setStatus(navigator.serviceWorker.controller ? 'Worker active, checking cache' : 'Installing') + wireRegistration(registration) + wireControllerReload() + + if (registration.waiting) { + log('registration already has a waiting worker') + showUpdatePrompt(registration.waiting, 'SKIP_WAITING') + } + + await checkForUpdates(registration, { force: true }) + navigator.serviceWorker.ready.then(readyRegistration => { + log('service worker ready, checking manifest-backed cache state', { + active: Boolean(readyRegistration.active), + scope: readyRegistration.scope, + }) + setStatus('Worker active, checking cache') + return checkForUpdates(readyRegistration, { force: true }) + }).catch(reportUpdateFailure) + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + log('document became visible, checking for updates') + checkForUpdates(registration).catch(reportUpdateFailure) + } + }) + + window.addEventListener('online', () => { + log('browser came online, forcing update check') + checkForUpdates(registration, { force: true }).catch(reportUpdateFailure) + }) + + window.addEventListener('pagehide', () => { + if (!formIsDirty && registration.waiting) { + log('clean pagehide with waiting worker, applying update') + registration.waiting.postMessage({ type: 'SKIP_WAITING' }) + } else if (formIsDirty && registration.waiting) { + log('pagehide skipped update because a form is dirty') + } + }) +} + +/** + * Connect registration events to the small update notice rendered by this + * example's runtime. + * + * @param {ServiceWorkerRegistration} registration + */ +function wireRegistration (registration) { + registration.addEventListener('updatefound', () => { + const installing = registration.installing + if (!installing) return + + log('browser found a service-worker update') + setStatus('Installing update') + installing.addEventListener('statechange', () => { + log('installing worker state changed', { state: installing.state }) + if (installing.state === 'installed' && navigator.serviceWorker.controller) { + showUpdatePrompt(installing, 'SKIP_WAITING') + } else if (installing.state === 'activated') { + setStatus('Worker active, checking cache') + } + }) + }) + + navigator.serviceWorker.addEventListener('message', event => { + handleServiceWorkerMessage(event.data, registration) + }) +} + +/** + * Reload once after an accepted service-worker update takes control. + */ +function wireControllerReload () { + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (applyingUpdate) return + applyingUpdate = true + log('service-worker controller changed, reloading') + window.location.reload() + }) +} + +/** + * Ask the browser to check for a service-worker update, show the window's + * current domstack manifest version immediately, and ask the active worker to + * reconcile Cache Storage in the background. + * + * @param {ServiceWorkerRegistration} registration + * @param {{ force?: boolean }} [opts] + */ +async function checkForUpdates (registration, opts = {}) { + const now = Date.now() + if (!opts.force && now - lastUpdateCheck < UPDATE_CHECK_INTERVAL) { + log('skipping update check due to throttle') + return + } + lastUpdateCheck = now + + log('checking for service-worker and manifest updates', { force: Boolean(opts.force) }) + await registration.update() + log('registration state after update check', { + active: Boolean(registration.active), + controlled: Boolean(navigator.serviceWorker.controller), + installing: Boolean(registration.installing), + waiting: Boolean(registration.waiting), + }) + + if (registration.waiting) { + log('update check found a waiting worker') + showUpdatePrompt(registration.waiting, 'SKIP_WAITING') + } + + await setVersionFromDomstackManifest() + + const worker = navigator.serviceWorker.controller + if (worker) { + log('asked controlling worker to check the domstack manifest') + postWorkerMessage(worker, { type: 'CHECK_FOR_UPDATES' }) + .then(response => { + if (response) { + handleServiceWorkerMessage(response, registration) + } else { + log('controlling worker did not reply to manifest check') + if (getStatus() === 'Worker active, checking cache') { + setStatus('Cache check timed out') + } + } + }) + .catch(reportUpdateFailure) + } else { + log('page is not controlled by a service worker yet; cache reconciliation will start after reload') + } +} + +/** + * @param {unknown} data + * @param {ServiceWorkerRegistration} registration + */ +function handleServiceWorkerMessage (data, registration) { + if (!data || typeof data !== 'object') return + const message = /** @type {{ type?: unknown, version?: unknown, error?: unknown }} */ (data) + log('message from service worker', message) + + if (message.type === 'CACHE_UPDATE_READY') { + setStatus('Cache update ready') + setVersion(message.version) + if (registration.waiting) { + showUpdatePrompt(registration.waiting, 'SKIP_WAITING') + } else if (registration.active) { + showUpdatePrompt(registration.active, 'APPLY_PENDING_CACHE') + } + } + + if (message.type === 'CACHE_UPDATE_CURRENT') { + setStatus('Offline cache current') + setVersion(message.version) + } + + if (message.type === 'CACHE_UPDATE_APPLIED') { + setStatus('Offline cache applied') + setVersion(message.version) + log('manifest-only cache update applied, reloading') + window.location.reload() + } + + if (message.type === 'CACHE_UPDATE_FAILED') { + setStatus('Update check failed') + console.warn('PWA cache update failed', message.error) + } +} + +/** + * Send a request directly to the worker and wait briefly for a MessageChannel + * response. This makes the example easier to debug than relying only on + * broadcast client messages. + * + * @param {ServiceWorker} worker + * @param {Record} message + * @returns {Promise} + */ +async function postWorkerMessage (worker, message) { + return await new Promise(resolve => { + const channel = new MessageChannel() + const timeout = setTimeout(() => { + channel.port1.close() + resolve(null) + }, WORKER_MESSAGE_TIMEOUT) + + channel.port1.onmessage = event => { + clearTimeout(timeout) + channel.port1.close() + resolve(event.data) + } + + worker.postMessage(message, [channel.port2]) + }) +} + +async function setVersionFromDomstackManifest () { + try { + const response = await fetch(DOMSTACK_MANIFEST_URL, { + cache: 'no-store', + credentials: 'same-origin', + }) + if (!response.ok) throw new Error(`Unable to fetch ${DOMSTACK_MANIFEST_URL}: ${response.status}`) + + const manifest = await response.json() + const version = /** @type {{ version?: unknown }} */ (manifest).version + if (typeof version === 'string') { + log('loaded domstack manifest version from window', { version }) + setVersion(version) + } + } catch (err) { + console.warn('Unable to load domstack manifest version from the window', err) + } +} + +/** + * Render the update prompt and post the matching activation message when the + * user accepts. + * + * @param {ServiceWorker} worker + * @param {'SKIP_WAITING'|'APPLY_PENDING_CACHE'} messageType + */ +function showUpdatePrompt (worker, messageType) { + const prompt = document.querySelector('[data-pwa-update]') + if (!prompt) return + + log('showing update prompt', { messageType }) + prompt.replaceChildren() + prompt.hidden = false + + const message = document.createElement('p') + message.textContent = 'A fresh static build is ready.' + + const actions = document.createElement('div') + actions.className = 'pwa-update__actions' + + const apply = document.createElement('button') + apply.type = 'button' + apply.textContent = 'Apply now' + apply.addEventListener('click', () => { + applyingUpdate = false + log('applying prompted update', { messageType }) + worker.postMessage({ type: messageType }) + }) + + const later = document.createElement('button') + later.type = 'button' + later.className = 'secondary' + later.textContent = 'Later' + later.addEventListener('click', () => { + log('dismissed update prompt for this page session') + prompt.hidden = true + }) + + actions.append(apply, later) + prompt.append(message, actions) +} + +/** + * Track form edits so pagehide auto-activation does not interrupt a dirty form. + */ +function trackFormDirtyState () { + document.addEventListener('input', event => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + if (!formIsDirty) log('form marked dirty') + formIsDirty = true + } + }) + + document.addEventListener('submit', () => { + log('form submitted, clearing dirty flag') + formIsDirty = false + }) +} + +/** + * Keep the visible online/offline status in sync with the browser state. + */ +function trackOnlineState () { + const render = () => { + const node = document.querySelector('[data-online-state]') + if (node) node.textContent = navigator.onLine ? 'Online' : 'Offline' + log('network state changed', { online: navigator.onLine }) + } + + render() + window.addEventListener('online', render) + window.addEventListener('offline', render) +} + +/** + * Disable network-only form submission controls while offline. + */ +function trackNetworkForms () { + const forms = Array.from(document.querySelectorAll('[data-network-form]')) + if (forms.length === 0) return + log('tracking network-only forms', { count: forms.length }) + + const render = () => { + for (const form of forms) { + const status = form.querySelector('[data-network-state]') + const submit = form.querySelector('[type="submit"]') + if (status) { + status.textContent = navigator.onLine + ? 'Online. Submissions will use the network.' + : 'Offline. Submission is paused until the network returns.' + } + if (submit instanceof HTMLButtonElement) { + submit.disabled = !navigator.onLine + } + } + } + + for (const form of forms) { + form.addEventListener('submit', event => { + if (!navigator.onLine) { + log('blocked network-only form submit while offline') + event.preventDefault() + } + }) + } + + render() + window.addEventListener('online', render) + window.addEventListener('offline', render) +} + +/** + * Remove service workers and Cache Storage entries owned by this example. + */ +async function resetServiceWorkers () { + const registrations = await navigator.serviceWorker.getRegistrations() + log('unregistering service workers', { count: registrations.length }) + await Promise.all(registrations.map(registration => registration.unregister())) + await deleteExampleCaches() + setStatus('PWA reset complete') +} + +/** + * Clear local caches only when the active worker belongs to this example scope. + */ +async function resetLocalServiceWorkers () { + if (!isLocalOrigin()) return + const registrations = await navigator.serviceWorker.getRegistrations() + const scopedRegistrations = registrations.filter(registration => { + const scope = new URL(registration.scope) + return scope.origin === location.origin && scope.pathname === DOMSTACK_SERVICE_WORKER_SCOPE + }) + + log('clearing local example pwa state', { registrations: scopedRegistrations.length }) + await Promise.all(scopedRegistrations.map(registration => registration.unregister())) + await deleteExampleCaches() +} + +/** + * Delete Cache Storage entries created by this example. + */ +async function deleteExampleCaches () { + const names = await caches.keys() + const exampleCaches = names.filter(name => name.startsWith(CACHE_PREFIX)) + log('deleting example caches', { caches: exampleCaches }) + await Promise.all( + exampleCaches.map(name => caches.delete(name)) + ) +} + +/** + * @param {string} value + */ +function setStatus (value) { + const node = document.querySelector('[data-pwa-status]') + if (node) node.textContent = value +} + +function getStatus () { + const node = document.querySelector('[data-pwa-status]') + return node?.textContent ?? '' +} + +/** + * @param {unknown} value + */ +function setVersion (value) { + const node = document.querySelector('[data-pwa-version]') + if (node && typeof value === 'string') node.textContent = value.slice(0, 12) +} + +/** + * @param {unknown} err + */ +function reportUpdateFailure (err) { + console.warn('PWA update check failed', err) +} + +function isLocalOrigin () { + return ['localhost', '127.0.0.1', '[::1]'].includes(location.hostname) +} + +function logLocalServeInstructions () { + console.info( + `${LOG_PREFIX} local manifest-enabled build detected; registering the PWA service worker.\n` + + 'Use `npm run serve` for PWA testing and `npm run watch` for sticky-cache-free development.' + ) + console.info( + `${LOG_PREFIX} reset service workers and caches with:\n` + + `${location.origin}/?reset-sw=1` + ) +} + +/** + * This example intentionally logs more than production code normally would so + * the PWA lifecycle is easy to watch in DevTools. + * + * @param {string} message + * @param {unknown} [details] + */ +function log (message, details) { + if (details === undefined) { + console.info(LOG_PREFIX, message) + } else { + console.info(LOG_PREFIX, message, details) + } +} diff --git a/examples/pwa/src/service-worker.js b/examples/pwa/src/service-worker.js new file mode 100644 index 00000000..eb3bd7c4 --- /dev/null +++ b/examples/pwa/src/service-worker.js @@ -0,0 +1 @@ +import './sw/events.js' diff --git a/examples/pwa/src/sw/cache.js b/examples/pwa/src/sw/cache.js new file mode 100644 index 00000000..177a0cda --- /dev/null +++ b/examples/pwa/src/sw/cache.js @@ -0,0 +1,122 @@ +import { + ACTIVE_VERSION_URL, + INSTALL_CACHE_PREFIX, + META_CACHE, + PENDING_VERSION_URL, + STATIC_CACHE_PREFIX, +} from '../pwa/cache-policy.js' +import { logWorker, serviceWorker } from './context.js' + +/** + * @param {string} version + */ +export function staticCacheName (version) { + return `${STATIC_CACHE_PREFIX}${version}` +} + +/** + * @param {string} version + */ +export function installCacheName (version) { + return `${INSTALL_CACHE_PREFIX}${version}` +} + +export async function getActiveVersion () { + return readMeta(ACTIVE_VERSION_URL) +} + +export async function getPendingVersion () { + return readMeta(PENDING_VERSION_URL) +} + +/** + * @param {string} version + */ +export async function setActiveVersion (version) { + logWorker('setting active cache version', { version }) + await writeMeta(ACTIVE_VERSION_URL, version) +} + +/** + * @param {string} version + */ +export async function setPendingVersion (version) { + logWorker('setting pending cache version', { version }) + await writeMeta(PENDING_VERSION_URL, version) +} + +export async function clearPendingVersion () { + logWorker('clearing pending cache version') + await deleteMeta(PENDING_VERSION_URL) +} + +/** + * Copy every request/response pair from an install cache into the final static + * cache. Cache Storage has no atomic rename operation, so this is the commit + * step after all responses have already been fetched and validated. + * + * @param {string} fromName + * @param {string} toName + */ +export async function copyCacheEntries (fromName, toName) { + const from = await serviceWorker.caches.open(fromName) + const to = await serviceWorker.caches.open(toName) + const requests = await from.keys() + logWorker('copying staged cache entries', { + count: requests.length, + from: fromName, + to: toName, + }) + + for (const request of requests) { + const response = await from.match(request) + if (response) await to.put(request, response) + } +} + +/** + * Remove old static and temporary install caches after a version is active. + * + * @param {string | null} keepVersion + */ +export async function cleanupStaticCaches (keepVersion) { + const names = await serviceWorker.caches.keys() + const deleting = names.filter(name => { + if (name === META_CACHE) return false + if (keepVersion && name === staticCacheName(keepVersion)) return false + return name.startsWith(STATIC_CACHE_PREFIX) || name.startsWith(INSTALL_CACHE_PREFIX) + }) + logWorker('cleaning old static caches', { + deleting, + keepVersion, + }) + await Promise.all( + deleting.map(name => serviceWorker.caches.delete(name)) + ) +} + +/** + * @param {string} url + */ +async function readMeta (url) { + const cache = await serviceWorker.caches.open(META_CACHE) + const response = await cache.match(new Request(url)) + return response ? response.text() : null +} + +/** + * @param {string} url + * @param {string} value + */ +async function writeMeta (url, value) { + const cache = await serviceWorker.caches.open(META_CACHE) + await cache.put(new Request(url), new Response(value)) +} + +/** + * @param {string} url + */ +async function deleteMeta (url) { + const cache = await serviceWorker.caches.open(META_CACHE) + await cache.delete(new Request(url)) +} diff --git a/examples/pwa/src/sw/clients.js b/examples/pwa/src/sw/clients.js new file mode 100644 index 00000000..a1e6dffa --- /dev/null +++ b/examples/pwa/src/sw/clients.js @@ -0,0 +1,22 @@ +import { logWorker, serviceWorker } from './context.js' + +/** + * Broadcast a structured message to every window client, including clients not + * yet controlled by the current worker. + * + * @param {Record} message + */ +export async function postToClients (message) { + const clients = await serviceWorker.clients.matchAll({ + includeUncontrolled: true, + type: 'window', + }) + logWorker('posting message to window clients', { + clients: clients.length, + message, + }) + + for (const client of clients) { + client.postMessage(message) + } +} diff --git a/examples/pwa/src/sw/context.js b/examples/pwa/src/sw/context.js new file mode 100644 index 00000000..72af60e1 --- /dev/null +++ b/examples/pwa/src/sw/context.js @@ -0,0 +1,32 @@ +export const serviceWorker = /** @type {ServiceWorkerGlobalScope & typeof globalThis} */ ( + /** @type {unknown} */ (globalThis) +) + +const LOG_PREFIX = '[domstack-pwa:sw]' + +/** + * This example logs service-worker internals so the install/update/cache flow is + * easy to follow in DevTools. + * + * @param {string} message + * @param {unknown} [details] + */ +export function logWorker (message, details) { + if (details === undefined) { + console.info(LOG_PREFIX, message) + } else { + console.info(LOG_PREFIX, message, details) + } +} + +/** + * @param {string} message + * @param {unknown} [details] + */ +export function warnWorker (message, details) { + if (details === undefined) { + console.warn(LOG_PREFIX, message) + } else { + console.warn(LOG_PREFIX, message, details) + } +} diff --git a/examples/pwa/src/sw/domstack-manifest.js b/examples/pwa/src/sw/domstack-manifest.js new file mode 100644 index 00000000..8c384446 --- /dev/null +++ b/examples/pwa/src/sw/domstack-manifest.js @@ -0,0 +1,83 @@ +import { + DOMSTACK_MANIFEST_ENABLED, + DOMSTACK_MANIFEST_URL, + shouldIncludePwaOutput, +} from '../pwa/cache-policy.js' +import { logWorker, serviceWorker } from './context.js' + +/** + * @typedef {object} DomstackManifestEntry + * @property {string} url + * @property {string} revision + * @property {string} [kind] + */ + +/** + * @typedef {object} DomstackManifest + * @property {string} version + * @property {DomstackManifestEntry[]} entries + */ + +/** + * Fetch and validate Domstack's domstack manifest. Watch builds set + * DOMSTACK_MANIFEST_ENABLED=false, so the worker exits early there. + * + * @returns {Promise} + */ +export async function fetchDomstackManifest () { + if (!DOMSTACK_MANIFEST_ENABLED) { + logWorker('domstack manifest disabled for this build') + return null + } + + logWorker('fetching domstack manifest', { url: DOMSTACK_MANIFEST_URL }) + const response = await serviceWorker.fetch(DOMSTACK_MANIFEST_URL, { + cache: 'no-store', + credentials: 'same-origin', + }) + + if (!response.ok) { + throw new Error(`Unable to fetch domstack manifest: ${response.status}`) + } + + const data = await response.json() + if (!isDomstackManifest(data)) { + throw new Error('Domstack manifest has an unexpected shape') + } + + const entries = data.entries.filter(entry => shouldIncludePwaOutput(entry, serviceWorker.location.origin)) + logWorker('domstack manifest loaded', { + includedEntries: entries.length, + totalEntries: data.entries.length, + version: data.version, + }) + + return { + version: data.version, + entries, + } +} + +/** + * @param {unknown} value + * @returns {value is DomstackManifest} + */ +function isDomstackManifest (value) { + if (!value || typeof value !== 'object') return false + const manifest = /** @type {{ version?: unknown, entries?: unknown }} */ (value) + + return typeof manifest.version === 'string' && + Array.isArray(manifest.entries) && + manifest.entries.every(isDomstackManifestEntry) +} + +/** + * @param {unknown} value + * @returns {value is DomstackManifestEntry} + */ +function isDomstackManifestEntry (value) { + if (!value || typeof value !== 'object') return false + const entry = /** @type {{ url?: unknown, revision?: unknown }} */ (value) + + return typeof entry.url === 'string' && typeof entry.revision === 'string' +} diff --git a/examples/pwa/src/sw/events.js b/examples/pwa/src/sw/events.js new file mode 100644 index 00000000..c2fb14c7 --- /dev/null +++ b/examples/pwa/src/sw/events.js @@ -0,0 +1,65 @@ +import { getActiveVersion } from './cache.js' +import { postToClients } from './clients.js' +import { logWorker, serviceWorker } from './context.js' +import { handleFetch } from './fetch.js' +import { handleMessage } from './messages.js' +import { + activatePendingCache, + prepareStaticCache, +} from './precache.js' + +/** + * Install follows the Service Worker lifecycle: + * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/install_event + */ +serviceWorker.addEventListener('install', event => { + logWorker('install event received') + event.waitUntil(handleInstall()) +}) + +/** + * Activate commits a fully staged cache and removes old cache versions: + * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/activate_event + */ +serviceWorker.addEventListener('activate', event => { + logWorker('activate event received') + event.waitUntil(handleActivate()) +}) + +/** + * Fetch keeps static navigations/assets cache-first and leaves excluded traffic + * on the network path: + * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/fetch_event + */ +serviceWorker.addEventListener('fetch', event => { + event.respondWith(handleFetch(event.request)) +}) + +/** + * Messages let the window runtime request update checks and user-approved + * activation: + * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/message_event + */ +serviceWorker.addEventListener('message', event => { + logWorker('message event received', event.data) + event.waitUntil(handleMessage(event)) +}) + +async function handleInstall () { + const hadActiveCache = Boolean(await getActiveVersion()) + logWorker('install started', { hadActiveCache }) + const result = await prepareStaticCache({ force: true }) + logWorker('install cache preparation finished', result) + + if (!hadActiveCache && result.status === 'ready') { + logWorker('first install has a complete cache, calling skipWaiting') + await serviceWorker.skipWaiting() + } +} + +async function handleActivate () { + const version = await activatePendingCache() + logWorker('activate completed', { version }) + await serviceWorker.clients.claim() + await postToClients({ type: 'CACHE_UPDATE_CURRENT', version }) +} diff --git a/examples/pwa/src/sw/fetch.js b/examples/pwa/src/sw/fetch.js new file mode 100644 index 00000000..d03e36e8 --- /dev/null +++ b/examples/pwa/src/sw/fetch.js @@ -0,0 +1,77 @@ +import { + OFFLINE_FALLBACK_URL, + shouldHandleRequest, +} from '../pwa/cache-policy.js' +import { + getActiveVersion, + staticCacheName, +} from './cache.js' +import { logWorker, serviceWorker, warnWorker } from './context.js' + +/** + * Serve static manifest entries cache-first and keep excluded/data requests on + * the network path. + * + * @param {Request} request + */ +export async function handleFetch (request) { + if (!shouldHandleRequest(request)) { + logWorker('network-only request', { + method: request.method, + mode: request.mode, + url: request.url, + }) + return serviceWorker.fetch(request) + } + + const cached = await matchActiveCache(request) + if (cached) { + logWorker('cache hit', { mode: request.mode, url: request.url }) + return cached + } + + try { + logWorker('cache miss, using network', { mode: request.mode, url: request.url }) + return await serviceWorker.fetch(request) + } catch (err) { + warnWorker('network request failed', { + error: err instanceof Error ? err.message : String(err), + mode: request.mode, + url: request.url, + }) + if (request.mode === 'navigate') { + const fallback = await matchActiveCache(new Request(OFFLINE_FALLBACK_URL)) + if (fallback) { + logWorker('serving offline fallback', { url: request.url }) + return fallback + } + } + + throw err + } +} + +/** + * @param {Request} request + */ +async function matchActiveCache (request) { + const activeVersion = await getActiveVersion() + if (!activeVersion) { + logWorker('no active cache version for request', { url: request.url }) + return null + } + + const cache = await serviceWorker.caches.open(staticCacheName(activeVersion)) + const directMatch = await cache.match(request, { ignoreSearch: true }) + if (directMatch) return directMatch + + if (request.mode !== 'navigate') return null + + const url = new URL(request.url) + if (!url.pathname.endsWith('/')) { + url.pathname = `${url.pathname}/` + } + url.search = '' + + return cache.match(url.href) +} diff --git a/examples/pwa/src/sw/messages.js b/examples/pwa/src/sw/messages.js new file mode 100644 index 00000000..3c40d0e1 --- /dev/null +++ b/examples/pwa/src/sw/messages.js @@ -0,0 +1,92 @@ +import { getActiveVersion } from './cache.js' +import { postToClients } from './clients.js' +import { logWorker, serviceWorker, warnWorker } from './context.js' +import { + activatePendingCache, + ensureOfflineFallbackIsPresent, + prepareStaticCache, +} from './precache.js' + +/** + * @param {ExtendableMessageEvent} event + */ +export async function handleMessage (event) { + const data = event.data + if (!data || typeof data !== 'object') return + logWorker('handling message', data) + + if (data.type === 'SKIP_WAITING') { + logWorker('skip waiting requested by client') + replyToMessage(event, { type: 'SKIP_WAITING_ACCEPTED' }) + await serviceWorker.skipWaiting() + return + } + + if (data.type === 'CHECK_FOR_UPDATES') { + const message = await checkForManifestUpdate() + replyToMessage(event, message) + await postToClients(message) + return + } + + if (data.type === 'APPLY_PENDING_CACHE') { + const version = await activatePendingCache() + logWorker('pending cache applied by client request', { version }) + const message = { type: 'CACHE_UPDATE_APPLIED', version } + replyToMessage(event, message) + await postToClients(message) + } +} + +async function checkForManifestUpdate () { + try { + logWorker('checking domstack manifest for updates') + const activeVersion = await getActiveVersion() + const activeCacheIsReady = await ensureOfflineFallbackIsPresent() + const result = await prepareStaticCache({ force: !activeCacheIsReady }) + logWorker('domstack manifest update check finished', { + ...result, + activeCacheIsReady, + activeVersion, + }) + + if (result.status === 'ready' && !activeCacheIsReady) { + const version = await activatePendingCache() + logWorker('promoted first or repaired static cache during manifest check', { version }) + return { + type: 'CACHE_UPDATE_CURRENT', + version, + } + } + + return { + type: result.status === 'ready' ? 'CACHE_UPDATE_READY' : 'CACHE_UPDATE_CURRENT', + version: result.version, + } + } catch (err) { + warnWorker('domstack manifest update check failed', err instanceof Error ? err.message : String(err)) + return { + type: 'CACHE_UPDATE_FAILED', + error: err instanceof Error ? err.message : String(err), + } + } +} + +/** + * @param {ExtendableMessageEvent} event + * @param {Record} message + */ +function replyToMessage (event, message) { + const port = event.ports?.[0] + if (port) { + logWorker('replying on message channel', message) + port.postMessage(message) + return + } + + const source = event.source + if (source && 'postMessage' in source && typeof source.postMessage === 'function') { + logWorker('replying to message source', message) + source.postMessage(message) + } +} diff --git a/examples/pwa/src/sw/precache.js b/examples/pwa/src/sw/precache.js new file mode 100644 index 00000000..343df7fd --- /dev/null +++ b/examples/pwa/src/sw/precache.js @@ -0,0 +1,182 @@ +import { + OFFLINE_FALLBACK_URL, +} from '../pwa/cache-policy.js' +import { + clearPendingVersion, + cleanupStaticCaches, + copyCacheEntries, + getActiveVersion, + getPendingVersion, + installCacheName, + setActiveVersion, + setPendingVersion, + staticCacheName, +} from './cache.js' +import { logWorker, serviceWorker } from './context.js' +import { fetchDomstackManifest } from './domstack-manifest.js' + +const MAX_PARALLEL_FETCHES = 8 + +/** + * Fetch, validate, and stage a full static cache from the current domstack + * manifest. The active cache is not changed until activation is accepted. + * + * @param {{ force?: boolean }} [opts] + */ +export async function prepareStaticCache (opts = {}) { + const manifest = await fetchDomstackManifest() + if (!manifest) return { status: 'disabled', version: null } + + const activeVersion = await getActiveVersion() + const pendingVersion = await getPendingVersion() + logWorker('preparing static cache', { + activeVersion, + entries: manifest.entries.length, + force: Boolean(opts.force), + pendingVersion, + version: manifest.version, + }) + if (!opts.force && (manifest.version === activeVersion || manifest.version === pendingVersion)) { + logWorker('static cache already current or pending', { + activeVersion, + pendingVersion, + version: manifest.version, + }) + return { status: 'current', version: manifest.version } + } + + const installName = installCacheName(manifest.version) + const finalName = staticCacheName(manifest.version) + + await serviceWorker.caches.delete(installName) + await serviceWorker.caches.delete(finalName) + + const cache = await serviceWorker.caches.open(installName) + await cacheManifestEntries(cache, manifest.entries) + await copyCacheEntries(installName, finalName) + await serviceWorker.caches.delete(installName) + await setPendingVersion(manifest.version) + logWorker('static cache staged and ready', { + cacheName: finalName, + version: manifest.version, + }) + + return { status: 'ready', version: manifest.version } +} + +/** + * Promote a staged static cache to the active version. + */ +export async function activatePendingCache () { + const pendingVersion = await getPendingVersion() + if (!pendingVersion) { + const activeVersion = await getActiveVersion() + logWorker('no pending cache to activate', { activeVersion }) + await cleanupStaticCaches(activeVersion) + return activeVersion + } + + logWorker('activating pending cache', { pendingVersion }) + await setActiveVersion(pendingVersion) + await clearPendingVersion() + await cleanupStaticCaches(pendingVersion) + + return pendingVersion +} + +/** + * @param {Cache} cache + * @param {{ url: string, revision: string }[]} entries + */ +async function cacheManifestEntries (cache, entries) { + const queue = [...entries] + logWorker('caching domstack manifest entries', { + concurrency: Math.min(MAX_PARALLEL_FETCHES, queue.length), + entries: queue.length, + }) + const workers = Array.from( + { length: Math.min(MAX_PARALLEL_FETCHES, queue.length) }, + () => cacheNextEntry(cache, queue) + ) + + await Promise.all(workers) +} + +/** + * @param {Cache} cache + * @param {{ url: string, revision: string }[]} queue + */ +async function cacheNextEntry (cache, queue) { + while (queue.length > 0) { + const entry = queue.shift() + if (!entry) return + await cacheEntry(cache, entry) + } +} + +/** + * @param {Cache} cache + * @param {{ url: string, revision: string }} entry + */ +async function cacheEntry (cache, entry) { + logWorker('fetching cache entry', { url: entry.url }) + const request = new Request(entry.url, { + credentials: 'same-origin', + }) + const response = await serviceWorker.fetch(request, { + cache: 'reload', + credentials: 'same-origin', + }) + + assertCacheableResponse(entry.url, response) + + const revisionMatches = await responseMatchesRevision(response, entry.revision) + if (!revisionMatches) { + throw new Error(`Cached response revision mismatch for ${entry.url}`) + } + + await cache.put(request, response) + logWorker('cached entry', { url: entry.url }) +} + +/** + * @param {string} url + * @param {Response} response + */ +function assertCacheableResponse (url, response) { + if (!response.ok || response.redirected || response.type !== 'basic') { + throw new Error(`Refusing to cache ${url}: ${response.status} ${response.type}`) + } +} + +/** + * @param {Response} response + * @param {string} revision + */ +async function responseMatchesRevision (response, revision) { + const buffer = await response.clone().arrayBuffer() + const digest = await serviceWorker.crypto.subtle.digest('SHA-256', buffer) + return toHex(digest) === revision +} + +/** + * @param {ArrayBuffer} buffer + */ +function toHex (buffer) { + return [...new Uint8Array(buffer)] + .map(value => value.toString(16).padStart(2, '0')) + .join('') +} + +export async function ensureOfflineFallbackIsPresent () { + const activeVersion = await getActiveVersion() + if (!activeVersion) return false + + const cache = await serviceWorker.caches.open(staticCacheName(activeVersion)) + const fallbackIsPresent = Boolean(await cache.match(OFFLINE_FALLBACK_URL)) + logWorker('checked offline fallback cache entry', { + fallbackIsPresent, + url: OFFLINE_FALLBACK_URL, + }) + return fallbackIsPresent +} diff --git a/package.json b/package.json index 746ddcc2..675dfb48 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "example:string-layouts": "npm --workspace @domstack/string-layouts-example run build", "example:default-layout": "npm --workspace @domstack/default-layout-example run build", "example:nested-dest": "npm --workspace @domstack/nested-dest-example run build", + "example:pwa": "npm --workspace @domstack/pwa-example run build", "example:uhtml-isomorphic": "npm --workspace @domstack/uhtml-isomorphic-example run build", "start": "npm run watch" },