diff --git a/examples/static-mpa-offline/DESIGN.md b/examples/static-mpa-offline/DESIGN.md new file mode 100644 index 0000000..b529849 --- /dev/null +++ b/examples/static-mpa-offline/DESIGN.md @@ -0,0 +1,132 @@ +# Static MPA Offline Service Worker Design Notes + +## Status + +These notes describe the current vanilla offline service-worker example in `examples/static-mpa-offline`. + +The example is intentionally small and does not use Workbox at runtime. +It still borrows Workbox's useful mental model: build a manifest from emitted files, precache revisioned assets during `install`, clean old cache entries during `activate`, and use a deliberate update UX. + +## Current architecture + +Domstack builds a stable root service worker at `/service-worker.js`. +The service worker is omitted from the Domstack manifest so its own output does not create a manifest/hash cycle. +After the final manifest is built, `hooks.manifestBuilt` injects the manifest-derived policy into the final service-worker bundle with `defineServiceWorkerConstant()`. + +The service worker does not fetch `/domstack-manifest.json` at runtime. +The example also does not emit a separate service-worker policy JSON file. + +The injected policy keeps the Domstack manifest entry shape: + +```ts +type StaticMpaOfflineServiceWorkerPolicy = { + version: string + entries: DomstackManifestEntry[] + offlineFallbackUrl: string +} +``` + +That lets the worker derive cache behavior from the same fields the manifest already owns: + +- `entry.url` +- `entry.revision` +- `entry.urlRevisioned` +- `entry.integrity` +- `entry.bytes` +- `entry.kind` +- `entry.role` +- `entry.static` +- `entry.manifestVars` + +## Manifest vars and policy + +The app exposes two page/layout vars to the manifest: + +```ts +manifestVars: ['offline', 'precache'] +``` + +The variable cascade is page → layout → global → default. +Layout modules use this to define route-section defaults. +Page vars or frontmatter can override those defaults for a single page. + +The root app policy carries the single offline fallback route: + +```ts +policy: { + offlineFallbackUrl: '/offline/', +} +``` + +The worker uses that route for failed offline navigations. +It does not need per-route fallback target metadata. + +## Cache model + +The worker uses a stable precache cache name and revisioned cache keys. +Unhashed URLs get a `?__DOMSTACK_REVISION__=` cache key. +Hashed URLs can use their URL as the cache key because the URL already changes when contents change. + +The worker uses a separate runtime cache for progressive-cache pages. +Pages with `offline: true` and `precache: false` are not install-cached. +They become available offline after their first successful online visit. + +Chunks are precached in production builds so dynamic imports used by cached pages stay available offline. +Watch builds disable splitting so the watch-mode service worker remains self-contained. + +## Watch mode + +Watch mode is for editing, not offline testing. +A watch build does not inject the production policy constant. +When the watch worker sees that no policy was injected, it skips caching, unregisters itself, and clears this example's caches. +The browser client also unregisters existing workers and clears owned caches when running in watch mode. + +Use `npm --workspace @domstack/static-mpa-offline-example run serve` for offline-cache testing. + +## Update lifecycle + +The service worker does not unconditionally call `skipWaiting()` in the production path. +The browser client detects waiting updates and shows an update prompt. +If the user accepts, the client sends `{ type: 'SKIP_WAITING' }` to the waiting worker. +The page reloads once after `controllerchange` so the page and service worker move to the same version together. + +This avoids mixed-version pages where old HTML or JavaScript is controlled by a new cache manifest. + +## Fetch behavior + +The worker handles same-origin `GET` requests only. +Navigations first try the precache, then the runtime cache, then the network, then the offline fallback. +Static subresources first try the precache. +Progressive-cache subresources can be stored in the runtime cache after the requesting page is visited online. + +Navigation preload may be enabled during activation, but cache wins are allowed to settle the preload promise with `event.waitUntil()` so browsers do not report abandoned preload work. + +## Recovery paths + +The example includes two recovery tiers. + +For recoverable mistakes where page JavaScript still runs, `?reset-sw` unregisters service workers, deletes this example's caches, removes the query parameter, and reloads. +For severe mistakes, deploy `rescue-service-worker.js` at `/service-worker.js` to replace the broken worker with a no-op worker at the same registration URL. + +Do not change the service-worker URL during recovery. +A no-op worker at a different URL leaves the broken registration active at the old URL. + +## Deployment headers + +Recommended production headers: + +```txt +/service-worker.js + Cache-Control: no-cache + +/**/*.html + Cache-Control: no-cache + +/assets/content-hashed-files... + Cache-Control: public, max-age=31536000, immutable +``` + +`no-cache` allows browser/storage revalidation. +It does not mean "never store". +Do not serve `/service-worker.js` with long-lived immutable caching. +Content-hashed CSS, JavaScript, and chunks can be immutable because their URL changes when contents change. diff --git a/examples/static-mpa-offline/README.md b/examples/static-mpa-offline/README.md new file mode 100644 index 0000000..84764ba --- /dev/null +++ b/examples/static-mpa-offline/README.md @@ -0,0 +1,175 @@ +# Static MPA Offline Example + +This example shows a small static multi-page app that can load selected static assets offline with a production-oriented service-worker lifecycle. + +- Domstack emits a stable root `/service-worker.js`. +- `src/globals/domstack-manifest/domstack-manifest.settings.ts` uses `hooks.manifestBuilt` to inject finalized manifest data into `/service-worker.js`. +- The service worker consumes the injected Domstack manifest entries directly. +- Selected pages, scripts, styles, chunks, and small static assets are precached. +- Layout vars define section-wide offline policy, and page vars or frontmatter can override that policy through domstack's normal variable cascade. +- The client prompts before activating an update discovered during an active session. +- Watch mode unregisters service workers and clears this example's caches to avoid half-cached development state. +- `?reset-sw` and `rescue-service-worker.js` are included as recovery paths. + +## Running + +```sh +npm --workspace @domstack/static-mpa-offline-example run serve +``` + +Then open the served localhost URL, wait for the offline cache to be ready, and use DevTools to test offline reloads. + +Use watch mode only for editing: + +```sh +npm --workspace @domstack/static-mpa-offline-example run watch +``` + +Watch mode does not inject production manifest policy into the service worker, so the watch worker unregisters itself and clears owned caches. +Use `serve` for offline-cache testing. + +## Pages in the sample app + +- `/` — home page and test instructions. +- `/about/` — normal precached offline page. +- `/offline/` — offline fallback page. +- `/admin/` — admin page excluded from precache; offline reload should show `/offline/`. +- `/progressive-cache/assets/` — page excluded from install-time precache but cached after the first online visit. +- `/progressive-cache/assets/details/` — second progressive-cache page with its own image subresource. +- `/progressive-cache/override/` — progressive-cache layout section page that opts back into precache. +- `/cache-inspector/` — diagnostic page that asks the service worker for cache contents. + +## Layout and page policy + +This example uses two user-facing manifest vars: + +```ts +manifestVars: ['offline', 'precache'] +``` + +The root layout defaults to `offline: true` and `precache: true`. +The progressive-cache layout defaults to `offline: true` and `precache: false`. +The admin layout defaults to `offline: false` and `precache: false`. + +The cascade is page → layout → global → default. +That means a layout can set a policy for a route section, and an individual page can still override that policy with `page.vars.ts` or frontmatter. + +The manifest settings also define one app-level fallback route: + +```ts +policy: { + offlineFallbackUrl: '/offline/', +} +``` + +The service worker uses that fallback for failed offline navigations instead of carrying route-specific fallback rules. + +## Progressive caching after first visit + +The `/progressive-cache/assets/` pages use the `progressive-cache` layout vars. +Those pages are available offline only after a successful online visit. + +To test it: + +1. Load `/progressive-cache/assets/` and `/progressive-cache/assets/details/` while online. +2. Switch DevTools to offline. +3. Reload those pages. +4. Their HTML and SVG image subresources should be served from the runtime cache. + +The `/progressive-cache/override/` page opts back into install-time precache from inside the progressive-cache section. + +## Client and service-worker structure + +The browser client and service worker are split by concern: + +```txt +src/ + globals/ + global.css + global.vars.ts + global-client/ + global.client.ts # bootstrap, config, and dependency wiring + connection-status.ts # online/offline detection + service-worker-events.ts # push/sync/periodic sync messages + service-worker-registration.ts # registration, updates, reload flow + service-worker-reset.ts # reset query param and watch cleanup + status-banner.ts # in-page status/indicator rendering + domstack-manifest/ + domstack-manifest.settings.ts # manifest vars, policy, include filter, and hook registration + policy-build.ts # injects finalized manifest data into /service-worker.js + service-worker/ + service-worker.ts # event wiring entrypoint + service-worker-settings.ts # shared app settings and app-defined types + background-events.ts # push/sync/periodic sync demo handlers + cache-inspection.ts # cache-inspector message handling + clients.ts # window client messaging helpers + fetch-handlers.ts # navigation and subresource fetch handling + lifecycle.ts # install/activate/reset/watch cleanup + precache.ts # manifest-entry-derived precache keys and cleanup + runtime-cache.ts # first-visit runtime caching +``` + +`global-client/global.client.ts` owns the example-specific browser wiring and creates the status banner. +The service-worker modules receive the config and policy they need as arguments. +This keeps lifecycle, connectivity state, recovery, fetch handling, and UI rendering separate enough to reuse or replace independently. + +## Push, sync, and periodic sync hooks + +`src/globals/service-worker/service-worker.ts` includes conservative handlers for: + +- `push` +- one-off Background Sync: `sync` +- Periodic Background Sync: `periodicsync` + +These handlers are extension points only. +They do not request notification permission, subscribe users to push, register sync jobs, or cache app data. +When triggered from DevTools, they post messages to open windows so the example can display/log that the service-worker event fired. +Push events show a notification only if the user has already granted notification permission. + +Full push/sync support is app-specific and usually also requires server-side push subscription storage, permission UX, retry policy, and privacy/security review. + +## Recovery paths + +For recoverable mistakes where pages still load, visit: + +```txt +/?reset-sw +``` + +The client unregisters service workers, deletes this example's offline caches, removes the query parameter, and reloads. + +For a truly bad service worker that breaks page loads, deploy `rescue-service-worker.js` at the exact production service-worker URL: + +```txt +/service-worker.js +``` + +This mirrors Workbox's recommended no-op recovery worker: same URL, immediate `skipWaiting()`, no `fetch` handler, and cache cleanup. + +## Research references + +If you are revisiting this example in a fresh context window, these are the docs and source files that informed the implementation. + +### Core docs + +- +- +- +- +- +- +- +- +- +- +- +- + +### Local context + +- `README.md` at the repo root — domstack manifest and first-class service-worker docs. +- `examples/static-mpa-offline/DESIGN.md` — design notes and rationale for this example. +- `examples/static-mpa-workbox-offline/README.md` — the same app structure implemented with Workbox caching APIs. +- `plans/domstack-manifest.md` — manifest hook and service-worker integration plan. +- `plans/standard-static-mpa-service-worker.md` — standard static MPA service-worker plan. +- `plans/workbox-workflow-integration.md` — implemented Workbox policy injection workflow. diff --git a/examples/static-mpa-offline/package.json b/examples/static-mpa-offline/package.json new file mode 100644 index 0000000..ff01975 --- /dev/null +++ b/examples/static-mpa-offline/package.json @@ -0,0 +1,27 @@ +{ + "name": "@domstack/static-mpa-offline-example", + "version": "0.0.0", + "description": "Static MPA offline service worker example for domstack", + "type": "module", + "imports": { + "#service-worker-settings": "./src/globals/service-worker/service-worker-settings.ts" + }, + "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 && domstack --watch" + }, + "keywords": [ + "domstack", + "service-worker", + "offline", + "pwa" + ], + "author": "Bret Comnes (https://bret.io/)", + "license": "MIT", + "dependencies": { + "@domstack/static": "file:../../." + } +} diff --git a/examples/static-mpa-offline/rescue-service-worker.js b/examples/static-mpa-offline/rescue-service-worker.js new file mode 100644 index 0000000..2510906 --- /dev/null +++ b/examples/static-mpa-offline/rescue-service-worker.js @@ -0,0 +1,31 @@ +// Emergency replacement for a bad production service worker. +// +// To use: deploy this file's contents at the SAME URL as the broken worker: +// /service-worker.js. Keeping the exact URL is critical; otherwise the broken +// registration will continue controlling its old scope. +// +// This follows Workbox's documented recovery approach: install and activate +// immediately, avoid a fetch handler entirely so requests pass through to the +// browser/network, and reload controlled windows once the no-op worker is active. + +const CACHE_PREFIXES = ['domstack-static-mpa-precache', 'domstack-static-mpa-runtime'] + +self.addEventListener('install', () => { + self.skipWaiting() +}) + +self.addEventListener('activate', event => { + event.waitUntil((async () => { + const cacheNames = await caches.keys() + await Promise.all( + cacheNames + .filter(name => CACHE_PREFIXES.some(prefix => name.startsWith(prefix))) + .map(name => caches.delete(name)) + ) + + const windowClients = await self.clients.matchAll({ type: 'window' }) + await Promise.all( + windowClients.map(windowClient => windowClient.navigate(windowClient.url)) + ) + })()) +}) diff --git a/examples/static-mpa-offline/src/about/client.ts b/examples/static-mpa-offline/src/about/client.ts new file mode 100644 index 0000000..b9e4533 --- /dev/null +++ b/examples/static-mpa-offline/src/about/client.ts @@ -0,0 +1,3 @@ +import { markPageClientLoaded } from '../mark-page-client-loaded.ts' + +markPageClientLoaded('about') diff --git a/examples/static-mpa-offline/src/about/page.md b/examples/static-mpa-offline/src/about/page.md new file mode 100644 index 0000000..941a71b --- /dev/null +++ b/examples/static-mpa-offline/src/about/page.md @@ -0,0 +1,6 @@ +# About the offline cache + +This page is a second static MPA route. It should be available after the service worker installs successfully. + +The worker intentionally avoids caching data/API requests. It focuses on the static application surface: HTML, CSS, JavaScript, chunks, workers, and selected static files. + diff --git a/examples/static-mpa-offline/src/about/style.css b/examples/static-mpa-offline/src/about/style.css new file mode 100644 index 0000000..6d94a4a --- /dev/null +++ b/examples/static-mpa-offline/src/about/style.css @@ -0,0 +1,5 @@ +@import "../page-style.css"; + +main { + --page-accent: seagreen; +} diff --git a/examples/static-mpa-offline/src/admin/client.ts b/examples/static-mpa-offline/src/admin/client.ts new file mode 100644 index 0000000..f813c0b --- /dev/null +++ b/examples/static-mpa-offline/src/admin/client.ts @@ -0,0 +1,3 @@ +import { markPageClientLoaded } from '../mark-page-client-loaded.ts' + +markPageClientLoaded('admin') diff --git a/examples/static-mpa-offline/src/admin/page.md b/examples/static-mpa-offline/src/admin/page.md new file mode 100644 index 0000000..7034526 --- /dev/null +++ b/examples/static-mpa-offline/src/admin/page.md @@ -0,0 +1,23 @@ +--- +title: Admin / network-only page +--- + +# Admin / network-only page + +This route demonstrates opting a section out of offline availability. + +Its sibling `page.vars.ts` selects the `admin` layout: + +```ts +export default { + layout: 'admin', +} +``` + +`src/layouts/admin.layout.ts` exports these layout vars: + +- `offline: false` +- `precache: false` + +So `/admin/` should load while online, but an offline reload should show the offline fallback page instead of this page. + diff --git a/examples/static-mpa-offline/src/admin/page.vars.ts b/examples/static-mpa-offline/src/admin/page.vars.ts new file mode 100644 index 0000000..da04b67 --- /dev/null +++ b/examples/static-mpa-offline/src/admin/page.vars.ts @@ -0,0 +1,5 @@ +import type { StaticMpaOfflinePageVars } from '#service-worker-settings' + +export default { + layout: 'admin', +} satisfies StaticMpaOfflinePageVars diff --git a/examples/static-mpa-offline/src/admin/style.css b/examples/static-mpa-offline/src/admin/style.css new file mode 100644 index 0000000..02dfb14 --- /dev/null +++ b/examples/static-mpa-offline/src/admin/style.css @@ -0,0 +1,5 @@ +@import "../page-style.css"; + +main { + --page-accent: crimson; +} diff --git a/examples/static-mpa-offline/src/cache-inspector/client.ts b/examples/static-mpa-offline/src/cache-inspector/client.ts new file mode 100644 index 0000000..f2e9ab0 --- /dev/null +++ b/examples/static-mpa-offline/src/cache-inspector/client.ts @@ -0,0 +1,58 @@ +/// + +const offlineCacheInspectorButton = document.querySelector('#inspect-caches') +const offlineCacheInspectorOutput = document.querySelector('#cache-inspection-output') + +offlineCacheInspectorButton?.addEventListener('click', async () => { + writeOutput('Requesting cache details from the service worker…') + + try { + writeOutput(JSON.stringify(await inspectOfflineExampleCaches(), null, 2)) + } catch (error) { + writeOutput(error instanceof Error ? error.message : String(error)) + } +}) + +function writeOutput (message: string): void { + if (!offlineCacheInspectorOutput) return + offlineCacheInspectorOutput.textContent = message +} + +async function inspectOfflineExampleCaches (): Promise { + if (!('serviceWorker' in navigator)) throw new Error('Service workers are not supported in this browser.') + if (!navigator.serviceWorker.controller) throw new Error('This page is not controlled by a service worker yet. Reload after the worker is ready.') + + const id = crypto.randomUUID() + + return await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + navigator.serviceWorker.removeEventListener('message', onMessage) + reject(new Error('Timed out waiting for service-worker cache details.')) + }, 5000) + + function onMessage (event: MessageEvent): void { + const message = event.data + if (!isOfflineCacheInspectionResult(message, id)) return + + window.clearTimeout(timeout) + navigator.serviceWorker.removeEventListener('message', onMessage) + resolve(message.payload) + } + + navigator.serviceWorker.addEventListener('message', onMessage) + navigator.serviceWorker.controller?.postMessage({ + id, + type: 'DOMSTACK_INSPECT_CACHES', + }) + }) +} + +function isOfflineCacheInspectionResult ( + message: unknown, + id: string +): message is { payload: unknown } { + if (!message || typeof message !== 'object') return false + return 'type' in message && message.type === 'DOMSTACK_CACHE_INSPECTION_RESULT' && + 'id' in message && message.id === id && + 'payload' in message +} diff --git a/examples/static-mpa-offline/src/cache-inspector/page.html b/examples/static-mpa-offline/src/cache-inspector/page.html new file mode 100644 index 0000000..8b102a5 --- /dev/null +++ b/examples/static-mpa-offline/src/cache-inspector/page.html @@ -0,0 +1,7 @@ +

