diff --git a/examples/static-mpa-workbox-offline/DESIGN.md b/examples/static-mpa-workbox-offline/DESIGN.md new file mode 100644 index 0000000..b26bc5c --- /dev/null +++ b/examples/static-mpa-workbox-offline/DESIGN.md @@ -0,0 +1,112 @@ +# Static MPA Workbox Offline Design Notes + +## Status + +These notes describe the current Workbox offline example in `examples/static-mpa-workbox-offline`. + +The example keeps authored service-worker code while using Domstack's finalized manifest to generate the Workbox data. +It does not use Workbox `injectManifest`, `self.__WB_MANIFEST`, `importScripts()`, or a runtime policy JSON fetch. + +## 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` computes a Workbox-shaped policy and injects it into the final service-worker bundle with `defineServiceWorkerConstant()`. + +The injected policy has one Workbox-native field and a few app-specific route fields: + +```ts +type StaticMpaWorkboxServiceWorkerPolicy = { + version: string + precacheManifest: WorkboxPrecacheEntry[] + runtimeUrls: string[] + networkOnlyUrls: string[] + offlineFallbackUrl: string +} +``` + +`precacheManifest` is passed directly to `precacheAndRoute()`. +The other fields are used by the app's Workbox `registerRoute()` calls and `offlineFallback()` recipe. + +## 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/', +} +``` + +## Workbox usage + +The service worker uses these Workbox APIs: + +- `precacheAndRoute()` for install-time precache. +- `cleanupOutdatedCaches()` for old Workbox precache cleanup. +- `offlineFallback()` for failed offline navigations. +- `NetworkFirst` for progressive-cache routes. +- `NetworkOnly` for network-only routes. +- `CacheableResponsePlugin` to limit runtime cache writes to configured response statuses. +- `ExpirationPlugin` to limit runtime cache age, entry count, and quota pressure. +- `workbox-window` in the browser client for registration and update events. + +The example keeps Workbox's native precache shape: + +```ts +type WorkboxPrecacheEntry = { + url: string + revision: string | null + integrity?: string +} +``` + +Hashed URLs use `revision: null`. +Unhashed URLs use the Domstack `revision` value. +If Domstack emitted `integrity`, it is passed through to Workbox. + +## Runtime routes + +Pages with `offline: true` and `precache: false` become Workbox `NetworkFirst` runtime routes. +Their HTML and same-section subresources can become available offline after a successful online visit. + +Pages with `offline: false` become navigation-only `NetworkOnly` routes. +Failed offline navigations fall through to the configured offline fallback. + +Pages with `precache: true` and no runtime strategy are included in the Workbox precache when they are static, revisioned, and below the configured size limit. +Chunks are precached in production builds so dynamic imports used by cached pages stay available offline. + +## Watch mode + +Watch mode is for editing, not offline testing. +When `DOMSTACK_MANIFEST_ENABLED` is false, the worker installs as a cleanup worker, clears Workbox-owned caches, and does not register runtime caching routes. +The browser client also unregisters existing workers and clears owned caches while watching. + +Use `npm --workspace @domstack/static-mpa-workbox-offline-example run serve` for offline-cache testing. + +## Update lifecycle + +The browser client uses `workbox-window` to register after load, detect waiting updates, and send `SKIP_WAITING` when the user accepts the update prompt. +The page reloads once when Workbox reports that the new worker is controlling the page. + +Redundant worker transitions are logged to the console rather than shown as user-facing error states. + +## 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. diff --git a/examples/static-mpa-workbox-offline/README.md b/examples/static-mpa-workbox-offline/README.md new file mode 100644 index 0000000..b0a02a1 --- /dev/null +++ b/examples/static-mpa-workbox-offline/README.md @@ -0,0 +1,108 @@ +# Static MPA Workbox Offline Example + +This example shows the same domstack static MPA offline policy model as `examples/static-mpa-offline`, but uses Workbox for the service-worker caching runtime. + +Key points: + +- Domstack emits a stable `/service-worker.js`. +- The service worker is authored with normal Workbox calls such as `precacheAndRoute(precacheManifest)`. +- `src/globals/domstack-manifest/domstack-manifest.settings.ts` uses `hooks.manifestBuilt` to inject a precomputed Workbox policy constant into `/service-worker.js`. +- Layout vars define section-wide offline policy: + - `root.layout.ts`: `offline: true`, `precache: true` + - `progressive-cache.layout.ts`: `offline: true`, `precache: false` + - `admin.layout.ts`: `offline: false`, `precache: false` +- Page vars/frontmatter can override layout vars through domstack's normal cascade. +- Watch mode unregisters the worker and clears Workbox-owned caches to avoid stale dev state. +- `?reset-sw` and `rescue-service-worker.js` are included as recovery paths. + +## Running + +```sh +npm --workspace @domstack/static-mpa-workbox-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-workbox-offline-example run watch +``` + +Watch mode does not inject production manifest policy into the service worker, so this example disables and unregisters service workers while watching. +Use `serve` for offline-cache testing. + +## What this example proves + +This example uses the new manifest features together: + +- `role` marks navigations, subresources, workers, and metadata. +- `static` filters cacheable build outputs. +- `urlRevisioned` lets hashed outputs use Workbox `revision: null`. +- `integrity` is passed through to Workbox precache entries. +- `manifestVars` carries resolved page/layout policy vars to each entry. +- root `policy` carries the app-level offline fallback route. +- `hooks.manifestBuilt` injects Workbox-shaped generated data after the final manifest exists. + +## Workbox policy injection flow + +The manifest built hook computes policy from the finalized Domstack manifest and injects it into the final service-worker bundle: + +```ts +context.defineServiceWorkerConstant('__DOMSTACK_WORKBOX_POLICY__', { + precacheManifest: [ + { url: '/', revision: '...' }, + ], + runtimeUrls: ['/progressive-cache/assets/'], + networkOnlyUrls: ['/admin/'], + offlineFallbackUrl: '/offline/', +}) +``` + +The authored service worker reads the injected constant, then passes the Workbox-native `precacheManifest` array directly to Workbox: + +```ts +const policy = __DOMSTACK_WORKBOX_POLICY__ +precacheAndRoute(policy.precacheManifest) +``` + +This keeps Workbox data generated from domstack's final manifest without Workbox globbing, `self.__WB_MANIFEST`, `importScripts()`, top-level await, or a runtime policy fetch. + +## 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 by Workbox `NetworkFirst`. +- `/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. + +## Recovery paths + +For recoverable mistakes where pages still load, visit: + +```txt +/?reset-sw +``` + +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 +``` + +## Research references + +- +- +- +- +- +- +- +- +- +- `plans/workbox-workflow-integration.md` +- `examples/static-mpa-offline/README.md` diff --git a/examples/static-mpa-workbox-offline/package.json b/examples/static-mpa-workbox-offline/package.json new file mode 100644 index 0000000..ee9dd27 --- /dev/null +++ b/examples/static-mpa-workbox-offline/package.json @@ -0,0 +1,35 @@ +{ + "name": "@domstack/static-mpa-workbox-offline-example", + "version": "0.0.0", + "description": "Static MPA offline Workbox 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:../../.", + "workbox-cacheable-response": "^7.4.1", + "workbox-core": "^7.4.1", + "workbox-expiration": "^7.4.1", + "workbox-precaching": "^7.4.1", + "workbox-recipes": "^7.4.1", + "workbox-routing": "^7.4.1", + "workbox-strategies": "^7.4.1", + "workbox-window": "^7.4.1" + } +} diff --git a/examples/static-mpa-workbox-offline/rescue-service-worker.js b/examples/static-mpa-workbox-offline/rescue-service-worker.js new file mode 100644 index 0000000..6fd5487 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-static-mpa-precache', 'domstack-workbox-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-workbox-offline/src/about/client.ts b/examples/static-mpa-workbox-offline/src/about/client.ts new file mode 100644 index 0000000..b9e4533 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/about/page.md b/examples/static-mpa-workbox-offline/src/about/page.md new file mode 100644 index 0000000..941a71b --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/about/style.css b/examples/static-mpa-workbox-offline/src/about/style.css new file mode 100644 index 0000000..6d94a4a --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/about/style.css @@ -0,0 +1,5 @@ +@import "../page-style.css"; + +main { + --page-accent: seagreen; +} diff --git a/examples/static-mpa-workbox-offline/src/admin/client.ts b/examples/static-mpa-workbox-offline/src/admin/client.ts new file mode 100644 index 0000000..f813c0b --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/admin/page.md b/examples/static-mpa-workbox-offline/src/admin/page.md new file mode 100644 index 0000000..7034526 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/admin/page.vars.ts b/examples/static-mpa-workbox-offline/src/admin/page.vars.ts new file mode 100644 index 0000000..1708336 --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/admin/page.vars.ts @@ -0,0 +1,5 @@ +import type { StaticMpaWorkboxPageVars } from '#service-worker-settings' + +export default { + layout: 'admin', +} satisfies StaticMpaWorkboxPageVars diff --git a/examples/static-mpa-workbox-offline/src/admin/style.css b/examples/static-mpa-workbox-offline/src/admin/style.css new file mode 100644 index 0000000..02dfb14 --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/admin/style.css @@ -0,0 +1,5 @@ +@import "../page-style.css"; + +main { + --page-accent: crimson; +} diff --git a/examples/static-mpa-workbox-offline/src/cache-inspector/client.ts b/examples/static-mpa-workbox-offline/src/cache-inspector/client.ts new file mode 100644 index 0000000..2cae4ff --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/cache-inspector/client.ts @@ -0,0 +1,48 @@ +/// + +import { markPageClientLoaded } from '../mark-page-client-loaded.ts' + +markPageClientLoaded('cache inspector') + +const workboxCacheInspectorButton = document.querySelector('#inspect-caches') +const workboxCacheInspectorOutput = document.querySelector('#cache-inspection-output') + +workboxCacheInspectorButton?.addEventListener('click', async () => { + writeOutput('Requesting cache details from the service worker…') + + try { + writeOutput(JSON.stringify(await inspectWorkboxExampleCaches(), null, 2)) + } catch (error) { + writeOutput(error instanceof Error ? error.message : String(error)) + } +}) + +function writeOutput (message: string): void { + if (!workboxCacheInspectorOutput) return + workboxCacheInspectorOutput.textContent = message +} + +async function inspectWorkboxExampleCaches (): 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 channel = new MessageChannel() + navigator.serviceWorker.controller.postMessage( + { type: 'DOMSTACK_INSPECT_CACHES' }, + [channel.port2] + ) + + return await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + channel.port1.close() + reject(new Error('Timed out waiting for service-worker cache details.')) + }, 5000) + + channel.port1.addEventListener('message', event => { + window.clearTimeout(timeout) + channel.port1.close() + resolve(event.data) + }, { once: true }) + channel.port1.start() + }) +} diff --git a/examples/static-mpa-workbox-offline/src/cache-inspector/page.html b/examples/static-mpa-workbox-offline/src/cache-inspector/page.html new file mode 100644 index 0000000..8b102a5 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/cache-inspector/page.vars.ts b/examples/static-mpa-workbox-offline/src/cache-inspector/page.vars.ts new file mode 100644 index 0000000..900ec8c --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/cache-inspector/page.vars.ts @@ -0,0 +1,5 @@ +import type { StaticMpaWorkboxPageVars } from '#service-worker-settings' + +export default { + title: 'Cache inspector', +} satisfies StaticMpaWorkboxPageVars diff --git a/examples/static-mpa-workbox-offline/src/cache-inspector/style.css b/examples/static-mpa-workbox-offline/src/cache-inspector/style.css new file mode 100644 index 0000000..023710a --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/cache-inspector/style.css @@ -0,0 +1,5 @@ +@import "../page-style.css"; + +main { + --page-accent: rebeccapurple; +} diff --git a/examples/static-mpa-workbox-offline/src/client.ts b/examples/static-mpa-workbox-offline/src/client.ts new file mode 100644 index 0000000..b9cb513 --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/client.ts @@ -0,0 +1,3 @@ +import { markPageClientLoaded } from './mark-page-client-loaded.ts' + +markPageClientLoaded('home') diff --git a/examples/static-mpa-workbox-offline/src/globals/domstack-manifest/domstack-manifest.settings.ts b/examples/static-mpa-workbox-offline/src/globals/domstack-manifest/domstack-manifest.settings.ts new file mode 100644 index 0000000..1df8e8f --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/globals/domstack-manifest/domstack-manifest.settings.ts @@ -0,0 +1,25 @@ +import type { DomstackManifestOptions } from '@domstack/static/types.js' +import type { + StaticMpaWorkboxManifestVars, + StaticMpaWorkboxPageVars, + StaticMpaWorkboxPolicy, +} from '#service-worker-settings' +import { offlineFallbackUrl } from '#service-worker-settings' +import { emitWorkboxManifest } from './policy-build.ts' + +const settings = { + // Expose only the resolved page vars that the service worker uses for route policy. + manifestVars: ['offline', 'precache'], + // Root policy is shared once instead of repeated on every manifest entry. + policy: { + offlineFallbackUrl, + }, + // Convert the finalized Domstack manifest into Workbox policy before bundling the worker. + hooks: { + manifestBuilt: [emitWorkboxManifest], + }, + // Internal metadata and source maps are not browser cache candidates. + includeEntry: entry => entry.kind !== 'metadata' && entry.kind !== 'sourcemap', +} satisfies DomstackManifestOptions + +export default settings diff --git a/examples/static-mpa-workbox-offline/src/globals/domstack-manifest/policy-build.ts b/examples/static-mpa-workbox-offline/src/globals/domstack-manifest/policy-build.ts new file mode 100644 index 0000000..4daf9a9 --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/globals/domstack-manifest/policy-build.ts @@ -0,0 +1,130 @@ +import type { + DomstackManifest, + DomstackManifestBuiltHookContext, + DomstackManifestEntry, +} from '@domstack/static/types.js' +import type { + StaticMpaWorkboxManifestVars, + StaticMpaWorkboxPolicy, + StaticMpaWorkboxServiceWorkerPolicy, + WorkboxPrecacheEntry, +} from '#service-worker-settings' +import { + maxPrecacheBytes, + offlineFallbackUrl, + workboxPolicyDefineName, +} from '#service-worker-settings' + +/** + * Build-time Workbox manifest generation. + * + * The hook computes policy data consumed by the authored service worker. + * The `precacheManifest` field uses Workbox's native precache entry shape; + * runtime route lists and fallback URLs remain app/Domstack policy. + */ + +/** Inject Workbox precache and route-policy data into `/service-worker.js`. */ +export async function emitWorkboxManifest ( + context: DomstackManifestBuiltHookContext +): Promise { + const policy = buildWorkboxPolicy(context.manifest) + context.defineServiceWorkerConstant(workboxPolicyDefineName, policy) +} + +function buildWorkboxPolicy ( + manifest: DomstackManifest +): StaticMpaWorkboxServiceWorkerPolicy { + const routes = navigationRoutes(manifest.entries) + const precacheManifest: WorkboxPrecacheEntry[] = [] + const runtimeUrls: string[] = [] + const networkOnlyUrls: string[] = [] + + for (const entry of manifest.entries) { + const runtimeStrategy = runtimeStrategyFor(entry, routes) + if (shouldPrecache(entry, runtimeStrategy)) { + precacheManifest.push(toWorkboxPrecacheEntry(entry)) + } else if (runtimeStrategy === 'runtime') { + runtimeUrls.push(entry.url) + } else if (runtimeStrategy === 'network-only') { + networkOnlyUrls.push(entry.url) + } + } + + return { + version: manifest.version, + precacheManifest, + runtimeUrls, + networkOnlyUrls, + offlineFallbackUrl: manifest.policy?.offlineFallbackUrl ?? offlineFallbackUrl, + } +} + +function toWorkboxPrecacheEntry (entry: DomstackManifestEntry): WorkboxPrecacheEntry { + return { + url: entry.url, + revision: entry.urlRevisioned ? null : entry.revision, + ...(entry.integrity ? { integrity: entry.integrity } : {}), + } +} + +type NavigationRoutePolicy = { + offline: boolean + pathname: string + precache: boolean +} + +function navigationRoutes (entries: DomstackManifestEntry[]): NavigationRoutePolicy[] { + return entries + .filter(entry => entry.role === 'navigation' && entry.manifestVars) + .map(entry => ({ + offline: entry.manifestVars?.offline === true, + pathname: pathname(entry.url), + precache: entry.manifestVars?.precache === true, + })) + .sort((a, b) => b.pathname.length - a.pathname.length) +} + +function runtimeStrategyFor ( + entry: DomstackManifestEntry, + routes: NavigationRoutePolicy[] +): 'network-only' | 'runtime' | undefined { + const policy = entry.role === 'navigation' + ? navigationPolicy(entry) + : routes.find(route => routeContains(route.pathname, pathname(entry.url))) + + if (!policy) return undefined + if (!policy.offline) return 'network-only' + if (!policy.precache) return 'runtime' + return undefined +} + +function navigationPolicy (entry: DomstackManifestEntry): NavigationRoutePolicy | undefined { + if (!entry.manifestVars) return undefined + return { + offline: entry.manifestVars.offline === true, + pathname: pathname(entry.url), + precache: entry.manifestVars.precache === true, + } +} + +function shouldPrecache ( + entry: DomstackManifestEntry, + runtimeStrategy: 'network-only' | 'runtime' | undefined +): boolean { + 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 (runtimeStrategy) return false + if (entry.role === 'subresource') return true + return entry.manifestVars?.precache === true +} + +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, 'https://example.invalid').pathname +} diff --git a/examples/static-mpa-workbox-offline/src/globals/global-client/connection-status.ts b/examples/static-mpa-workbox-offline/src/globals/global-client/connection-status.ts new file mode 100644 index 0000000..f9379c3 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/globals/global-client/global.client.ts b/examples/static-mpa-workbox-offline/src/globals/global-client/global.client.ts new file mode 100644 index 0000000..197ddd6 --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/globals/global-client/global.client.ts @@ -0,0 +1,72 @@ +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 config = { + cachePrefixes: [...cachePrefixes], + manifestEnabled: process.env.DOMSTACK_MANIFEST_ENABLED === 'true', + offlineRecheckIntervalMs, + offlineStorageKey, + onlineCheckTimeoutMs, + serviceWorkerScope: process.env.DOMSTACK_SERVICE_WORKER_SCOPE, + serviceWorkerUrl: process.env.DOMSTACK_SERVICE_WORKER_URL, +} + +const status = createStatusBanner() + +initializeConnectionStatus(status, config) +initializeServiceWorkerEventMessages(status) + +const { serviceWorkerScope, serviceWorkerUrl } = config + +if (serviceWorkerUrl && serviceWorkerScope && 'serviceWorker' in navigator) { + if (shouldResetServiceWorker()) { + try { + await resetServiceWorkerFromWindow(status, config) + } catch (error) { + console.error('Service worker reset failed', error) + } + } else if (!config.manifestEnabled) { + try { + await disableServiceWorkerForWatchMode(status, config) + } 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-workbox-offline/src/globals/global-client/service-worker-events.ts b/examples/static-mpa-workbox-offline/src/globals/global-client/service-worker-events.ts new file mode 100644 index 0000000..ea5c94f --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/globals/global-client/service-worker-registration.ts b/examples/static-mpa-workbox-offline/src/globals/global-client/service-worker-registration.ts new file mode 100644 index 0000000..259286a --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/globals/global-client/service-worker-registration.ts @@ -0,0 +1,116 @@ +import { Workbox } from 'workbox-window' +import type { StatusBanner } from './status-banner.ts' + +/** + * Service-worker registration and update UX for the static MPA example. + * + * This module uses `workbox-window` to track 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 workbox = new Workbox(url, { + scope, + type: 'module', + updateViaCache: 'none', + }) + const hadControllerAtRegistration = Boolean(navigator.serviceWorker.controller) + let applyingUpdate = false + let lifecycleStatusShown = false + + const showLifecycleStatus = (...args: Parameters): void => { + lifecycleStatusShown = true + status.showStatus(...args) + } + + workbox.addEventListener('installing', () => { + showLifecycleStatus('Installing offline cache…') + }) + + workbox.addEventListener('activated', event => { + if (!event.isUpdate) showLifecycleStatus('Offline cache is ready.', 'ready') + }) + + workbox.addEventListener('waiting', event => { + if (event.wasWaitingBeforeRegister) { + applyingUpdate = true + workbox.messageSkipWaiting() + showLifecycleStatus('Applying offline update from previous visit…', 'update') + return + } + + lifecycleStatusShown = true + promptForUpdate(status, workbox, () => { + applyingUpdate = true + }) + }) + + workbox.addEventListener('controlling', () => { + if (hadControllerAtRegistration || applyingUpdate) { + window.location.reload() + return + } + + showLifecycleStatus('Offline cache is ready.', 'ready') + }) + + workbox.addEventListener('redundant', event => { + console.info('Service worker became redundant during registration.', event) + }) + + const registration = await workbox.register({ immediate: true }) + await workbox.update() + if (lifecycleStatusShown) return + + if (registration?.installing) { + status.showStatus('Installing offline cache…') + return + } + + if (registration?.waiting) { + promptForUpdate(status, workbox, () => { + applyingUpdate = true + }) + return + } + + if (registration?.active) { + status.showStatus('Offline cache is ready.', 'ready') + return + } + + status.showStatus('Service worker registered.', 'info') +} + +/** Show the in-page update prompt for a newly installed waiting worker. */ +function promptForUpdate ( + status: Pick, + workbox: Workbox, + onApplyUpdate: () => void +): void { + const reload = document.createElement('button') + reload.type = 'button' + reload.textContent = 'Reload now' + reload.addEventListener('click', () => { + reload.disabled = true + onApplyUpdate() + workbox.messageSkipWaiting() + status.showStatus('Updating offline cache…', 'update') + }) + + 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]) +} diff --git a/examples/static-mpa-workbox-offline/src/globals/global-client/service-worker-reset.ts b/examples/static-mpa-workbox-offline/src/globals/global-client/service-worker-reset.ts new file mode 100644 index 0000000..32dd4c6 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/globals/global-client/status-banner.ts b/examples/static-mpa-workbox-offline/src/globals/global-client/status-banner.ts new file mode 100644 index 0000000..5c04574 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/globals/global.css b/examples/static-mpa-workbox-offline/src/globals/global.css new file mode 100644 index 0000000..7f30a72 --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/globals/global.css @@ -0,0 +1,110 @@ +: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; +} + +.page-client-note { + border-inline-start: 0.25rem solid color-mix(in srgb, currentColor 35%, transparent); + color: color-mix(in srgb, currentColor 70%, transparent); + font-size: 0.875rem; + margin-block-start: 2rem; + padding-inline-start: 0.75rem; +} + +.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-workbox-offline/src/globals/global.vars.ts b/examples/static-mpa-workbox-offline/src/globals/global.vars.ts new file mode 100644 index 0000000..a632b3f --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/globals/global.vars.ts @@ -0,0 +1,5 @@ +import type { StaticMpaWorkboxPageVars } from '#service-worker-settings' + +export default { + layout: 'root', +} satisfies StaticMpaWorkboxPageVars diff --git a/examples/static-mpa-workbox-offline/src/globals/service-worker/background-events.ts b/examples/static-mpa-workbox-offline/src/globals/service-worker/background-events.ts new file mode 100644 index 0000000..3254b2f --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/globals/service-worker/background-events.ts @@ -0,0 +1,108 @@ +/// + +/** + * Demo-only background event handlers for browser DevTools. + * + * Workbox does not provide generic push, one-off sync, or periodic sync + * handlers. The Workbox background-sync package is for replaying failed + * requests, so this example uses the platform events directly and forwards + * visible messages to open pages. + */ + +/** Register native background-event handlers used by browser DevTools demos. */ +export function initializeBackgroundEventDemos (scope: ServiceWorkerGlobalScope): void { + scope.addEventListener('push', event => { + event.waitUntil(handlePush(scope, event as PushEventLike)) + }) + + scope.addEventListener('sync', ((event: Event) => { + const syncEvent = event as TaggedExtendableEvent + syncEvent.waitUntil(handleSync(scope, syncEvent, 'DOMSTACK_SYNC_RECEIVED')) + }) as EventListener) + + scope.addEventListener('periodicsync', ((event: Event) => { + const periodicSyncEvent = event as TaggedExtendableEvent + periodicSyncEvent.waitUntil(handleSync(scope, periodicSyncEvent, 'DOMSTACK_PERIODIC_SYNC_RECEIVED')) + }) as EventListener) +} + +type PushEventLike = ExtendableEvent & { + data?: { + json: () => unknown + text: () => string + } +} + +type TaggedExtendableEvent = ExtendableEvent & { + tag?: string +} + +type ServiceWorkerEventMessage = { + payload?: unknown + tag?: string + type: 'DOMSTACK_PERIODIC_SYNC_RECEIVED' | 'DOMSTACK_PUSH_RECEIVED' | 'DOMSTACK_SYNC_RECEIVED' +} + +async function handlePush (scope: ServiceWorkerGlobalScope, event: PushEventLike): Promise { + const payload = readPushPayload(event) + await Promise.all([ + postToWindowClients(scope, { + payload, + type: 'DOMSTACK_PUSH_RECEIVED', + }), + showPushNotification(scope, payload), + ]) +} + +async function handleSync ( + scope: ServiceWorkerGlobalScope, + event: TaggedExtendableEvent, + type: ServiceWorkerEventMessage['type'] +): Promise { + await postToWindowClients(scope, { + tag: event.tag, + type, + }) +} + +function readPushPayload (event: PushEventLike): unknown { + if (!event.data) return undefined + + try { + return event.data.json() + } catch { + return event.data.text() + } +} + +async function postToWindowClients ( + scope: ServiceWorkerGlobalScope, + message: ServiceWorkerEventMessage +): Promise { + const clients = await scope.clients.matchAll({ + includeUncontrolled: true, + type: 'window', + }) + + for (const client of clients) client.postMessage(message) +} + +async function showPushNotification (scope: ServiceWorkerGlobalScope, payload: unknown): Promise { + if (Notification.permission !== 'granted') return + + await scope.registration.showNotification('Domstack push event', { + body: stringifyPushPayload(payload), + tag: 'domstack-push-demo', + }) +} + +function stringifyPushPayload (payload: unknown): string { + if (payload === undefined) return 'Push event received from DevTools.' + if (typeof payload === 'string') return payload + + try { + return JSON.stringify(payload) + } catch { + return 'Push event received from DevTools.' + } +} diff --git a/examples/static-mpa-workbox-offline/src/globals/service-worker/cache-inspection.ts b/examples/static-mpa-workbox-offline/src/globals/service-worker/cache-inspection.ts new file mode 100644 index 0000000..99c72c0 --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/globals/service-worker/cache-inspection.ts @@ -0,0 +1,94 @@ +/// + +/** + * 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) + const replyPort = event.ports[0] + if (replyPort) { + replyPort.postMessage(inspection) + } else { + event.source?.postMessage(inspection) + } + })()) + + 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-workbox-offline/src/globals/service-worker/service-worker-settings.ts b/examples/static-mpa-workbox-offline/src/globals/service-worker/service-worker-settings.ts new file mode 100644 index 0000000..ed36e5a --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/globals/service-worker/service-worker-settings.ts @@ -0,0 +1,42 @@ +export const cachePrefix = 'domstack-workbox-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 runtimeCacheMaxAgeSeconds = 30 * 24 * 60 * 60 +export const runtimeCacheMaxEntries = 50 +export const runtimeCacheName = `${cachePrefix}-runtime` +export const runtimeCacheableStatuses = [200] as const +export const workboxOfflineFallbackCacheName = 'workbox-offline-fallbacks' +export const workboxPolicyDefineName = '__DOMSTACK_WORKBOX_POLICY__' + +export const cachePrefixes = [cachePrefix, workboxOfflineFallbackCacheName] as const + +export type StaticMpaWorkboxManifestVars = { + offline?: boolean + precache?: boolean +} + +export type StaticMpaWorkboxPageVars = StaticMpaWorkboxManifestVars & { + layout?: 'root' | 'admin' | 'progressive-cache' + title?: string +} + +export type StaticMpaWorkboxPolicy = { + offlineFallbackUrl: string +} + +export type WorkboxPrecacheEntry = { + integrity?: string + revision: string | null + url: string +} + +export type StaticMpaWorkboxServiceWorkerPolicy = { + networkOnlyUrls: string[] + offlineFallbackUrl: string + precacheManifest: WorkboxPrecacheEntry[] + runtimeUrls: string[] + version: string +} diff --git a/examples/static-mpa-workbox-offline/src/globals/service-worker/service-worker.ts b/examples/static-mpa-workbox-offline/src/globals/service-worker/service-worker.ts new file mode 100644 index 0000000..79525e4 --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/globals/service-worker/service-worker.ts @@ -0,0 +1,129 @@ +/// + +import { CacheableResponsePlugin } from 'workbox-cacheable-response' +import { clientsClaim, setCacheNameDetails } from 'workbox-core' +import { ExpirationPlugin } from 'workbox-expiration' +import { handleCacheInspectionMessage } from './cache-inspection.ts' +import { initializeBackgroundEventDemos } from './background-events.ts' +import { + cleanupOutdatedCaches, + precacheAndRoute, +} from 'workbox-precaching' +import { offlineFallback } from 'workbox-recipes' +import { registerRoute } from 'workbox-routing' +import { NetworkFirst, NetworkOnly } from 'workbox-strategies' +import { + cachePrefix, + cachePrefixes, + runtimeCacheMaxAgeSeconds, + runtimeCacheMaxEntries, + runtimeCacheName, + runtimeCacheableStatuses, + type StaticMpaWorkboxServiceWorkerPolicy, +} from '#service-worker-settings' + +/** + * Workbox service-worker entrypoint for the static MPA offline example. + * + * Domstack's `manifestBuilt` hook computes final policy data from the build + * manifest and injects it into this final service-worker bundle. The + * Workbox-native `precacheManifest` field is passed directly to Workbox. + */ + +export {} + +declare const self: ServiceWorkerGlobalScope +declare const __DOMSTACK_WORKBOX_POLICY__: StaticMpaWorkboxServiceWorkerPolicy + +const manifestEnabled = process.env.DOMSTACK_MANIFEST_ENABLED === 'true' +const manifestVersion = process.env.DOMSTACK_MANIFEST_VERSION ?? '' + +setCacheNameDetails({ prefix: cachePrefix }) +initializeBackgroundEventDemos(self) + +if (manifestEnabled) { + const policy = __DOMSTACK_WORKBOX_POLICY__ + const runtimeStrategy = new NetworkFirst({ + cacheName: runtimeCacheName, + plugins: [ + new CacheableResponsePlugin({ + statuses: [...runtimeCacheableStatuses], + }), + new ExpirationPlugin({ + maxAgeSeconds: runtimeCacheMaxAgeSeconds, + maxEntries: runtimeCacheMaxEntries, + purgeOnQuotaError: true, + }), + ], + }) + if (manifestVersion && policy.version !== manifestVersion) { + throw new Error('Generated Workbox policy version does not match the bundled domstack manifest version.') + } + + const runtimeUrls = new Set(policy.runtimeUrls.map(normalizePath)) + const networkOnlyUrls = new Set(policy.networkOnlyUrls.map(normalizePath)) + + cleanupOutdatedCaches() + precacheAndRoute(policy.precacheManifest, { + ignoreURLParametersMatching: [/^utm_/, /^fbclid$/], + }) + + registerRoute( + ({ request, url }) => request.mode === 'navigate' && runtimeUrls.has(normalizePath(url.pathname)), + runtimeStrategy + ) + + registerRoute( + ({ request, url }) => request.mode !== 'navigate' && runtimeUrls.has(normalizePath(url.pathname)), + runtimeStrategy + ) + + registerRoute( + ({ request, url }) => request.mode === 'navigate' && networkOnlyUrls.has(normalizePath(url.pathname)), + new NetworkOnly() + ) + + offlineFallback({ + pageFallback: policy.offlineFallbackUrl, + }) + + clientsClaim() + + self.addEventListener('message', event => { + if (handleCacheInspectionMessage({ cachePrefixes: [...cachePrefixes] }, event)) return + + if (event.data?.type === 'SKIP_WAITING') { + event.waitUntil(self.skipWaiting()) + return + } + + if (event.data?.type === 'RESET_SERVICE_WORKER') { + event.waitUntil(resetServiceWorker()) + } + }) +} else { + // Watch builds disable manifest policy, so activate a self-clearing worker instead of stale offline routes. + clientsClaim() + + self.addEventListener('install', () => { + self.skipWaiting() + }) + + self.addEventListener('activate', event => { + event.waitUntil(resetServiceWorker()) + }) +} + +async function resetServiceWorker (): Promise { + const cacheNames = await caches.keys() + await Promise.all( + cacheNames + .filter(name => cachePrefixes.some(cachePrefix => name.startsWith(cachePrefix))) + .map(name => caches.delete(name)) + ) +} + +function normalizePath (pathname: string): string { + if (pathname.endsWith('/index.html')) return pathname.slice(0, -'index.html'.length) + return pathname +} diff --git a/examples/static-mpa-workbox-offline/src/layouts/admin.layout.ts b/examples/static-mpa-workbox-offline/src/layouts/admin.layout.ts new file mode 100644 index 0000000..4e2dc4a --- /dev/null +++ b/examples/static-mpa-workbox-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 { StaticMpaWorkboxPageVars } 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 Workbox does not store or + * replay potentially private content while offline. + */ +export const vars = { + offline: false, + precache: false, +} satisfies StaticMpaWorkboxPageVars + +/** 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-workbox-offline/src/layouts/progressive-cache.layout.ts b/examples/static-mpa-workbox-offline/src/layouts/progressive-cache.layout.ts new file mode 100644 index 0000000..13f0f49 --- /dev/null +++ b/examples/static-mpa-workbox-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 { StaticMpaWorkboxPageVars } 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. Workbox learns + * the page and same-route subresources through runtime caching. + */ +export const vars = { + offline: true, + precache: false, +} satisfies StaticMpaWorkboxPageVars + +/** 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-workbox-offline/src/layouts/render-policy-layout.ts b/examples/static-mpa-workbox-offline/src/layouts/render-policy-layout.ts new file mode 100644 index 0000000..96febeb --- /dev/null +++ b/examples/static-mpa-workbox-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 Workbox 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-workbox-offline/src/layouts/root.layout.ts b/examples/static-mpa-workbox-offline/src/layouts/root.layout.ts new file mode 100644 index 0000000..0501273 --- /dev/null +++ b/examples/static-mpa-workbox-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 { StaticMpaWorkboxPageVars } from '#service-worker-settings' +import { renderPolicyLayout } from './render-policy-layout.ts' + +/** + * Default layout for public offline-first pages. + * + * Pages using this layout are included in the generated Workbox precache by + * default, so they should be safe to serve immediately while offline. + */ +export const vars = { + offline: true, + precache: true, +} satisfies StaticMpaWorkboxPageVars + +/** 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-workbox-offline/src/mark-page-client-loaded.ts b/examples/static-mpa-workbox-offline/src/mark-page-client-loaded.ts new file mode 100644 index 0000000..b7f611d --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/offline/client.ts b/examples/static-mpa-workbox-offline/src/offline/client.ts new file mode 100644 index 0000000..50ec0d4 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/offline/page.md b/examples/static-mpa-workbox-offline/src/offline/page.md new file mode 100644 index 0000000..6a5e20a --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/offline/style.css b/examples/static-mpa-workbox-offline/src/offline/style.css new file mode 100644 index 0000000..3fe90ad --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/offline/style.css @@ -0,0 +1,5 @@ +@import "../page-style.css"; + +main { + --page-accent: darkorange; +} diff --git a/examples/static-mpa-workbox-offline/src/page-style.css b/examples/static-mpa-workbox-offline/src/page-style.css new file mode 100644 index 0000000..cd22656 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/page.md b/examples/static-mpa-workbox-offline/src/page.md new file mode 100644 index 0000000..e8d153a --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/page.md @@ -0,0 +1,86 @@ +# Static MPA Workbox Offline Example + +This small multi-page app demonstrates Workbox precaching and runtime caching driven by domstack's final build manifest. + +- `/service-worker.js` is stable and un-hashed. +- The service worker source uses normal Workbox calls such as `precacheAndRoute(policy.precacheManifest)`. +- `hooks.manifestBuilt` injects Workbox precache entries into the final service-worker bundle. +- 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 StaticMpaWorkboxPageVars = { + 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` reads the finished manifest in a `manifestBuilt` hook and injects `__DOMSTACK_WORKBOX_POLICY__` into the final service-worker bundle. The service worker passes `policy.precacheManifest` directly to Workbox and uses the remaining policy fields to configure runtime routes and the offline fallback. + +## 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, Workbox stores matching navigations and same-route subresources with a `NetworkFirst` runtime strategy. + +After visiting a runtime page online once, reload it offline to confirm it was learned at runtime. diff --git a/examples/static-mpa-workbox-offline/src/progressive-cache/alpha/client.ts b/examples/static-mpa-workbox-offline/src/progressive-cache/alpha/client.ts new file mode 100644 index 0000000..c354be2 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/alpha/page.md b/examples/static-mpa-workbox-offline/src/progressive-cache/alpha/page.md new file mode 100644 index 0000000..4e4ee20 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/alpha/style.css b/examples/static-mpa-workbox-offline/src/progressive-cache/alpha/style.css new file mode 100644 index 0000000..b29e7cc --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/assets/client.ts b/examples/static-mpa-workbox-offline/src/progressive-cache/assets/client.ts new file mode 100644 index 0000000..2818f70 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/assets/details/client.ts b/examples/static-mpa-workbox-offline/src/progressive-cache/assets/details/client.ts new file mode 100644 index 0000000..acdbc8e --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/assets/details/details-badge.svg b/examples/static-mpa-workbox-offline/src/progressive-cache/assets/details/details-badge.svg new file mode 100644 index 0000000..f8fd77e --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/assets/details/page.md b/examples/static-mpa-workbox-offline/src/progressive-cache/assets/details/page.md new file mode 100644 index 0000000..181985f --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/assets/details/page.vars.ts b/examples/static-mpa-workbox-offline/src/progressive-cache/assets/details/page.vars.ts new file mode 100644 index 0000000..b2efab8 --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/progressive-cache/assets/details/page.vars.ts @@ -0,0 +1,5 @@ +import type { StaticMpaWorkboxPageVars } from '#service-worker-settings' + +export default { + layout: 'progressive-cache', +} satisfies StaticMpaWorkboxPageVars diff --git a/examples/static-mpa-workbox-offline/src/progressive-cache/assets/details/style.css b/examples/static-mpa-workbox-offline/src/progressive-cache/assets/details/style.css new file mode 100644 index 0000000..3fcdaa2 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/assets/page.md b/examples/static-mpa-workbox-offline/src/progressive-cache/assets/page.md new file mode 100644 index 0000000..904173c --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/assets/page.vars.ts b/examples/static-mpa-workbox-offline/src/progressive-cache/assets/page.vars.ts new file mode 100644 index 0000000..b2efab8 --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/progressive-cache/assets/page.vars.ts @@ -0,0 +1,5 @@ +import type { StaticMpaWorkboxPageVars } from '#service-worker-settings' + +export default { + layout: 'progressive-cache', +} satisfies StaticMpaWorkboxPageVars diff --git a/examples/static-mpa-workbox-offline/src/progressive-cache/assets/runtime-badge.svg b/examples/static-mpa-workbox-offline/src/progressive-cache/assets/runtime-badge.svg new file mode 100644 index 0000000..87ebb98 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/assets/style.css b/examples/static-mpa-workbox-offline/src/progressive-cache/assets/style.css new file mode 100644 index 0000000..5503fb0 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/beta/client.ts b/examples/static-mpa-workbox-offline/src/progressive-cache/beta/client.ts new file mode 100644 index 0000000..02c9b63 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/beta/page.md b/examples/static-mpa-workbox-offline/src/progressive-cache/beta/page.md new file mode 100644 index 0000000..90442c6 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/beta/style.css b/examples/static-mpa-workbox-offline/src/progressive-cache/beta/style.css new file mode 100644 index 0000000..b99f93e --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/override/client.ts b/examples/static-mpa-workbox-offline/src/progressive-cache/override/client.ts new file mode 100644 index 0000000..3c641dc --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/override/page.md b/examples/static-mpa-workbox-offline/src/progressive-cache/override/page.md new file mode 100644 index 0000000..cf60b79 --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/progressive-cache/override/page.vars.ts b/examples/static-mpa-workbox-offline/src/progressive-cache/override/page.vars.ts new file mode 100644 index 0000000..8799c83 --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/progressive-cache/override/page.vars.ts @@ -0,0 +1,5 @@ +import type { StaticMpaWorkboxPageVars } from '#service-worker-settings' + +export default { + precache: true, +} satisfies StaticMpaWorkboxPageVars diff --git a/examples/static-mpa-workbox-offline/src/progressive-cache/override/style.css b/examples/static-mpa-workbox-offline/src/progressive-cache/override/style.css new file mode 100644 index 0000000..84d608e --- /dev/null +++ b/examples/static-mpa-workbox-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-workbox-offline/src/style.css b/examples/static-mpa-workbox-offline/src/style.css new file mode 100644 index 0000000..83c2532 --- /dev/null +++ b/examples/static-mpa-workbox-offline/src/style.css @@ -0,0 +1,5 @@ +@import "./page-style.css"; + +main { + --page-accent: dodgerblue; +} diff --git a/examples/static-mpa-workbox-offline/tsconfig.json b/examples/static-mpa-workbox-offline/tsconfig.json new file mode 100644 index 0000000..174f9cb --- /dev/null +++ b/examples/static-mpa-workbox-offline/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["src/**/*.ts"] +} diff --git a/plans/workbox-workflow-integration.md b/plans/workbox-workflow-integration.md new file mode 100644 index 0000000..b4a6c3f --- /dev/null +++ b/plans/workbox-workflow-integration.md @@ -0,0 +1,227 @@ +# Workbox Workflow Integration Plan + +## Status: Example implemented with injected policy + +`examples/static-mpa-workbox-offline` demonstrates the current preferred Workbox integration model. + +Domstack does not run Workbox `injectManifest` and does not generate a JavaScript global loaded with `importScripts()`. + +Instead, `hooks.manifestBuilt` computes a Workbox-oriented policy from the finalized Domstack manifest and injects it into the final `/service-worker.js` bundle with `context.defineServiceWorkerConstant()`. + +The authored service worker remains normal user code. + +It imports Workbox packages directly and passes the generated `precacheManifest` field to Workbox APIs. + +## Goals + +- Support Workbox without forcing it into domstack core. +- Generate correct Workbox precache data from Domstack's real emitted outputs. +- Keep user-authored service-worker code inspectable and customizable. +- Use Workbox for mature precaching, routing, strategies, plugins, and update helpers. +- Avoid runtime policy fetches and generated global scripts. +- Keep watch mode safe when no manifest policy exists. + +## Non-goals + +- Do not make service workers automatic for every domstack build. +- Do not require Workbox as a core dependency for sites that do not opt into it. +- Do not cache API/data endpoints by default. +- Do not hide service-worker stickiness or recovery requirements. +- Do not replace custom user-authored service workers. + +## Current example architecture + +The Workbox example uses: + +- `src/globals/domstack-manifest/domstack-manifest.settings.ts` +- `src/globals/domstack-manifest/policy-build.ts` +- `src/globals/service-worker/service-worker.ts` +- `src/globals/global-client/*` + +The manifest settings hook injects a policy constant: + +```ts +context.defineServiceWorkerConstant('__DOMSTACK_WORKBOX_POLICY__', { + version: manifest.version, + offlineFallbackUrl: '/offline/', + precacheManifest: [ + { url: '/', revision: 'sha256hex...' }, + { url: '/about/', revision: 'sha256hex...' }, + { url: '/global-ABC123.css', revision: null, integrity: 'sha256-...' }, + ], + runtimeUrls: ['/progressive-cache/'], + networkOnlyUrls: ['/admin/'], +}) +``` + +The service worker reads the injected policy inside the manifest-enabled branch: + +```ts +declare const __DOMSTACK_WORKBOX_POLICY__: StaticMpaWorkboxServiceWorkerPolicy + +if (manifestEnabled) { + const policy = __DOMSTACK_WORKBOX_POLICY__ + precacheAndRoute(policy.precacheManifest) +} +``` + +The policy constant must not be read at module top level in watch mode. + +Watch mode builds do not define it. + +## Workbox APIs currently used + +The example uses: + +- `workbox-precaching` + - `precacheAndRoute()` + - `cleanupOutdatedCaches()` + - `matchPrecache()` where needed by fallback behavior +- `workbox-routing` + - `registerRoute()` + - `setCatchHandler()` +- `workbox-strategies` + - `NetworkFirst` + - `NetworkOnly` +- `workbox-cacheable-response` + - `CacheableResponsePlugin` +- `workbox-expiration` + - `ExpirationPlugin` +- `workbox-recipes` + - `offlineFallback()` +- `workbox-window` + - registration lifecycle events + - `messageSkipWaiting()` + +## Policy shape + +Workbox's native precache input is: + +```ts +type WorkboxPrecacheEntry = { + url: string + revision: string | null + integrity?: string +} +``` + +The example policy includes that native shape plus app-specific route policy: + +```ts +type StaticMpaWorkboxServiceWorkerPolicy = { + version: string + offlineFallbackUrl: string + precacheManifest: WorkboxPrecacheEntry[] + runtimeUrls: string[] + networkOnlyUrls: string[] +} +``` + +Only `precacheManifest` is passed directly to Workbox precaching. + +`runtimeUrls`, `networkOnlyUrls`, and `offlineFallbackUrl` are app policy and are mapped explicitly to Workbox routing/strategy APIs. + +## Why injected policy is preferred + +Injected policy has these advantages: + +- no runtime fetch for a policy JSON file +- no generated JS file imported by the service worker +- no `importScripts()` convention +- no Workbox `self.__WB_MANIFEST` source transform +- policy changes alter `/service-worker.js` bytes +- browser update lifecycle is triggered naturally +- the authored service worker remains regular bundled module code + +This means Domstack only needs the general `manifestBuilt` hook and final service-worker build step. + +It does not need Workbox-specific source transformation in core. + +## Watch mode + +Workbox precaching is disabled in watch mode. + +Watch mode sets `DOMSTACK_MANIFEST_ENABLED=false` and does not run the manifest/policy injection path. + +The service worker branch for watch mode: + +- installs immediately +- deletes owned caches +- unregisters itself +- registers no Workbox routes +- does not touch the injected policy constant + +The client also unregisters existing workers and clears known caches in watch mode. + +Watch builds disable esbuild splitting so `/service-worker.js` stays self-contained during cleanup. + +## Client lifecycle + +The Workbox example uses `workbox-window` because it provides cleaner lifecycle events than hand-rolled registration logic. + +Current behavior: + +- `installing` shows “Installing offline cache…” +- `activated` shows ready state for first install +- `waiting` prompts for update or applies a previously waiting update +- `controlling` reloads after accepted updates +- `redundant` logs to the console only + +Watch-mode cleanup happens before normal registration and does not wait for `window.load`. + +Production registration waits for `window.load`. + +## Runtime caching policy + +The example only runtime-caches routes selected by manifest vars. + +It uses Workbox plugins to keep runtime cache behavior bounded: + +- `CacheableResponsePlugin` limits which responses can enter the cache. +- `ExpirationPlugin` limits cache age/count. + +The example does not cache arbitrary API/data requests by default. + +## Potential package helper + +A future helper could live outside core or as an optional export: + +```ts +import { createWorkboxPolicy } from '@domstack/static/workbox' + +export default { + hooks: { + manifestBuilt: [context => { + context.defineServiceWorkerConstant( + '__APP_WORKBOX_POLICY__', + createWorkboxPolicy(context.manifest, options), + ) + }], + }, +} +``` + +The helper could cover: + +- max precache size +- include/exclude filters +- revision/null handling for hashed URLs +- optional integrity inclusion +- route-policy derivation from selected `manifestVars` +- warnings for skipped entries + +This should remain optional. + +Domstack core should continue exposing generic manifest hooks rather than hard-coding Workbox behavior. + +## Deprecated ideas + +These ideas were considered but are not the current direction: + +- generating a public Workbox manifest module and importing it from the service worker +- fetching a policy JSON file during service-worker install +- using `importScripts()` to load generated globals +- transforming `self.__WB_MANIFEST` like Workbox `injectManifest` +- generating the entire Workbox service worker from core config + +They remain possible for external integrations, but the example and current plan prefer injected constants.