Cache inspector

+ +

This page asks the active service worker for a diagnostic list of caches owned by this example.

+ + + +
Press the button to inspect cached files.
diff --git a/examples/static-mpa-offline/src/cache-inspector/page.vars.ts b/examples/static-mpa-offline/src/cache-inspector/page.vars.ts new file mode 100644 index 0000000..f2bfdf5 --- /dev/null +++ b/examples/static-mpa-offline/src/cache-inspector/page.vars.ts @@ -0,0 +1,5 @@ +import type { StaticMpaOfflinePageVars } from '#service-worker-settings' + +export default { + title: 'Cache inspector', +} satisfies StaticMpaOfflinePageVars diff --git a/examples/static-mpa-offline/src/cache-inspector/style.css b/examples/static-mpa-offline/src/cache-inspector/style.css new file mode 100644 index 0000000..023710a --- /dev/null +++ b/examples/static-mpa-offline/src/cache-inspector/style.css @@ -0,0 +1,5 @@ +@import "../page-style.css"; + +main { + --page-accent: rebeccapurple; +} diff --git a/examples/static-mpa-offline/src/client.ts b/examples/static-mpa-offline/src/client.ts new file mode 100644 index 0000000..b9cb513 --- /dev/null +++ b/examples/static-mpa-offline/src/client.ts @@ -0,0 +1,3 @@ +import { markPageClientLoaded } from './mark-page-client-loaded.ts' + +markPageClientLoaded('home') diff --git a/examples/static-mpa-offline/src/globals/domstack-manifest/domstack-manifest.settings.ts b/examples/static-mpa-offline/src/globals/domstack-manifest/domstack-manifest.settings.ts new file mode 100644 index 0000000..d057544 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/domstack-manifest/domstack-manifest.settings.ts @@ -0,0 +1,21 @@ +import type { DomstackManifestOptions } from '@domstack/static/types.js' +import type { + StaticMpaOfflineManifestVars, + StaticMpaOfflinePageVars, + StaticMpaOfflinePolicy, +} from '#service-worker-settings' +import { offlineFallbackUrl } from '#service-worker-settings' +import { emitServiceWorkerPolicy } from './policy-build.ts' + +const settings = { + manifestVars: ['offline', 'precache'], + policy: { + offlineFallbackUrl, + }, + hooks: { + manifestBuilt: [emitServiceWorkerPolicy], + }, + includeEntry: entry => entry.kind !== 'metadata' && entry.kind !== 'sourcemap', +} satisfies DomstackManifestOptions + +export default settings diff --git a/examples/static-mpa-offline/src/globals/domstack-manifest/policy-build.ts b/examples/static-mpa-offline/src/globals/domstack-manifest/policy-build.ts new file mode 100644 index 0000000..fc0c7af --- /dev/null +++ b/examples/static-mpa-offline/src/globals/domstack-manifest/policy-build.ts @@ -0,0 +1,33 @@ +import type { + DomstackManifest, + DomstackManifestBuiltHookContext, +} from '@domstack/static/types.js' +import type { + StaticMpaOfflineManifestVars, + StaticMpaOfflinePolicy, + StaticMpaOfflineServiceWorkerPolicy, +} from '#service-worker-settings' +import { + offlineFallbackUrl, + serviceWorkerPolicyDefineName, +} from '#service-worker-settings' + +/** Inject the final manifest data consumed by `/service-worker.js`. */ +export async function emitServiceWorkerPolicy ( + context: DomstackManifestBuiltHookContext +): Promise { + context.defineServiceWorkerConstant( + serviceWorkerPolicyDefineName, + toServiceWorkerPolicy(context.manifest) + ) +} + +function toServiceWorkerPolicy ( + manifest: DomstackManifest +): StaticMpaOfflineServiceWorkerPolicy { + return { + version: manifest.version, + entries: manifest.entries, + offlineFallbackUrl: manifest.policy?.offlineFallbackUrl ?? offlineFallbackUrl, + } +} diff --git a/examples/static-mpa-offline/src/globals/global-client/connection-status.ts b/examples/static-mpa-offline/src/globals/global-client/connection-status.ts new file mode 100644 index 0000000..f9379c3 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/global-client/connection-status.ts @@ -0,0 +1,172 @@ +import type { StatusBanner } from './status-banner.ts' + +/** + * Online/offline indicator state for cached navigations. + * + * This module mostly trusts `navigator.onLine`, but remembers when the current + * tab observed offline so cached page-to-page navigations do not incorrectly + * reset the indicator to online. While offline, it periodically probes the + * origin to detect recovery when browser `online` events are unreliable. + */ + +export type ConnectionStatusOptions = { + offlineRecheckIntervalMs: number + offlineStorageKey: string + onlineCheckTimeoutMs: number +} + +let connectionStatusCheck = 0 +let connectionStatusOnline = navigator.onLine +let offlineRecheckTimer: number | undefined +let onlineCheckPromise: Promise | undefined + +/** Register browser lifecycle listeners and render the initial connection state. */ +export function initializeConnectionStatus ( + status: Pick, + options: ConnectionStatusOptions +): void { + window.addEventListener('online', () => updateConnectionStatus(status, options)) + window.addEventListener('offline', () => updateConnectionStatus(status, options)) + window.addEventListener('focus', () => updateConnectionStatus(status, options)) + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + updateConnectionStatus(status, options) + } else { + stopOfflineRecheck() + } + }) + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => updateConnectionStatus(status, options), { once: true }) + } else { + updateConnectionStatus(status, options) + } +} + +/** Update the indicator from browser state, probing only after this tab observed offline. */ +function updateConnectionStatus ( + status: Pick, + options: ConnectionStatusOptions +): void { + if (!navigator.onLine) { + setConnectionStatus(status, options, false) + return + } + + if (wasOfflineInThisTab(options)) { + setConnectionStatus(status, options, false) + verifyOfflineRecovery(status, options) + return + } + + setConnectionStatus(status, options, true) +} + +/** Persist and render the current connection state, starting/stopping recovery checks. */ +function setConnectionStatus ( + status: Pick, + options: ConnectionStatusOptions, + online: boolean +): void { + connectionStatusOnline = online + rememberConnectionStatus(options, online) + status.showConnectionStatus(online) + + if (online) { + stopOfflineRecheck() + } else { + startOfflineRecheck(status, options) + } +} + +/** Start a low-frequency recovery probe while the tab is visible and marked offline. */ +function startOfflineRecheck ( + status: Pick, + options: ConnectionStatusOptions +): void { + if (offlineRecheckTimer !== undefined) return + if (document.visibilityState === 'hidden') return + + offlineRecheckTimer = window.setInterval(() => { + if (!connectionStatusOnline) verifyOfflineRecovery(status, options) + }, options.offlineRecheckIntervalMs) +} + +/** Stop the offline recovery probe. */ +function stopOfflineRecheck (): void { + if (offlineRecheckTimer === undefined) return + + window.clearInterval(offlineRecheckTimer) + offlineRecheckTimer = undefined +} + +/** Probe the origin once and flip online only if the network check succeeds. */ +function verifyOfflineRecovery ( + status: Pick, + options: ConnectionStatusOptions +): void { + const check = ++connectionStatusCheck + + verifyOnlineStatus(options).then(online => { + if (check !== connectionStatusCheck) return + if (online) setConnectionStatus(status, options, true) + }).catch(() => { + if (check !== connectionStatusCheck) return + setConnectionStatus(status, options, false) + }) +} + +/** Coalesce concurrent origin reachability checks into one in-flight request. */ +async function verifyOnlineStatus (options: ConnectionStatusOptions): Promise { + if (onlineCheckPromise) return onlineCheckPromise + + onlineCheckPromise = fetchOnlineStatus(options) + try { + return await onlineCheckPromise + } finally { + onlineCheckPromise = undefined + } +} + +/** Return whether this tab previously observed offline state across cached navigations. */ +function wasOfflineInThisTab (options: ConnectionStatusOptions): boolean { + try { + return window.sessionStorage.getItem(options.offlineStorageKey) === 'true' + } catch { + return !connectionStatusOnline + } +} + +/** Store offline state in sessionStorage so cached navigations preserve the indicator. */ +function rememberConnectionStatus (options: ConnectionStatusOptions, online: boolean): void { + try { + if (online) { + window.sessionStorage.removeItem(options.offlineStorageKey) + } else { + window.sessionStorage.setItem(options.offlineStorageKey, 'true') + } + } catch { + // Storage can be unavailable in private browsing or restrictive contexts. + } +} + +/** Fetch the origin with cache bypass to confirm network reachability after offline state. */ +async function fetchOnlineStatus (options: ConnectionStatusOptions): Promise { + const controller = new AbortController() + const timeout = window.setTimeout(() => controller.abort(), options.onlineCheckTimeoutMs) + + try { + const url = new URL(window.location.origin) + url.searchParams.set('__domstack_online_check', String(Date.now())) + + const response = await fetch(url.href, { + cache: 'no-store', + credentials: 'same-origin', + signal: controller.signal, + }) + + return response.ok + } finally { + window.clearTimeout(timeout) + } +} diff --git a/examples/static-mpa-offline/src/globals/global-client/global.client.ts b/examples/static-mpa-offline/src/globals/global-client/global.client.ts new file mode 100644 index 0000000..7382b35 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/global-client/global.client.ts @@ -0,0 +1,74 @@ +import { initializeConnectionStatus } from './connection-status.ts' +import { initializeServiceWorkerEventMessages } from './service-worker-events.ts' +import { registerServiceWorker } from './service-worker-registration.ts' +import { + disableServiceWorkerForWatchMode, + resetServiceWorkerFromWindow, + shouldResetServiceWorker, +} from './service-worker-reset.ts' +import { createStatusBanner } from './status-banner.ts' +import { + cachePrefixes, + offlineRecheckIntervalMs, + offlineStorageKey, + onlineCheckTimeoutMs, +} from '#service-worker-settings' + +/** + * Browser entrypoint for the offline static MPA example. + * + * This file owns example-specific config and wires independent modules together: + * connection status, service-worker registration, reset/watch cleanup, and the + * in-page status UI. The modules receive only the config/UI capabilities they need. + */ + +const connectionStatusOptions = { + offlineRecheckIntervalMs, + offlineStorageKey, + onlineCheckTimeoutMs, +} + +const serviceWorkerResetOptions = { + cachePrefixes: [...cachePrefixes], +} + +const manifestEnabled = process.env.DOMSTACK_MANIFEST_ENABLED === 'true' +const serviceWorkerScope = process.env.DOMSTACK_SERVICE_WORKER_SCOPE +const serviceWorkerUrl = process.env.DOMSTACK_SERVICE_WORKER_URL + +const status = createStatusBanner() + +initializeConnectionStatus(status, connectionStatusOptions) +initializeServiceWorkerEventMessages(status) + +if (serviceWorkerUrl && serviceWorkerScope && 'serviceWorker' in navigator) { + if (shouldResetServiceWorker()) { + try { + await resetServiceWorkerFromWindow(status, serviceWorkerResetOptions) + } catch (error) { + console.error('Service worker reset failed', error) + } + } else if (!manifestEnabled) { + try { + await disableServiceWorkerForWatchMode(status, serviceWorkerResetOptions) + } catch (error) { + console.error('Service worker watch-mode cleanup failed', error) + } + } else { + try { + await windowLoaded() + await registerServiceWorker(status, serviceWorkerUrl, serviceWorkerScope) + } catch (error) { + console.error('Service worker registration failed', error) + } + } +} + +/** Resolve after the load event so service-worker registration avoids competing with first paint. */ +async function windowLoaded (): Promise { + if (document.readyState === 'complete') return + + await new Promise(resolve => { + window.addEventListener('load', () => resolve(), { once: true }) + }) +} diff --git a/examples/static-mpa-offline/src/globals/global-client/service-worker-events.ts b/examples/static-mpa-offline/src/globals/global-client/service-worker-events.ts new file mode 100644 index 0000000..ea5c94f --- /dev/null +++ b/examples/static-mpa-offline/src/globals/global-client/service-worker-events.ts @@ -0,0 +1,43 @@ +import type { StatusBanner } from './status-banner.ts' + +/** + * Display optional service-worker background events in the example UI. + * + * The service worker can receive push, one-off sync, and periodic sync events. + * This module keeps those example-only messages separate from registration and + * update lifecycle code. + */ + +/** Listen for background-event messages from the service worker and render/log them. */ +export function initializeServiceWorkerEventMessages (status: Pick): void { + if (!('serviceWorker' in navigator)) return + + navigator.serviceWorker.addEventListener('message', event => { + const message = event.data + if (!isServiceWorkerEventMessage(message)) return + + if (message.type === 'DOMSTACK_PUSH_RECEIVED') { + console.info('Service worker push event received', message.payload) + status.showStatus('Push event received by the service worker.', 'info') + } + + if (message.type === 'DOMSTACK_SYNC_RECEIVED') { + console.info('Service worker sync event received', message.tag) + status.showStatus(`Sync event received: ${String(message.tag ?? 'untagged')}`, 'info') + } + + if (message.type === 'DOMSTACK_PERIODIC_SYNC_RECEIVED') { + console.info('Service worker periodic sync event received', message.tag) + status.showStatus(`Periodic sync event received: ${String(message.tag ?? 'untagged')}`, 'info') + } + }) +} + +/** Narrow structured-clone messages to the simple event shape this example understands. */ +function isServiceWorkerEventMessage (value: unknown): value is { + payload?: unknown + tag?: unknown + type: string +} { + return Boolean(value) && typeof value === 'object' && typeof (value as { type?: unknown }).type === 'string' +} diff --git a/examples/static-mpa-offline/src/globals/global-client/service-worker-registration.ts b/examples/static-mpa-offline/src/globals/global-client/service-worker-registration.ts new file mode 100644 index 0000000..574e443 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/global-client/service-worker-registration.ts @@ -0,0 +1,102 @@ +import type { StatusBanner } from './status-banner.ts' + +/** + * Service-worker registration and update UX for the static MPA example. + * + * This module owns browser registration, waiting-worker prompts, and the + * one-time reload after a user-approved update takes control. It receives a UI + * interface from the entrypoint instead of importing a banner implementation. + */ + +/** Register the service worker after page load and wire update lifecycle events. */ +export async function registerServiceWorker ( + status: Pick, + url: string, + scope: string +): Promise { + const registration = await navigator.serviceWorker.register(url, { + scope, + type: 'module', + updateViaCache: 'none', + }) + + status.showStatus('Installing offline cache…') + + if (registration.installing) { + trackInstallingWorker(status, registration.installing, registration) + } else if (registration.waiting && navigator.serviceWorker.controller) { + applyWaitingUpdate(status, registration, 'Applying offline update from previous visit…') + } else if (registration.active) { + status.showStatus('Offline cache is ready.', 'ready') + } + + registration.addEventListener('updatefound', () => { + const worker = registration.installing + if (worker) trackInstallingWorker(status, worker, registration) + }) + + let refreshing = false + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (refreshing) return + refreshing = true + window.location.reload() + }) +} + +/** Track an installing worker until it is ready, failed, or waiting for user action. */ +function trackInstallingWorker ( + status: Pick, + worker: ServiceWorker, + registration: ServiceWorkerRegistration +): void { + worker.addEventListener('statechange', () => { + if (worker.state === 'installed') { + if (navigator.serviceWorker.controller) { + promptForUpdate(status, registration) + } else { + status.showStatus('Offline cache is ready.', 'ready') + } + return + } + + if (worker.state === 'redundant') { + console.info('Service worker became redundant during registration.', worker) + } + }) +} + +/** Show the in-page update prompt for a newly installed waiting worker. */ +function promptForUpdate ( + status: Pick, + registration: ServiceWorkerRegistration +): void { + const reload = document.createElement('button') + reload.type = 'button' + reload.textContent = 'Reload now' + reload.addEventListener('click', () => { + reload.disabled = true + applyWaitingUpdate(status, registration, 'Updating offline cache…') + }) + + const later = document.createElement('button') + later.type = 'button' + later.textContent = 'Later' + later.addEventListener('click', () => { + status.showStatus('Update will be applied after all tabs are closed.', 'info') + }) + + status.showStatus('A new offline version is available.', 'update', [reload, later]) +} + +/** Ask the waiting worker to skip waiting; `controllerchange` will reload the page. */ +function applyWaitingUpdate ( + status: Pick, + registration: ServiceWorkerRegistration, + message: string +): void { + const worker = registration.waiting + if (!worker) return + + worker.postMessage({ type: 'SKIP_WAITING' }) + status.showStatus(message, 'update') +} diff --git a/examples/static-mpa-offline/src/globals/global-client/service-worker-reset.ts b/examples/static-mpa-offline/src/globals/global-client/service-worker-reset.ts new file mode 100644 index 0000000..32dd4c6 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/global-client/service-worker-reset.ts @@ -0,0 +1,71 @@ +import type { StatusBanner } from './status-banner.ts' + +/** + * Recovery and watch-mode cleanup helpers for sticky service-worker state. + * + * These paths intentionally live outside registration logic because they are + * destructive: they unregister workers, delete owned caches, and reload/navigate + * pages to escape stale or broken service-worker control. + */ + +export type ServiceWorkerResetOptions = { + cachePrefixes: string[] +} + +/** Return true when the current URL requests a client-side service-worker reset. */ +export function shouldResetServiceWorker (): boolean { + const params = new URLSearchParams(window.location.search) + return params.has('reset-sw') || params.has('reset-service-worker') +} + +/** Reset service workers/caches from a page that still loads enough JS to recover. */ +export async function resetServiceWorkerFromWindow ( + status: Pick, + options: ServiceWorkerResetOptions +): Promise { + status.showStatus('Resetting service worker and offline caches…', 'update') + + await unregisterServiceWorkersAndDeleteCaches(options) + + const url = new URL(window.location.href) + url.searchParams.delete('reset-sw') + url.searchParams.delete('reset-service-worker') + window.location.replace(url.href) +} + +/** Clear production SW state during watch mode, where no domstack manifest is emitted. */ +export async function disableServiceWorkerForWatchMode ( + status: Pick, + options: ServiceWorkerResetOptions +): Promise { + const hadController = Boolean(navigator.serviceWorker.controller) + await unregisterServiceWorkersAndDeleteCaches(options) + + if (hadController) { + status.showStatus('Service worker disabled in watch mode. Reloading without offline cache…', 'update') + window.location.reload() + return + } + + status.showStatus('Service worker disabled in watch mode. Use `npm run serve` to test offline caching.', 'info') +} + +/** Unregister all same-origin service workers and delete caches matching owned prefixes. */ +async function unregisterServiceWorkersAndDeleteCaches (options: ServiceWorkerResetOptions): Promise { + const registrations = await navigator.serviceWorker.getRegistrations() + for (const registration of registrations) { + registration.active?.postMessage({ type: 'RESET_SERVICE_WORKER' }) + registration.waiting?.postMessage({ type: 'RESET_SERVICE_WORKER' }) + registration.installing?.postMessage({ type: 'RESET_SERVICE_WORKER' }) + await registration.unregister() + } + + if ('caches' in window) { + const cacheNames = await caches.keys() + await Promise.all( + cacheNames + .filter(name => options.cachePrefixes.some(prefix => name.startsWith(prefix))) + .map(name => caches.delete(name)) + ) + } +} diff --git a/examples/static-mpa-offline/src/globals/global-client/status-banner.ts b/examples/static-mpa-offline/src/globals/global-client/status-banner.ts new file mode 100644 index 0000000..5c04574 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/global-client/status-banner.ts @@ -0,0 +1,73 @@ +/** + * Minimal status UI adapter used by the example client modules. + * + * Keeping this as a small interface lets lifecycle/connectivity modules receive + * UI capabilities as arguments instead of importing DOM rendering directly. + */ + +export type StatusBanner = { + showConnectionStatus (online: boolean): void + showStatus (message: string, state?: string, actions?: HTMLElement[]): void +} + +/** Create the default in-page banner implementation for this example. */ +export function createStatusBanner (): StatusBanner { + getStatusBanner() + + return { + showConnectionStatus, + showStatus, + } +} + +/** Render a status message and optional action buttons in the shared banner. */ +function showStatus (message: string, state = 'info', actions: HTMLElement[] = []): void { + const status = getStatusBanner() + + status.dataset.state = state + status.replaceChildren(getConnectionIndicator()) + + const text = document.createElement('span') + text.textContent = message + status.append(text) + + if (actions.length > 0) { + const actionList = document.createElement('span') + actionList.className = 'offline-status__actions' + actionList.append(...actions) + status.append(actionList) + } +} + +/** Render the online/offline badge without changing the current status message. */ +function showConnectionStatus (online: boolean): void { + const indicator = getConnectionIndicator() + indicator.dataset.state = online ? 'online' : 'offline' + indicator.textContent = online ? 'Online' : 'Offline' +} + +/** Return the shared status banner, creating it at the top of the body if needed. */ +function getStatusBanner (): HTMLElement { + let status = document.querySelector('.offline-status') + if (!status) { + status = document.createElement('div') + status.className = 'offline-status' + document.body.prepend(status) + } + + return status +} + +/** Return the connection badge, preserving it across banner re-renders. */ +function getConnectionIndicator (): HTMLElement { + let indicator = document.querySelector('.connection-status') + if (!indicator) { + indicator = document.createElement('span') + indicator.className = 'connection-status' + } + + const status = getStatusBanner() + if (!status.contains(indicator)) status.prepend(indicator) + + return indicator +} diff --git a/examples/static-mpa-offline/src/globals/global.css b/examples/static-mpa-offline/src/globals/global.css new file mode 100644 index 0000000..2ed5910 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/global.css @@ -0,0 +1,102 @@ +:root { + --offline-status-height: 4rem; + color-scheme: light dark; + font-family: system-ui, sans-serif; + line-height: 1.5; +} + +body { + margin: 0; +} + +body::before { + block-size: var(--offline-status-height); + content: ""; + display: block; + flex: none; +} + +main { + margin-inline: auto; + max-width: 42rem; + padding: 2rem; +} + +a { + color: #0b65c2; +} + +.example-navigation { + box-sizing: border-box; + margin-inline: auto; + max-width: 42rem; + padding: 1.5rem 2rem 0; +} + +.home-button { + border: 1px solid currentColor; + border-radius: 999px; + display: inline-block; + font-weight: 700; + padding: 0.375rem 0.75rem; + text-decoration: none; +} + +.home-button:hover { + text-decoration: underline; +} + +.offline-status { + align-items: center; + background: Canvas; + block-size: var(--offline-status-height); + border-block-end: 1px solid color-mix(in srgb, currentColor 20%, transparent); + box-sizing: border-box; + display: flex; + font-size: 0.875rem; + gap: 0.75rem; + inset-block-start: 0; + inset-inline: 0; + justify-content: space-between; + overflow: hidden; + padding: 0.75rem 2rem; + position: fixed; + z-index: 10; +} + +.connection-status { + border: 1px solid color-mix(in srgb, currentColor 35%, transparent); + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + padding: 0.125rem 0.5rem; +} + +.connection-status[data-state="online"] { + color: green; +} + +.connection-status[data-state="offline"] { + color: crimson; +} + +.offline-status__actions { + display: inline-flex; + gap: 0.5rem; +} + +.offline-status button { + cursor: pointer; +} + +.offline-status[data-state="ready"] { + background: color-mix(in srgb, green 14%, transparent); +} + +.offline-status[data-state="update"] { + background: color-mix(in srgb, orange 18%, transparent); +} + +.offline-status[data-state="error"] { + background: color-mix(in srgb, red 16%, transparent); +} diff --git a/examples/static-mpa-offline/src/globals/global.vars.ts b/examples/static-mpa-offline/src/globals/global.vars.ts new file mode 100644 index 0000000..662255b --- /dev/null +++ b/examples/static-mpa-offline/src/globals/global.vars.ts @@ -0,0 +1,5 @@ +import type { StaticMpaOfflinePageVars } from '#service-worker-settings' + +export default { + layout: 'root', +} satisfies StaticMpaOfflinePageVars diff --git a/examples/static-mpa-offline/src/globals/service-worker/background-events.ts b/examples/static-mpa-offline/src/globals/service-worker/background-events.ts new file mode 100644 index 0000000..49d6fc4 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/service-worker/background-events.ts @@ -0,0 +1,111 @@ +/// + +import { serviceWorkerNotificationTag } from '#service-worker-settings' +import { postToWindowClients } from './clients.ts' + +/** + * Optional background-event extension hooks for the offline example. + * + * These handlers intentionally do not subscribe users, request permissions, + * register sync jobs, or cache app data. They only surface events to open pages + * and, for push, show a notification when permission has already been granted. + * + * Related functions: + * - `handlePushEvent()` handles Push API events. + * - `handleSyncEvent()` handles one-off Background Sync events. + * - `handlePeriodicSyncEvent()` handles Periodic Background Sync events. + * + * MDN quick links: + * - Push API: https://developer.mozilla.org/en-US/docs/Web/API/Push_API + * - PushEvent: https://developer.mozilla.org/en-US/docs/Web/API/PushEvent + * - Notifications API: https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API + * - Background Synchronization API: https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API + * - Periodic Background Sync API: https://developer.mozilla.org/en-US/docs/Web/API/Web_Periodic_Background_Synchronization_API + */ + +declare const self: ServiceWorkerGlobalScope + +type ExtendableEventLike = Event & { + waitUntil (promise: Promise): void +} + +export type PushEventLike = ExtendableEventLike & { + data?: { + json (): unknown + text (): string + } +} + +export type SyncEventLike = ExtendableEventLike & { + tag?: string +} + +/** + * Handle a push payload by notifying open windows and optionally showing a notification. + * + * See MDN PushEvent: https://developer.mozilla.org/en-US/docs/Web/API/PushEvent + */ +export async function handlePushEvent (event: PushEventLike): Promise { + const payload = readPushPayload(event) + + await postToWindowClients({ + payload, + type: 'DOMSTACK_PUSH_RECEIVED', + }) + + if (!('Notification' in self) || Notification.permission !== 'granted') return + + const notification = normalizeNotificationPayload(payload) + await self.registration.showNotification(notification.title, { + body: notification.body, + data: notification.data, + tag: serviceWorkerNotificationTag, + }) +} + +/** Surface a one-off Background Sync event to open windows. */ +export async function handleSyncEvent (event: SyncEventLike): Promise { + await postToWindowClients({ + tag: event.tag ?? null, + type: 'DOMSTACK_SYNC_RECEIVED', + }) +} + +/** Surface a Periodic Background Sync event to open windows. */ +export async function handlePeriodicSyncEvent (event: SyncEventLike): Promise { + await postToWindowClients({ + tag: event.tag ?? null, + type: 'DOMSTACK_PERIODIC_SYNC_RECEIVED', + }) +} + +function readPushPayload (event: PushEventLike): unknown { + if (!event.data) return null + + try { + return event.data.json() + } catch { + return event.data.text() + } +} + +function normalizeNotificationPayload (payload: unknown): { + title: string + body?: string + data?: unknown +} { + if (payload && typeof payload === 'object') { + const record = payload as Record + return { + title: typeof record.title === 'string' ? record.title : 'Static MPA offline example', + body: typeof record.body === 'string' ? record.body : undefined, + data: payload, + } + } + + return { + title: 'Static MPA offline example', + body: typeof payload === 'string' ? payload : undefined, + data: payload, + } +} diff --git a/examples/static-mpa-offline/src/globals/service-worker/cache-inspection.ts b/examples/static-mpa-offline/src/globals/service-worker/cache-inspection.ts new file mode 100644 index 0000000..b890ef3 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/service-worker/cache-inspection.ts @@ -0,0 +1,93 @@ +/// + +/** + * Respond to demo cache-inspection requests from controlled pages. + * + * This is intentionally diagnostic-only: it exposes cache names, request URLs, + * response status/type, selected headers, and approximate body size for caches + * owned by this example service worker. + */ + +export type CacheInspectionConfig = { + cachePrefixes: string[] +} + +export function handleCacheInspectionMessage ( + config: CacheInspectionConfig, + event: ExtendableMessageEvent +): boolean { + if (event.data?.type !== 'DOMSTACK_INSPECT_CACHES') return false + + event.waitUntil((async () => { + const inspection = await inspectOwnedCaches(config) + event.source?.postMessage({ + id: event.data.id, + payload: inspection, + type: 'DOMSTACK_CACHE_INSPECTION_RESULT', + }) + })()) + + return true +} + +async function inspectOwnedCaches (config: CacheInspectionConfig): Promise { + const cacheNames = (await caches.keys()) + .filter(name => config.cachePrefixes.some(prefix => name.startsWith(prefix))) + .sort() + + return { + generatedAt: new Date().toISOString(), + caches: await Promise.all(cacheNames.map(inspectCache)), + } +} + +async function inspectCache (name: string): Promise { + const cache = await caches.open(name) + const requests = await cache.keys() + const entries = await Promise.all(requests.map(request => inspectCacheEntry(cache, request))) + + return { + name, + entries: entries.sort((a, b) => a.url.localeCompare(b.url)), + } +} + +async function inspectCacheEntry (cache: Cache, request: Request): Promise<{ + bodyBytes: number | null + contentLength: string | null + contentType: string | null + status: number + type: ResponseType + url: string +}> { + const response = await cache.match(request) + if (!response) { + return { + bodyBytes: null, + contentLength: null, + contentType: null, + status: 0, + type: 'error', + url: request.url, + } + } + + const bodyBytes = await estimateBodyBytes(response) + + return { + bodyBytes, + contentLength: response.headers.get('content-length'), + contentType: response.headers.get('content-type'), + status: response.status, + type: response.type, + url: request.url, + } +} + +async function estimateBodyBytes (response: Response): Promise { + try { + return (await response.clone().arrayBuffer()).byteLength + } catch { + return null + } +} diff --git a/examples/static-mpa-offline/src/globals/service-worker/clients.ts b/examples/static-mpa-offline/src/globals/service-worker/clients.ts new file mode 100644 index 0000000..13773bf --- /dev/null +++ b/examples/static-mpa-offline/src/globals/service-worker/clients.ts @@ -0,0 +1,31 @@ +/// + +/** + * Window-client helpers for service-worker-to-page communication. + * + * Related functions: + * - `postToWindowClients()` broadcasts background event messages to open pages. + * + * MDN quick links: + * - Clients: https://developer.mozilla.org/en-US/docs/Web/API/Clients + * - Client: https://developer.mozilla.org/en-US/docs/Web/API/Client + * - WindowClient: https://developer.mozilla.org/en-US/docs/Web/API/WindowClient + */ + +declare const self: ServiceWorkerGlobalScope + +/** + * Broadcast a structured-cloneable message to every same-origin window client. + * + * See MDN Client.postMessage(): https://developer.mozilla.org/en-US/docs/Web/API/Client/postMessage + */ +export async function postToWindowClients (message: Record): Promise { + const clients = await self.clients.matchAll({ + includeUncontrolled: true, + type: 'window', + }) + + for (const client of clients) { + client.postMessage(message) + } +} diff --git a/examples/static-mpa-offline/src/globals/service-worker/fetch-handlers.ts b/examples/static-mpa-offline/src/globals/service-worker/fetch-handlers.ts new file mode 100644 index 0000000..2bf26f7 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/service-worker/fetch-handlers.ts @@ -0,0 +1,168 @@ +/// + +import { + getRuntimeStrategy, + getServiceWorkerPolicyEntry, + matchInPrecache, +} from './precache.ts' +import { + cacheFirstRuntimeCache, + networkFirstRuntimeCache, +} from './runtime-cache.ts' +import type { + ActiveServiceWorkerConfig, + StaticMpaOfflineServiceWorkerPolicyEntry, +} from '#service-worker-settings' + +/** + * Fetch-event routing for navigations and static subresources. + * + * Related functions: + * - `handleFetchEvent()` is the event-level router. + * - `handleNavigation()` handles MPA navigations and offline fallback policy. + * - `cacheFirst()` serves static subresources from precache when available. + * - `navigationCandidates()` mirrors Workbox-like URL variations for static MPAs. + * + * MDN quick links: + * - FetchEvent: https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent + * - respondWith(): https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent/respondWith + * - preloadResponse: https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent/preloadResponse + * - Request.destination: https://developer.mozilla.org/en-US/docs/Web/API/Request/destination + */ + +declare const self: ServiceWorkerGlobalScope + +/** + * Route only same-origin GET requests that this service worker owns. + * + * See MDN `FetchEvent.respondWith()` constraints: + * https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent/respondWith + */ +export function handleFetchEvent (config: ActiveServiceWorkerConfig, event: FetchEvent): void { + const { request } = event + + if (request.method !== 'GET') return + + const url = new URL(request.url) + if (url.origin !== self.location.origin) return + + if (isDevelopmentRequest(url)) return + + if (request.mode === 'navigate') { + event.respondWith(handleNavigation(config, request, event.preloadResponse)) + return + } + + event.respondWith(handleSubresource(config, request)) +} + +/** Serve cached navigations first, then network/preload, then configured offline fallback. */ +async function handleNavigation ( + config: ActiveServiceWorkerConfig, + request: Request, + preloadResponsePromise: Promise +): Promise { + const cached = await matchNavigation(config, request) + if (cached) return cached + + try { + if (shouldRuntimeCacheRequest(config, request)) { + return await networkFirstRuntimeCache(config, request, preloadResponsePromise) + } + + const preloadResponse = await preloadResponsePromise + if (preloadResponse) return preloadResponse + + return await fetch(request) + } catch (error) { + const fallback = await matchInPrecache(config, config.policy.offlineFallbackUrl) + if (fallback) return fallback + + throw error + } +} + +/** Apply route policy to a non-navigation request before falling back to static cache-first handling. */ +async function handleSubresource (config: ActiveServiceWorkerConfig, request: Request): Promise { + if (shouldRuntimeCacheRequest(config, request)) { + return await cacheFirstRuntimeCache(config, request) + } + + return await cacheFirstStaticSubresource(config, request) +} + +/** Serve manifest-known static subresources from precache, otherwise fall through to network. */ +async function cacheFirstStaticSubresource (config: ActiveServiceWorkerConfig, request: Request): Promise { + const cached = await matchInPrecache(config, request.url) + if (cached) return cached + + try { + return await fetch(request) + } catch { + return Response.error() + } +} + +/** Match a navigation against canonical static-MPA URL variants in the precache. */ +async function matchNavigation (config: ActiveServiceWorkerConfig, request: Request): Promise { + for (const url of navigationCandidates(new URL(request.url))) { + const cached = await matchInPrecache(config, url) + if (cached) return cached + } + + return undefined +} + +/** Generate static MPA URL candidates, similar to Workbox's precache URL variations. */ +function navigationCandidates (url: URL): string[] { + const withoutIgnoredParams = new URL(url.href) + withoutIgnoredParams.hash = '' + for (const param of Array.from(withoutIgnoredParams.searchParams.keys())) { + if (/^utm_/.test(param) || param === 'fbclid') { + withoutIgnoredParams.searchParams.delete(param) + } + } + + const pathname = withoutIgnoredParams.pathname + const candidates = new Set() + + candidates.add(withoutIgnoredParams.pathname + withoutIgnoredParams.search) + + if (pathname.endsWith('/')) { + candidates.add(pathname + 'index.html') + } else { + candidates.add(pathname + '/') + candidates.add(pathname + '.html') + candidates.add(pathname + '/index.html') + } + + if (pathname === '/') candidates.add('/index.html') + + return Array.from(candidates) +} + +/** Ignore local development-server requests that should never be cached. */ +function isDevelopmentRequest (url: URL): boolean { + return url.pathname.startsWith('/__bs/') || + url.pathname.startsWith('/browser-sync/') || + url.pathname === '/browser-sync-client.js' +} + +/** Return whether the request inherits an explicit runtime-cache route policy. */ +function shouldRuntimeCacheRequest (config: ActiveServiceWorkerConfig, request: Request): boolean { + const policyEntry = getRequestPolicyEntry(config, request) + return policyEntry ? getRuntimeStrategy(config, policyEntry) === 'runtime' : false +} + +/** Resolve policy directly for an output, or inherit policy from its same-origin referrer page. */ +function getRequestPolicyEntry (config: ActiveServiceWorkerConfig, request: Request): StaticMpaOfflineServiceWorkerPolicyEntry | undefined { + const directEntry = getServiceWorkerPolicyEntry(config, request.url) + if (directEntry) return directEntry + + if (!request.referrer) return undefined + + const referrer = new URL(request.referrer) + if (referrer.origin !== self.location.origin) return undefined + + return getServiceWorkerPolicyEntry(config, referrer.href) +} diff --git a/examples/static-mpa-offline/src/globals/service-worker/lifecycle.ts b/examples/static-mpa-offline/src/globals/service-worker/lifecycle.ts new file mode 100644 index 0000000..88ccf8c --- /dev/null +++ b/examples/static-mpa-offline/src/globals/service-worker/lifecycle.ts @@ -0,0 +1,47 @@ +/// + +import { + activatePrecache, + deleteOwnedCaches, +} from './precache.ts' +import type { + ActiveServiceWorkerConfig, + ServiceWorkerConfig, +} from '#service-worker-settings' + +/** + * Service-worker lifecycle helpers that are independent of fetch routing. + * + * Related functions: + * - `activateWorker()` enables navigation preload, cleans precache state, and claims clients. + * - `resetServiceWorker()` clears owned caches and unregisters this worker. + * + * MDN quick links: + * - install event: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/install_event + * - activate event: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/activate_event + * - Clients.claim(): https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim + * - NavigationPreloadManager: https://developer.mozilla.org/en-US/docs/Web/API/NavigationPreloadManager + */ + +declare const self: ServiceWorkerGlobalScope + +/** + * Complete activation work after install succeeds. + * + * `clients.claim()` lets this active worker control existing clients: + * https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim + */ +export async function activateWorker (config: ActiveServiceWorkerConfig): Promise { + if (self.registration.navigationPreload) { + await self.registration.navigationPreload.enable() + } + + await activatePrecache(config) + await self.clients.claim() +} + +/** Remove this worker and its owned caches. */ +export async function resetServiceWorker (config: ServiceWorkerConfig): Promise { + await deleteOwnedCaches(config) + await self.registration.unregister() +} diff --git a/examples/static-mpa-offline/src/globals/service-worker/precache.ts b/examples/static-mpa-offline/src/globals/service-worker/precache.ts new file mode 100644 index 0000000..6372167 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/service-worker/precache.ts @@ -0,0 +1,205 @@ +/// + +import { + maxPrecacheBytes, + revisionParam, +} from '#service-worker-settings' +import type { + ActiveServiceWorkerConfig, + ServiceWorkerConfig, + StaticMpaOfflinePrecacheEntry, + StaticMpaOfflineRuntimeStrategy, + StaticMpaOfflineServiceWorkerPolicy, + StaticMpaOfflineServiceWorkerPolicyEntry, +} from '#service-worker-settings' + +/** + * Injected-manifest-driven precache implementation for the static MPA example. + * + * The service worker consumes Domstack manifest entries directly and derives the + * small amount of cache behavior it needs from their resolved manifest vars. + */ + +declare const self: ServiceWorkerGlobalScope + +type NavigationRoutePolicy = { + offline: boolean + pathname: string + precache: boolean +} + +let currentPolicyVersion: string | undefined +let currentUrlToEntry: Map | undefined +let currentNavigationRoutes: NavigationRoutePolicy[] | undefined + +/** Install-time precache step. Rejecting here keeps the old worker active. */ +export async function installPrecache (config: ActiveServiceWorkerConfig): Promise { + const cache = await caches.open(config.precacheName) + + for (const entry of precacheEntries(config)) { + const cacheKey = precacheKey(entry) + const cached = await cache.match(cacheKey) + if (cached) continue + + const request = new Request(entry.url, { + cache: 'reload', + credentials: 'same-origin', + }) + const assetResponse = await fetch(request) + + if (!assetResponse.ok) { + throw new Error(`Refusing to precache ${entry.url}: ${assetResponse.status}`) + } + + await cache.put(cacheKey, assetResponse) + } + + await caches.delete(config.runtimeCacheName) + await deleteOutdatedPrecacheEntries(config) + setCurrentPolicy(config.policy) +} + +/** Activation-time cleanup for legacy caches and outdated revisioned entries. */ +export async function activatePrecache (config: ActiveServiceWorkerConfig): Promise { + const cacheNames = await caches.keys() + for (const name of cacheNames) { + if (name.startsWith(config.precacheName) && name !== config.precacheName) { + await caches.delete(name) + } + } + + await deleteOutdatedPrecacheEntries(config) +} + +/** Match a public URL against the current precache policy and cache storage. */ +export async function matchInPrecache (config: ActiveServiceWorkerConfig, url: string): Promise { + const entry = getServiceWorkerPolicyEntry(config, url) + if (!entry || !shouldPrecache(config, entry)) return undefined + + const cache = await caches.open(config.precacheName) + return cache.match(precacheKey(entry)) +} + +/** Return a manifest entry for a public URL. */ +export function getServiceWorkerPolicyEntry ( + config: ActiveServiceWorkerConfig, + url: string +): StaticMpaOfflineServiceWorkerPolicyEntry | undefined { + ensurePolicyMaps(config.policy) + return currentUrlToEntry?.get(normalizeCacheUrl(url)) +} + +/** Return the runtime strategy derived from a manifest entry and its route policy. */ +export function getRuntimeStrategy ( + config: ActiveServiceWorkerConfig, + entry: StaticMpaOfflineServiceWorkerPolicyEntry +): StaticMpaOfflineRuntimeStrategy | undefined { + const policy = entry.role === 'navigation' + ? navigationPolicy(entry) + : routePolicyForUrl(config.policy, entry.url) + + if (!policy) return undefined + if (!policy.offline) return 'network-only' + if (!policy.precache) return 'runtime' + return undefined +} + +/** Delete all cache names owned by this example's service worker. */ +export async function deleteOwnedCaches (config: ServiceWorkerConfig): Promise { + const cacheNames = await caches.keys() + for (const name of cacheNames) { + if (config.cachePrefixes.some(prefix => name.startsWith(prefix))) { + await caches.delete(name) + } + } +} + +async function deleteOutdatedPrecacheEntries (config: ActiveServiceWorkerConfig): Promise { + const expectedCacheKeys = new Set(precacheEntries(config).map(entry => normalizeCacheUrl(precacheKey(entry)))) + const cache = await caches.open(config.precacheName) + const requests = await cache.keys() + + for (const request of requests) { + if (!expectedCacheKeys.has(request.url)) { + await cache.delete(request) + } + } +} + +function setCurrentPolicy (policy: StaticMpaOfflineServiceWorkerPolicy): void { + currentPolicyVersion = policy.version + currentUrlToEntry = new Map( + policy.entries.map(entry => [normalizeCacheUrl(entry.url), entry]) + ) + currentNavigationRoutes = navigationRoutes(policy) +} + +function ensurePolicyMaps (policy: StaticMpaOfflineServiceWorkerPolicy): void { + if (currentPolicyVersion === policy.version) return + setCurrentPolicy(policy) +} + +function precacheEntries (config: ActiveServiceWorkerConfig): StaticMpaOfflinePrecacheEntry[] { + return config.policy.entries.filter(entry => shouldPrecache(config, entry)) +} + +function shouldPrecache ( + config: ActiveServiceWorkerConfig, + entry: StaticMpaOfflineServiceWorkerPolicyEntry +): entry is StaticMpaOfflinePrecacheEntry { + if (!entry.revision) return false + if (entry.bytes && entry.bytes > maxPrecacheBytes) return false + if (entry.static !== true) return false + if (entry.kind === 'chunk') return true + if (getRuntimeStrategy(config, entry)) return false + if (entry.role === 'subresource') return true + return entry.manifestVars?.precache === true +} + +function navigationRoutes (policy: StaticMpaOfflineServiceWorkerPolicy): NavigationRoutePolicy[] { + return policy.entries + .filter(entry => entry.role === 'navigation' && entry.manifestVars) + .map(entry => navigationPolicy(entry)) + .filter(route => route !== undefined) + .sort((a, b) => b.pathname.length - a.pathname.length) +} + +function navigationPolicy (entry: StaticMpaOfflineServiceWorkerPolicyEntry): NavigationRoutePolicy | undefined { + if (!entry.manifestVars) return undefined + return { + offline: entry.manifestVars.offline === true, + pathname: pathname(entry.url), + precache: entry.manifestVars.precache === true, + } +} + +function routePolicyForUrl ( + policy: StaticMpaOfflineServiceWorkerPolicy, + url: string +): NavigationRoutePolicy | undefined { + ensurePolicyMaps(policy) + const requestPathname = pathname(url) + return currentNavigationRoutes?.find(route => routeContains(route.pathname, requestPathname)) +} + +function routeContains (routePathname: string, requestPathname: string): boolean { + if (routePathname === '/') return requestPathname === '/' + return requestPathname === routePathname || requestPathname.startsWith(routePathname) +} + +function pathname (url: string): string { + return new URL(url, self.location.origin).pathname +} + +function precacheKey (entry: StaticMpaOfflinePrecacheEntry): string { + const url = new URL(entry.url, self.location.origin) + url.hash = '' + if (!entry.urlRevisioned) url.searchParams.set(revisionParam, entry.revision) + return url.pathname + url.search +} + +function normalizeCacheUrl (url: string): string { + const normalized = new URL(url, self.location.origin) + normalized.hash = '' + return normalized.href +} diff --git a/examples/static-mpa-offline/src/globals/service-worker/runtime-cache.ts b/examples/static-mpa-offline/src/globals/service-worker/runtime-cache.ts new file mode 100644 index 0000000..c27f877 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/service-worker/runtime-cache.ts @@ -0,0 +1,86 @@ +/// + +import type { ServiceWorkerConfig } from '#service-worker-settings' + +/** + * Runtime cache for demo routes that intentionally are not precached. + * + * The static MPA example uses this to show a second offline pattern: a page and + * its same-route subresources can be omitted from install-time precache, then + * learned after the user visits them online. + */ + +/** Serve from network first and store successful responses for later offline visits. */ +export async function networkFirstRuntimeCache ( + config: ServiceWorkerConfig, + request: Request, + preloadResponsePromise?: Promise +): Promise { + const cached = await matchInRuntimeCache(config, request) + + try { + const preloadResponse = preloadResponsePromise ? await preloadResponsePromise : undefined + if (preloadResponse) { + await cacheRuntimeResponse(config, request, preloadResponse.clone()) + return preloadResponse + } + + const response = await fetchRuntimeRequest(request) + await cacheRuntimeResponse(config, request, response.clone()) + return response + } catch (error) { + if (cached) return cached + throw error + } +} + +/** Serve from runtime cache first, fetching and storing the response on misses. */ +export async function cacheFirstRuntimeCache ( + config: ServiceWorkerConfig, + request: Request +): Promise { + const cached = await matchInRuntimeCache(config, request) + if (cached) return cached + + try { + const response = await fetchRuntimeRequest(request) + await cacheRuntimeResponse(config, request, response.clone()) + return response + } catch { + return Response.error() + } +} + +async function matchInRuntimeCache (config: ServiceWorkerConfig, request: Request): Promise { + const cache = await caches.open(config.runtimeCacheName) + return cache.match(normalizeRuntimeRequest(request)) +} + +async function cacheRuntimeResponse ( + config: ServiceWorkerConfig, + request: Request, + response: Response +): Promise { + if (!response.ok) return + if (response.type !== 'basic') return + + const cache = await caches.open(config.runtimeCacheName) + await cache.put(normalizeRuntimeRequest(request), response) +} + +function fetchRuntimeRequest (request: Request): Promise { + return fetch(new Request(request.url, { + cache: 'reload', + credentials: 'same-origin', + method: 'GET', + })) +} + +function normalizeRuntimeRequest (request: Request): Request { + const url = new URL(request.url) + url.hash = '' + return new Request(url.href, { + credentials: 'same-origin', + method: 'GET', + }) +} diff --git a/examples/static-mpa-offline/src/globals/service-worker/service-worker-settings.ts b/examples/static-mpa-offline/src/globals/service-worker/service-worker-settings.ts new file mode 100644 index 0000000..9aa86e0 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/service-worker/service-worker-settings.ts @@ -0,0 +1,53 @@ +import type { DomstackManifestEntry } from '@domstack/static/types.js' + +export const cachePrefix = 'domstack-static-mpa' +export const maxPrecacheBytes = 2 * 1024 * 1024 +export const offlineFallbackUrl = '/offline/' +export const offlineRecheckIntervalMs = 5000 +export const offlineStorageKey = `${cachePrefix}-offline` +export const onlineCheckTimeoutMs = 3000 +export const precacheName = `${cachePrefix}-precache` +export const revisionParam = '__DOMSTACK_REVISION__' +export const runtimeCacheName = `${cachePrefix}-runtime` +export const serviceWorkerNotificationTag = 'domstack-static-mpa-offline' +export const serviceWorkerPolicyDefineName = '__DOMSTACK_SERVICE_WORKER_POLICY__' + +export const cachePrefixes = [precacheName, runtimeCacheName] as const + +export type StaticMpaOfflineRuntimeStrategy = 'network-only' | 'runtime' + +export type StaticMpaOfflineManifestVars = { + offline?: boolean + precache?: boolean +} + +export type StaticMpaOfflinePageVars = StaticMpaOfflineManifestVars & { + layout?: 'root' | 'admin' | 'progressive-cache' + title?: string +} + +export type StaticMpaOfflinePolicy = { + offlineFallbackUrl: string +} + +export type ServiceWorkerConfig = { + cachePrefixes: string[] + precacheName: string + runtimeCacheName: string +} + +export type ActiveServiceWorkerConfig = ServiceWorkerConfig & { + policy: StaticMpaOfflineServiceWorkerPolicy +} + +export type StaticMpaOfflineServiceWorkerPolicy = { + version: string + entries: StaticMpaOfflineServiceWorkerPolicyEntry[] + offlineFallbackUrl: string +} + +export type StaticMpaOfflineServiceWorkerPolicyEntry = DomstackManifestEntry + +export type StaticMpaOfflinePrecacheEntry = StaticMpaOfflineServiceWorkerPolicyEntry & { + revision: string +} diff --git a/examples/static-mpa-offline/src/globals/service-worker/service-worker.ts b/examples/static-mpa-offline/src/globals/service-worker/service-worker.ts new file mode 100644 index 0000000..881bc34 --- /dev/null +++ b/examples/static-mpa-offline/src/globals/service-worker/service-worker.ts @@ -0,0 +1,105 @@ +/// + +import { handleCacheInspectionMessage } from './cache-inspection.ts' +import { + handlePeriodicSyncEvent, + handlePushEvent, + handleSyncEvent, + type PushEventLike, + type SyncEventLike, +} from './background-events.ts' +import { handleFetchEvent } from './fetch-handlers.ts' +import { + activateWorker, + resetServiceWorker, +} from './lifecycle.ts' +import { installPrecache } from './precache.ts' +import { + cachePrefixes, + precacheName, + runtimeCacheName, +} from '#service-worker-settings' +import type { + ActiveServiceWorkerConfig, + ServiceWorkerConfig, + StaticMpaOfflineServiceWorkerPolicy, +} from '#service-worker-settings' + +/** + * Static MPA service-worker entrypoint. + * + * This file owns example-specific config and event wiring. Implementation + * details live in focused modules so caching, fetch routing, lifecycle reset, + * client messaging, and optional background events can evolve independently. + * + * MDN quick links: + * - Service Worker API: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API + * - ServiceWorkerGlobalScope: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope + * - ExtendableEvent.waitUntil(): https://developer.mozilla.org/en-US/docs/Web/API/ExtendableEvent/waitUntil + * - skipWaiting(): https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/skipWaiting + */ + +declare const self: ServiceWorkerGlobalScope + +declare const __DOMSTACK_SERVICE_WORKER_POLICY__: StaticMpaOfflineServiceWorkerPolicy + +const config: ServiceWorkerConfig = { + cachePrefixes: [...cachePrefixes], + precacheName, + runtimeCacheName, +} + +const policy = typeof __DOMSTACK_SERVICE_WORKER_POLICY__ === 'undefined' + ? undefined + : __DOMSTACK_SERVICE_WORKER_POLICY__ + +if (policy) { + const activeConfig: ActiveServiceWorkerConfig = { ...config, policy } + + self.addEventListener('install', event => { + event.waitUntil(installPrecache(activeConfig)) + }) + + self.addEventListener('activate', event => { + event.waitUntil(activateWorker(activeConfig)) + }) + + self.addEventListener('message', event => { + if (handleCacheInspectionMessage(activeConfig, event)) return + + if (event.data?.type === 'SKIP_WAITING') { + event.waitUntil(self.skipWaiting()) + return + } + + if (event.data?.type === 'RESET_SERVICE_WORKER') { + event.waitUntil(resetServiceWorker(activeConfig)) + } + }) + + self.addEventListener('fetch', event => { + handleFetchEvent(activeConfig, event) + }) + + self.addEventListener('push', event => { + event.waitUntil(handlePushEvent(event as PushEventLike)) + }) + + self.addEventListener('sync', ((event: Event) => { + const syncEvent = event as SyncEventLike + syncEvent.waitUntil(handleSyncEvent(syncEvent)) + }) as EventListener) + + self.addEventListener('periodicsync', ((event: Event) => { + const periodicSyncEvent = event as SyncEventLike + periodicSyncEvent.waitUntil(handlePeriodicSyncEvent(periodicSyncEvent)) + }) as EventListener) +} else { + self.addEventListener('install', () => { + self.skipWaiting() + }) + + self.addEventListener('activate', event => { + event.waitUntil(resetServiceWorker(config)) + }) +} diff --git a/examples/static-mpa-offline/src/layouts/admin.layout.ts b/examples/static-mpa-offline/src/layouts/admin.layout.ts new file mode 100644 index 0000000..4236994 --- /dev/null +++ b/examples/static-mpa-offline/src/layouts/admin.layout.ts @@ -0,0 +1,33 @@ +import type { HtmlResult } from 'fragtml/types.ts' +import type { LayoutFunction } from '@domstack/static/types.js' +import type { StaticMpaOfflinePageVars } from '#service-worker-settings' +import { renderPolicyLayout } from './render-policy-layout.ts' + +/** + * Layout for protected/admin routes. + * + * These pages are marked network-only by default so the service worker does not + * store or replay potentially private content while offline. + */ +export const vars = { + offline: false, + precache: false, +} satisfies StaticMpaOfflinePageVars + +/** Render admin page chrome while reusing the shared demo layout template. */ +const adminLayout: LayoutFunction, string | HtmlResult, string> = ({ + children, + scripts, + styles, + vars, +}) => { + return renderPolicyLayout({ + bodyClass: 'policy-layout policy-layout--admin', + children, + scripts, + styles, + title: vars.title ?? 'Admin route', + }) +} + +export default adminLayout diff --git a/examples/static-mpa-offline/src/layouts/progressive-cache.layout.ts b/examples/static-mpa-offline/src/layouts/progressive-cache.layout.ts new file mode 100644 index 0000000..9961ff4 --- /dev/null +++ b/examples/static-mpa-offline/src/layouts/progressive-cache.layout.ts @@ -0,0 +1,33 @@ +import type { HtmlResult } from 'fragtml/types.ts' +import type { LayoutFunction } from '@domstack/static/types.js' +import type { StaticMpaOfflinePageVars } from '#service-worker-settings' +import { renderPolicyLayout } from './render-policy-layout.ts' + +/** + * Layout for pages that should become available offline only after a visit. + * + * The route is allowed offline, but is not install-time precached. The service + * worker learns the page and same-route subresources through runtime caching. + */ +export const vars = { + offline: true, + precache: false, +} satisfies StaticMpaOfflinePageVars + +/** Render progressive-cache page chrome while applying runtime-cache policy defaults. */ +const progressiveCacheLayout: LayoutFunction, string | HtmlResult, string> = ({ + children, + scripts, + styles, + vars, +}) => { + return renderPolicyLayout({ + bodyClass: 'policy-layout policy-layout--progressive-cache', + children, + scripts, + styles, + title: vars.title ?? 'Progressive cache route', + }) +} + +export default progressiveCacheLayout diff --git a/examples/static-mpa-offline/src/layouts/render-policy-layout.ts b/examples/static-mpa-offline/src/layouts/render-policy-layout.ts new file mode 100644 index 0000000..61da190 --- /dev/null +++ b/examples/static-mpa-offline/src/layouts/render-policy-layout.ts @@ -0,0 +1,51 @@ +import { html, raw, render } from 'fragtml' +import type { HtmlResult } from 'fragtml/types.ts' + +/** + * Shared HTML shell used by the offline-policy demo layouts. + * + * Individual layouts provide policy vars and body classes; this helper owns the + * common document structure, stylesheet/script tags, home navigation, and child + * rendering for both Markdown and HTML page sources. + */ +export function renderPolicyLayout ({ + bodyClass, + children, + scripts, + styles, + title, +}: { + bodyClass: string + children: string | HtmlResult + scripts?: string[] + styles?: string[] + title: unknown +}): string { + const head = html` + + + + ${String(title)} + ${styles?.map(style => html``)} + ${scripts?.map(script => html``)} + + ` + + const body = html` + + +
+ ${typeof children === 'string' ? raw(children) : children} +
+ + ` + + return render(html` + + + ${head} + ${body} + `) +} diff --git a/examples/static-mpa-offline/src/layouts/root.layout.ts b/examples/static-mpa-offline/src/layouts/root.layout.ts new file mode 100644 index 0000000..949ef31 --- /dev/null +++ b/examples/static-mpa-offline/src/layouts/root.layout.ts @@ -0,0 +1,33 @@ +import type { HtmlResult } from 'fragtml/types.ts' +import type { LayoutFunction } from '@domstack/static/types.js' +import type { StaticMpaOfflinePageVars } from '#service-worker-settings' +import { renderPolicyLayout } from './render-policy-layout.ts' + +/** + * Default layout for public offline-first pages. + * + * Pages using this layout are install-time precached by default, so they should + * be safe to serve immediately while offline. + */ +export const vars = { + offline: true, + precache: true, +} satisfies StaticMpaOfflinePageVars + +/** Render the normal public page chrome and apply the shared navigation wrapper. */ +const rootLayout: LayoutFunction, string | HtmlResult, string> = ({ + children, + scripts, + styles, + vars, +}) => { + return renderPolicyLayout({ + bodyClass: 'policy-layout policy-layout--root', + children, + scripts, + styles, + title: vars.title ?? 'Static MPA offline example', + }) +} + +export default rootLayout diff --git a/examples/static-mpa-offline/src/mark-page-client-loaded.ts b/examples/static-mpa-offline/src/mark-page-client-loaded.ts new file mode 100644 index 0000000..b7f611d --- /dev/null +++ b/examples/static-mpa-offline/src/mark-page-client-loaded.ts @@ -0,0 +1,12 @@ +/// + +export function markPageClientLoaded (pageName: string): void { + const main = document.querySelector('main') + if (!main) return + + const note = document.createElement('p') + note.className = 'page-client-note' + note.dataset.pageClient = pageName + note.textContent = `Page client loaded: ${pageName}` + main.append(note) +} diff --git a/examples/static-mpa-offline/src/offline/client.ts b/examples/static-mpa-offline/src/offline/client.ts new file mode 100644 index 0000000..50ec0d4 --- /dev/null +++ b/examples/static-mpa-offline/src/offline/client.ts @@ -0,0 +1,3 @@ +import { markPageClientLoaded } from '../mark-page-client-loaded.ts' + +markPageClientLoaded('offline') diff --git a/examples/static-mpa-offline/src/offline/page.md b/examples/static-mpa-offline/src/offline/page.md new file mode 100644 index 0000000..6a5e20a --- /dev/null +++ b/examples/static-mpa-offline/src/offline/page.md @@ -0,0 +1,4 @@ +# Offline + +The requested page is not in the offline cache and the network is unavailable. + diff --git a/examples/static-mpa-offline/src/offline/style.css b/examples/static-mpa-offline/src/offline/style.css new file mode 100644 index 0000000..3fe90ad --- /dev/null +++ b/examples/static-mpa-offline/src/offline/style.css @@ -0,0 +1,5 @@ +@import "../page-style.css"; + +main { + --page-accent: darkorange; +} diff --git a/examples/static-mpa-offline/src/page-style.css b/examples/static-mpa-offline/src/page-style.css new file mode 100644 index 0000000..cd22656 --- /dev/null +++ b/examples/static-mpa-offline/src/page-style.css @@ -0,0 +1,8 @@ +.page-client-note { + background: color-mix(in srgb, var(--page-accent, currentColor) 8%, transparent); +} + +main h1 { + border-block-end: 0.125rem solid color-mix(in srgb, var(--page-accent, currentColor) 35%, transparent); + padding-block-end: 0.25rem; +} diff --git a/examples/static-mpa-offline/src/page.md b/examples/static-mpa-offline/src/page.md new file mode 100644 index 0000000..e96e577 --- /dev/null +++ b/examples/static-mpa-offline/src/page.md @@ -0,0 +1,85 @@ +# Static MPA Offline Example + +This small multi-page app demonstrates a production-oriented offline cache for static domstack output. + +- `/service-worker.js` is stable and un-hashed. +- `/service-worker.js` receives finalized manifest data at build time. +- Normal static pages and assets can reload offline. +- Updates use an in-page prompt instead of a blocking dialog. +- Watch mode disables service workers so local edits do not get stuck behind stale caches. +- A bad service worker can be reset with `?reset-sw`. + +## Try it + +1. Run `npm run serve` in this example. +2. Wait for the banner to say the offline cache is ready. +3. Use DevTools to go offline. +4. Reload the cached pages below. + +## Sample pages + +- [About the offline cache](./about/) — default policy: `offline: true`, `precache: true`. +- [Offline fallback](./offline/) — fallback used when an uncached or network-only navigation fails offline. +- [Admin / network-only page](./admin/) — uses the `admin` layout policy: `offline: false`, `precache: false`. +- [Progressive cache assets](./progressive-cache/assets/) — uses the progressive-cache layout policy: `offline: true`, `precache: false`. +- [Progressive cache asset details](./progressive-cache/assets/details/) — second progressive-cache page with its own image subresource. +- [Progressive cache alpha](./progressive-cache/alpha/) — frontmatter selects the progressive-cache layout. +- [Progressive cache beta](./progressive-cache/beta/) — second frontmatter-driven progressive-cache page. +- [Progressive cache override](./progressive-cache/override/) — uses the progressive-cache layout, but overrides `precache: true` in `page.vars.ts`. +- [Cache inspector](./cache-inspector/) — asks the service worker for cache names and cached response details. + +## Policy vars + +The example intentionally uses small, user-facing vars: + +```ts +export type StaticMpaOfflinePageVars = { + offline?: boolean + precache?: boolean + layout?: 'root' | 'admin' | 'progressive-cache' +} +``` + +Layouts export policy defaults with `export const vars`. For example, `src/layouts/root.layout.ts` makes normal pages available offline immediately: + +```ts +export const vars = { + offline: true, + precache: true, +} +``` + +`src/globals/domstack-manifest/domstack-manifest.settings.ts` selects the resolved `offline` and `precache` page variables for `entry.manifestVars`. The resolved variable cascade includes layout vars, page vars, and Markdown frontmatter. + +`src/globals/domstack-manifest/policy-build.ts` injects the finalized manifest entries into `/service-worker.js`. The service worker consumes those entries directly and derives cache behavior from their resolved `manifestVars`. + +## Layout policy examples + +The example layouts export these policy defaults: + +- `src/layouts/root.layout.ts`: `offline: true`, `precache: true` +- `src/layouts/admin.layout.ts`: `offline: false`, `precache: false` +- `src/layouts/progressive-cache.layout.ts`: `offline: true`, `precache: false` + +A page can select a layout in Markdown frontmatter: + +```md +--- +title: Progressive cache alpha +layout: progressive-cache +--- +``` + +A page can override a layout policy with `page.vars.ts`: + +```ts +export default { + precache: true, +} +``` + +## Runtime cache behavior + +Pages with `offline: true` and `precache: false` are skipped during install-time precaching. When visited online, the service worker stores the successful navigation in the runtime cache. Same-route subresources inherit the requesting page's generated runtime policy through `request.referrer`, because domstack does not yet emit complete page → subresource `dependencies` metadata. + +After visiting a runtime page online once, reload it offline to confirm it was learned at runtime. diff --git a/examples/static-mpa-offline/src/progressive-cache/alpha/client.ts b/examples/static-mpa-offline/src/progressive-cache/alpha/client.ts new file mode 100644 index 0000000..c354be2 --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/alpha/client.ts @@ -0,0 +1,3 @@ +import { markPageClientLoaded } from '../../mark-page-client-loaded.ts' + +markPageClientLoaded('progressive-cache alpha') diff --git a/examples/static-mpa-offline/src/progressive-cache/alpha/page.md b/examples/static-mpa-offline/src/progressive-cache/alpha/page.md new file mode 100644 index 0000000..4e4ee20 --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/alpha/page.md @@ -0,0 +1,15 @@ +--- +title: Progressive cache alpha +layout: progressive-cache +--- + +# Progressive cache alpha + +This page uses Markdown frontmatter to select `layout: progressive-cache`. + +That layout policy means: + +- `offline: true` +- `precache: false` + +So this route should not be cached during service-worker install. Visit it online once, then reload it offline. diff --git a/examples/static-mpa-offline/src/progressive-cache/alpha/style.css b/examples/static-mpa-offline/src/progressive-cache/alpha/style.css new file mode 100644 index 0000000..b29e7cc --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/alpha/style.css @@ -0,0 +1,5 @@ +@import "../../page-style.css"; + +main { + --page-accent: mediumseagreen; +} diff --git a/examples/static-mpa-offline/src/progressive-cache/assets/client.ts b/examples/static-mpa-offline/src/progressive-cache/assets/client.ts new file mode 100644 index 0000000..2818f70 --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/assets/client.ts @@ -0,0 +1,3 @@ +import { markPageClientLoaded } from '../../mark-page-client-loaded.ts' + +markPageClientLoaded('progressive-cache assets') diff --git a/examples/static-mpa-offline/src/progressive-cache/assets/details/client.ts b/examples/static-mpa-offline/src/progressive-cache/assets/details/client.ts new file mode 100644 index 0000000..acdbc8e --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/assets/details/client.ts @@ -0,0 +1,3 @@ +import { markPageClientLoaded } from '../../../mark-page-client-loaded.ts' + +markPageClientLoaded('progressive-cache asset details') diff --git a/examples/static-mpa-offline/src/progressive-cache/assets/details/details-badge.svg b/examples/static-mpa-offline/src/progressive-cache/assets/details/details-badge.svg new file mode 100644 index 0000000..f8fd77e --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/assets/details/details-badge.svg @@ -0,0 +1,9 @@ + + Progressive cache details badge + A green badge used to demonstrate a second progressive-cache page subresource. + + + + Details cached + A second learned page and image + diff --git a/examples/static-mpa-offline/src/progressive-cache/assets/details/page.md b/examples/static-mpa-offline/src/progressive-cache/assets/details/page.md new file mode 100644 index 0000000..181985f --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/assets/details/page.md @@ -0,0 +1,10 @@ +# Progressive cache asset details + +This second page is also learned at runtime instead of precached. + +It has its own image subresource. Visit this page online once, then reload it offline to confirm both the HTML and image were cached by the runtime route. + +![Runtime cached green badge](./details-badge.svg) + +- [Back to progressive cache assets](../) + diff --git a/examples/static-mpa-offline/src/progressive-cache/assets/details/page.vars.ts b/examples/static-mpa-offline/src/progressive-cache/assets/details/page.vars.ts new file mode 100644 index 0000000..d1b5119 --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/assets/details/page.vars.ts @@ -0,0 +1,5 @@ +import type { StaticMpaOfflinePageVars } from '#service-worker-settings' + +export default { + layout: 'progressive-cache', +} satisfies StaticMpaOfflinePageVars diff --git a/examples/static-mpa-offline/src/progressive-cache/assets/details/style.css b/examples/static-mpa-offline/src/progressive-cache/assets/details/style.css new file mode 100644 index 0000000..3fcdaa2 --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/assets/details/style.css @@ -0,0 +1,5 @@ +@import "../../../page-style.css"; + +main { + --page-accent: royalblue; +} diff --git a/examples/static-mpa-offline/src/progressive-cache/assets/page.md b/examples/static-mpa-offline/src/progressive-cache/assets/page.md new file mode 100644 index 0000000..904173c --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/assets/page.md @@ -0,0 +1,10 @@ +# Progressive cache assets + +This page is intentionally **not** precached during service-worker install. + +Visit it while online, then go offline and reload it. The service worker should serve this page from the runtime cache because you already visited it. + +![Runtime cached blue badge](./runtime-badge.svg) + +- [Progressive cache asset details](./details/) + diff --git a/examples/static-mpa-offline/src/progressive-cache/assets/page.vars.ts b/examples/static-mpa-offline/src/progressive-cache/assets/page.vars.ts new file mode 100644 index 0000000..d1b5119 --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/assets/page.vars.ts @@ -0,0 +1,5 @@ +import type { StaticMpaOfflinePageVars } from '#service-worker-settings' + +export default { + layout: 'progressive-cache', +} satisfies StaticMpaOfflinePageVars diff --git a/examples/static-mpa-offline/src/progressive-cache/assets/runtime-badge.svg b/examples/static-mpa-offline/src/progressive-cache/assets/runtime-badge.svg new file mode 100644 index 0000000..87ebb98 --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/assets/runtime-badge.svg @@ -0,0 +1,9 @@ + + Progressive cache badge + A blue badge used to demonstrate progressive-cache subresources. + + + + Progressive cache + Fetched online, available offline later + diff --git a/examples/static-mpa-offline/src/progressive-cache/assets/style.css b/examples/static-mpa-offline/src/progressive-cache/assets/style.css new file mode 100644 index 0000000..5503fb0 --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/assets/style.css @@ -0,0 +1,5 @@ +@import "../../page-style.css"; + +main { + --page-accent: slateblue; +} diff --git a/examples/static-mpa-offline/src/progressive-cache/beta/client.ts b/examples/static-mpa-offline/src/progressive-cache/beta/client.ts new file mode 100644 index 0000000..02c9b63 --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/beta/client.ts @@ -0,0 +1,3 @@ +import { markPageClientLoaded } from '../../mark-page-client-loaded.ts' + +markPageClientLoaded('progressive-cache beta') diff --git a/examples/static-mpa-offline/src/progressive-cache/beta/page.md b/examples/static-mpa-offline/src/progressive-cache/beta/page.md new file mode 100644 index 0000000..90442c6 --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/beta/page.md @@ -0,0 +1,10 @@ +--- +title: Progressive cache beta +layout: progressive-cache +--- + +# Progressive cache beta + +This is a second page using the same `progressive-cache` layout from frontmatter. + +It demonstrates that a layout-level policy can apply the same offline behavior to multiple pages without repeating a `page.vars.ts` file next to each page. diff --git a/examples/static-mpa-offline/src/progressive-cache/beta/style.css b/examples/static-mpa-offline/src/progressive-cache/beta/style.css new file mode 100644 index 0000000..b99f93e --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/beta/style.css @@ -0,0 +1,5 @@ +@import "../../page-style.css"; + +main { + --page-accent: teal; +} diff --git a/examples/static-mpa-offline/src/progressive-cache/override/client.ts b/examples/static-mpa-offline/src/progressive-cache/override/client.ts new file mode 100644 index 0000000..3c641dc --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/override/client.ts @@ -0,0 +1,3 @@ +import { markPageClientLoaded } from '../../mark-page-client-loaded.ts' + +markPageClientLoaded('progressive-cache override') diff --git a/examples/static-mpa-offline/src/progressive-cache/override/page.md b/examples/static-mpa-offline/src/progressive-cache/override/page.md new file mode 100644 index 0000000..cf60b79 --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/override/page.md @@ -0,0 +1,16 @@ +--- +title: Progressive cache override +layout: progressive-cache +--- + +# Progressive cache override + +This page uses the progressive-cache layout in frontmatter, but its sibling `page.vars.ts` overrides the layout policy: + +```ts +export default { + precache: true, +} +``` + +So unlike the other progressive-cache pages, this page should be available offline immediately after the service worker install finishes. diff --git a/examples/static-mpa-offline/src/progressive-cache/override/page.vars.ts b/examples/static-mpa-offline/src/progressive-cache/override/page.vars.ts new file mode 100644 index 0000000..951607e --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/override/page.vars.ts @@ -0,0 +1,5 @@ +import type { StaticMpaOfflinePageVars } from '#service-worker-settings' + +export default { + precache: true, +} satisfies StaticMpaOfflinePageVars diff --git a/examples/static-mpa-offline/src/progressive-cache/override/style.css b/examples/static-mpa-offline/src/progressive-cache/override/style.css new file mode 100644 index 0000000..84d608e --- /dev/null +++ b/examples/static-mpa-offline/src/progressive-cache/override/style.css @@ -0,0 +1,5 @@ +@import "../../page-style.css"; + +main { + --page-accent: mediumvioletred; +} diff --git a/examples/static-mpa-offline/src/style.css b/examples/static-mpa-offline/src/style.css new file mode 100644 index 0000000..83c2532 --- /dev/null +++ b/examples/static-mpa-offline/src/style.css @@ -0,0 +1,5 @@ +@import "./page-style.css"; + +main { + --page-accent: dodgerblue; +} diff --git a/examples/static-mpa-offline/tsconfig.json b/examples/static-mpa-offline/tsconfig.json new file mode 100644 index 0000000..174f9cb --- /dev/null +++ b/examples/static-mpa-offline/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["src/**/*.ts"] +} diff --git a/plans/standard-static-mpa-service-worker.md b/plans/standard-static-mpa-service-worker.md new file mode 100644 index 0000000..edfee61 --- /dev/null +++ b/plans/standard-static-mpa-service-worker.md @@ -0,0 +1,199 @@ +# Optional Standard Static MPA Service Worker + +## Status: Proposal validated by example + +`examples/static-mpa-offline` is the current domstack-native prototype for a possible optional standard static MPA service-worker preset. + +It no longer runtime-fetches `domstack-manifest.json` or a generated policy JSON file. + +Instead, `hooks.manifestBuilt` injects the finalized manifest-shaped policy into `/service-worker.js` with `defineServiceWorkerConstant()`. + +The service worker consumes Domstack manifest entries directly and derives cache behavior from the manifest fields and selected offline vars. + +## Goals + +- Provide a simple, robust, production-ready static MPA offline preset. +- Keep service workers explicit opt-in. +- Avoid forcing Workbox on sites that only need static MPA offline behavior. +- Use Domstack's finalized build graph instead of hand-maintained asset lists. +- Keep watch mode safe by disabling caches and unregistering old workers. +- Include recovery paths from the start. + +## Non-goals + +- Do not auto-enable service workers for all domstack sites. +- Do not cache API/data endpoints by default. +- Do not implement app-specific offline mutations, background sync, push subscriptions, or data models. +- Do not force a domstack-provided update UI into user layouts. +- Do not replace Workbox for apps that need Workbox plugins and recipes. + +## Current example behavior + +The vanilla example has these moving parts: + +- `src/globals/domstack-manifest/domstack-manifest.settings.ts` selects `offline` and `precache` manifest vars and registers the build hook. +- `src/globals/domstack-manifest/policy-build.ts` injects `{ version, entries, offlineFallbackUrl }` into `/service-worker.js`. +- `src/globals/service-worker/service-worker.ts` chooses production vs watch behavior by detecting whether the injected policy constant exists. +- `src/globals/service-worker/precache.ts` derives precache keys and runtime strategy from Domstack manifest entries. +- `src/globals/global-client/*` owns registration, update UI, watch cleanup, reset query params, and connection status. + +The service worker uses: + +- stable `/service-worker.js` +- stable cache names +- revisioned cache keys for non-hashed URLs +- cache-first handling for precached static outputs +- network-first handling for progressive/runtime routes +- network-only behavior for offline-disabled routes +- navigation fallback to the offline page +- watch-mode no-policy self-disable +- `SKIP_WAITING` and `RESET_SERVICE_WORKER` messages + +## Offline vars convention + +The example intentionally keeps user-facing vars small: + +```ts +type StaticMpaOfflineManifestVars = { + offline?: boolean + precache?: boolean +} +``` + +`offline: true` means the page/route is allowed to become available offline. + +`offline: false` makes the route network-only with offline fallback behavior for navigations. + +`precache: true` means the navigation page is cached during install. + +`precache: false` means the navigation page is runtime-cached after the first successful visit. + +Layout vars set section defaults. + +Page vars/frontmatter can override layout vars through the normal cascade. + +The cascade is: + +```txt +page vars -> layout vars -> global vars -> defaults +``` + +## Build-time injection model + +The current build model is: + +```txt +final Domstack manifest + -> manifestBuilt hook + -> context.defineServiceWorkerConstant('__DOMSTACK_SERVICE_WORKER_POLICY__', policy) + -> final /service-worker.js bundle +``` + +This is preferred over: + +- fetching `/domstack-manifest.json` at runtime +- fetching `/domstack-service-worker-policy.json` at runtime +- generating JavaScript globals with `importScripts()` +- using top-level await in service workers + +Policy changes change `/service-worker.js` bytes and trigger the browser update lifecycle. + +## Watch mode + +Watch mode does not produce a manifest policy. + +The service worker detects that the injected policy constant is missing and installs as a no-op cleanup worker. + +The watch worker: + +- calls `skipWaiting()` during install +- deletes owned caches during activation +- unregisters itself +- registers no fetch handler + +The browser client also unregisters workers and clears known caches in watch mode. + +This double layer matters because a previous production worker can serve cached HTML/JS before the watch-mode client code runs. + +Watch builds disable esbuild splitting so `/service-worker.js` stays self-contained during cleanup. + +## Client registration helper behavior + +A future reusable client helper should: + +1. No-op when `navigator.serviceWorker` is unavailable. +2. Clean up when `DOMSTACK_MANIFEST_ENABLED` is false. +3. Register after `window.load` by default. +4. Register with the stable service-worker URL/scope from Domstack defines. +5. Use `{ type: 'module', updateViaCache: 'none' }`. +6. Detect `installing`, `waiting`, and `active` states immediately after registration. +7. Expose callbacks/events for ready, update available, updating, reset, error, and online/offline state. +8. Avoid hard-coded blocking dialogs. +9. Provide a default reset query param such as `?reset-sw`. +10. Reload once on `controllerchange` after an accepted update. + +## Possible reusable API + +Start with reusable imports rather than generated service-worker source: + +```ts +// src/service-worker.ts +import '@domstack/static/service-worker/static-mpa' +``` + +```ts +// src/global.client.ts +import { registerDomstackServiceWorker } from '@domstack/static/client/service-worker' + +registerDomstackServiceWorker() +``` + +This keeps service workers inspectable and customizable. + +A higher-level preset can come later if the helper API stabilizes. + +## Recovery design + +Every standard path should include two recovery tiers. + +### Recoverable reset + +If page JS still loads, a query param should reset worker state: + +```txt +/?reset-sw +``` + +Behavior: + +1. Post `RESET_SERVICE_WORKER` to active/waiting/installing workers. +2. Unregister matching registrations. +3. Delete known domstack cache prefixes. +4. Remove the reset query param. +5. Reload from the network. + +### Emergency replacement worker + +A rescue worker can be deployed at the exact production service-worker URL: + +```txt +/service-worker.js +``` + +It should: + +- call `skipWaiting()` during install +- have no `fetch` handler +- delete known domstack caches during activate +- reload or let clients reload after control changes + +The exact URL requirement is important. + +Deploying a rescue worker at a different URL leaves the broken worker active. + +## Open questions + +- Should domstack ship reusable static-MPA service-worker modules, or keep examples as copyable recipes? +- Should core expose helper utilities for deriving runtime strategy and precache keys from manifest entries? +- Should the public `domstack-manifest.json` schema remain a packaged artifact if service-worker use mostly relies on injected constants? +- How much default update UI should a helper provide versus only dispatching events?