diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 123842dd..70eb2539 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,7 +20,9 @@ updates: - "react-dom" - "@types/react" - "@types/react-dom" - + workbox: + patterns: + - "workbox-*" schedule: interval: "daily" diff --git a/README.md b/README.md index f081e7d9..1063cc55 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,8 @@ $ npm --workspace @domstack/basic-example run build Each example is an npm workspace under `examples/*`, so a root `npm install` links the local `@domstack/static` package into example builds. Example packages keep `@domstack/static` declared as `file:../../.` so the local dependency target remains explicit. +The [`examples/workbox-pwa`](./examples/workbox-pwa) workspace shows a Workbox-backed implementation that converts the unstable domstack manifest preview into a Workbox precache. + ### Additional examples Here are some additional external examples of larger domstack projects. @@ -1087,7 +1089,8 @@ const html = renderCache.get(page.pageInfo.path) ?? '' Every programmatic build returns a `domstackManifest` object in the build results. The CLI also writes `domstack-manifest.json` into the destination directory by default. Applications can use this manifest as the source of truth for service-worker precaching, deployment metadata, or other static output -integrations. +integrations. See [`examples/workbox-pwa`](./examples/workbox-pwa) for a Workbox-backed implementation +that injects domstack manifest entries into Workbox's precache manifest. The manifest is a normalized list of files that domstack emitted: diff --git a/examples/workbox-pwa/README.md b/examples/workbox-pwa/README.md new file mode 100644 index 00000000..e1d1d52c --- /dev/null +++ b/examples/workbox-pwa/README.md @@ -0,0 +1,74 @@ +# DOMStack Workbox PWA Example + +This example shows a static PWA that uses Domstack's unstable preview `domstack-manifest.json` output as the source of truth for a Workbox precache. + +> [!WARNING] +> The domstack manifest and first-class service-worker support shown here are unstable preview features. +> The option names, manifest schema, generated outputs, and browser defines may change outside of a major version while the API is validated. +> Pin `@domstack/static` to an exact version before building long-lived integrations on this preview contract. + +It demonstrates: + +- A `service-worker.js` source that Domstack bundles to `/service-worker.js`. +- Workbox `precacheAndRoute(self.__WB_MANIFEST)`, `NavigationRoute`, and `PrecacheFallbackPlugin` using Domstack manifest entries injected at build time. +- A `settings/domstack-manifest.settings.js` file that filters the generated domstack manifest. +- Shared PWA policy in `settings/cache-policy.js`. +- Domstack browser defines isolated in `globals/domstack.js`. +- Domstack global assets grouped in `globals/global.client.js`, `globals/global.css`, and `globals/global.vars.js`. +- Offline precaching for app, docs, legal-style pages, static assets, shared chunks, and the web app manifest. +- Excluding `/blog/**`, `/admin/**`, source maps, metadata files, and pages with `precache: false` or `offline: false`. + +## Running + +```bash +npm install +npm --workspace @domstack/workbox-pwa-example run serve +``` + +The example serves on by default so it can run alongside the hand-rolled PWA example on port 3000. + +Service workers require a secure origin. `localhost` is allowed by browsers, and `npm run serve` runs a manifest-enabled build so the Workbox precache path works there. It also serves without live-reload HTML injection so fetched files match the domstack manifest revisions. + +Wait until the UI says `Offline cache current` before testing an offline refresh. To clear all example workers and caches: + +```txt +/?reset-sw=1 +``` + +## Files + +```txt +scripts/ + inject-domstack-manifest.js # Converts Domstack manifest entries into self.__WB_MANIFEST + serve.js # Serves the already-built public/ directory on port 3001 +src/ + globals/ + domstack.js # Browser defines injected by Domstack + global.client.js # Registers the Workbox-powered service worker + global.css # Site styles + global.vars.js # Shared page variables + settings/ + cache-policy.js # Shared domstack manifest filtering and route policy + domstack-manifest.settings.js # Filters the written domstack manifest + manifest.webmanifest # Web app manifest copied as a static asset + service-worker.js # Workbox-powered site service worker entry +``` + +## Production Pattern + +This example follows Workbox's `injectManifest`-style runtime pattern: + +```js +precacheAndRoute(self.__WB_MANIFEST) +``` + +Domstack emits `public/domstack-manifest.json` with `{ url, revision }` records. After Domstack builds and bundles `src/service-worker.js`, `scripts/inject-domstack-manifest.js` converts the emitted Domstack manifest entries into Workbox precache entries and replaces the `self.__WB_MANIFEST` placeholder in `public/service-worker.js`. + +Application policy stays in app code: + +- `settings/domstack-manifest.settings.js` decides what can ever enter the manifest. +- `settings/cache-policy.js` centralizes manifest filtering and the offline fallback URL. +- `service-worker.js` uses Workbox's documented precache route plus a network-only navigation route with a precached offline fallback. +- `globals/global.client.js` decides when to register, reset, and report cache status. + +Watch mode does not write the domstack manifest or run the injection step, so use `npm run serve` when testing the offline lifecycle. diff --git a/examples/workbox-pwa/package.json b/examples/workbox-pwa/package.json new file mode 100644 index 00000000..fe41ad2c --- /dev/null +++ b/examples/workbox-pwa/package.json @@ -0,0 +1,32 @@ +{ + "name": "@domstack/workbox-pwa-example", + "version": "0.0.0", + "description": "DOMStack domstack manifest + Workbox offline cache example", + "type": "module", + "scripts": { + "start": "npm run serve", + "build": "npm run clean && domstack && node scripts/inject-domstack-manifest.js", + "clean": "rm -rf public && mkdir -p public", + "serve": "npm run build && node scripts/serve.js", + "watch": "npm run clean && dom --watch" + }, + "keywords": [ + "domstack", + "pwa", + "service-worker", + "offline", + "workbox" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@domstack/static": "file:../../.", + "workbox-core": "^7.4.1", + "workbox-precaching": "^7.4.1", + "workbox-routing": "^7.4.1", + "workbox-strategies": "^7.4.1" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" + } +} diff --git a/examples/workbox-pwa/scripts/inject-domstack-manifest.js b/examples/workbox-pwa/scripts/inject-domstack-manifest.js new file mode 100644 index 00000000..30e50b3f --- /dev/null +++ b/examples/workbox-pwa/scripts/inject-domstack-manifest.js @@ -0,0 +1,65 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const publicDir = fileURLToPath(new URL('../public/', import.meta.url)) +const manifestPath = join(publicDir, 'domstack-manifest.json') +const serviceWorkerPath = join(publicDir, 'service-worker.js') + +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) +if (!isDomstackManifest(manifest)) { + throw new Error('Expected public/domstack-manifest.json to contain a Domstack manifest') +} + +const precacheEntries = manifest.entries + .filter(entry => entry.revision) + .map(entry => ({ + revision: entry.revision, + url: entry.url, + })) + +if (!precacheEntries.some(entry => entry.url === '/offline/' || entry.url === '/offline/index.html')) { + throw new Error('Expected the Domstack manifest to include the offline fallback route') +} + +let serviceWorker = await readFile(serviceWorkerPath, 'utf8') +if (!serviceWorker.includes('self.__WB_MANIFEST')) { + throw new Error('Expected public/service-worker.js to include the Workbox self.__WB_MANIFEST placeholder') +} + +serviceWorker = serviceWorker.replace( + 'self.__WB_MANIFEST', + JSON.stringify(precacheEntries) +) +serviceWorker = serviceWorker.replace( + /(["'])__DOMSTACK_PRECACHE_VERSION__\1/g, + JSON.stringify(manifest.version) +) + +await writeFile(serviceWorkerPath, serviceWorker) +console.info( + `[domstack-workbox-pwa] injected ${precacheEntries.length} Domstack manifest entries into service-worker.js (${manifest.version.slice(0, 12)})` +) + +/** + * @param {unknown} value + * @returns {value is { version: string, entries: Array<{ url: string, revision?: string | null }> }} + */ +function isDomstackManifest (value) { + if (!value || typeof value !== 'object') return false + const manifest = /** @type {{ version?: unknown, entries?: unknown }} */ (value) + return typeof manifest.version === 'string' && + Array.isArray(manifest.entries) && + manifest.entries.every(isDomstackManifestEntry) +} + +/** + * @param {unknown} value + * @returns {value is { url: string, revision?: string | null }} + */ +function isDomstackManifestEntry (value) { + if (!value || typeof value !== 'object') return false + const entry = /** @type {{ url?: unknown, revision?: unknown }} */ (value) + return typeof entry.url === 'string' && + (entry.revision === undefined || entry.revision === null || typeof entry.revision === 'string') +} diff --git a/examples/workbox-pwa/scripts/serve.js b/examples/workbox-pwa/scripts/serve.js new file mode 100644 index 00000000..5f686fa4 --- /dev/null +++ b/examples/workbox-pwa/scripts/serve.js @@ -0,0 +1,103 @@ +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { createServer } from 'node:http' +import { extname, join, normalize, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +const publicDir = normalize(fileURLToPath(new URL('../public/', import.meta.url))) +const port = Number.parseInt(process.env.PORT ?? '3001', 10) + +const server = createServer(async (request, response) => { + if (!request.url) { + response.writeHead(400) + response.end('Bad request') + return + } + + try { + const filePath = await resolveFilePath(request.url) + if (!filePath) { + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + response.end('Not found') + return + } + + response.writeHead(200, headersFor(filePath)) + createReadStream(filePath).pipe(response) + } catch (err) { + response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }) + response.end(err instanceof Error ? err.message : String(err)) + } +}) + +server.listen(port, () => { + console.info(`[domstack-workbox-pwa] serving public/ on http://localhost:${port}`) + console.info('[domstack-workbox-pwa] reset service workers and caches with /?reset-sw=1') +}) + +/** + * @param {string} requestUrl + */ +async function resolveFilePath (requestUrl) { + const url = new URL(requestUrl, `http://localhost:${port}`) + const pathname = decodeURIComponent(url.pathname) + const requestedPath = normalize(join(publicDir, pathname)) + if (!requestedPath.startsWith(`${publicDir}${sep}`) && requestedPath !== publicDir) return null + + const candidates = pathname.endsWith('/') + ? [join(requestedPath, 'index.html')] + : [requestedPath, join(requestedPath, 'index.html')] + + for (const candidate of candidates) { + if (await isFile(candidate)) return candidate + } + + return null +} + +/** + * @param {string} filePath + */ +async function isFile (filePath) { + try { + return (await stat(filePath)).isFile() + } catch { + return false + } +} + +/** + * @param {string} filePath + */ +function headersFor (filePath) { + return { + 'cache-control': filePath.endsWith('/service-worker.js') + ? 'no-store' + : 'public, max-age=0, must-revalidate', + 'content-type': contentTypeFor(filePath), + } +} + +/** + * @param {string} filePath + */ +function contentTypeFor (filePath) { + switch (extname(filePath)) { + case '.css': + return 'text/css; charset=utf-8' + case '.html': + return 'text/html; charset=utf-8' + case '.js': + return 'text/javascript; charset=utf-8' + case '.json': + return 'application/json; charset=utf-8' + case '.map': + return 'application/json; charset=utf-8' + case '.svg': + return 'image/svg+xml' + case '.webmanifest': + return 'application/manifest+json; charset=utf-8' + default: + return 'application/octet-stream' + } +} diff --git a/examples/workbox-pwa/src/README.md b/examples/workbox-pwa/src/README.md new file mode 100644 index 00000000..5e5b2658 --- /dev/null +++ b/examples/workbox-pwa/src/README.md @@ -0,0 +1,79 @@ +--- +title: App Shell +--- + +
+
+
+

Workbox PWA shell

+

This page is built as ordinary static HTML, then a Workbox-powered service worker converts Domstack's domstack manifest into a precache for offline launches.

+
+ Domstack manifest + Workbox precache + Offline shell + No API cache +
+
+
+
+ Worker + Not registered +
+
+ Cache version + Waiting for domstack manifest +
+
+ Network + Checking +
+
+
+ + + + +
diff --git a/examples/workbox-pwa/src/admin/README.md b/examples/workbox-pwa/src/admin/README.md new file mode 100644 index 00000000..1be29f24 --- /dev/null +++ b/examples/workbox-pwa/src/admin/README.md @@ -0,0 +1,11 @@ +--- +title: Admin +precache: false +offline: false +--- + +# Admin + +This protected route is intentionally excluded from the PWA precache by both path policy and page vars. + +Admin traffic should stay network-only in this example. diff --git a/examples/workbox-pwa/src/blog/README.md b/examples/workbox-pwa/src/blog/README.md new file mode 100644 index 00000000..dcd6721a --- /dev/null +++ b/examples/workbox-pwa/src/blog/README.md @@ -0,0 +1,13 @@ +--- +title: Blog +precache: false +offline: false +--- + +# Blog + +This route is present in the static site but filtered out of the first PWA cache by `settings/domstack-manifest.settings.js`. + +Its page vars also set `precache: false` and `offline: false`, which lets the cache policy reject it without relying only on path names. + +- [First post](/blog/first-post/) diff --git a/examples/workbox-pwa/src/blog/first-post/README.md b/examples/workbox-pwa/src/blog/first-post/README.md new file mode 100644 index 00000000..856bfde0 --- /dev/null +++ b/examples/workbox-pwa/src/blog/first-post/README.md @@ -0,0 +1,9 @@ +--- +title: First Post +precache: false +offline: false +--- + +# First Post + +Blog content exists in the generated site, but the example cache policy excludes `/blog/**` from the first install cache. diff --git a/examples/workbox-pwa/src/docs/README.md b/examples/workbox-pwa/src/docs/README.md new file mode 100644 index 00000000..5dd70fc7 --- /dev/null +++ b/examples/workbox-pwa/src/docs/README.md @@ -0,0 +1,23 @@ +--- +title: Docs +--- + +# Docs + +This route is intentionally included in the PWA cache. It represents static documentation, legal pages, help pages, or other public content that should remain available after the first online visit. + +The service worker gets this page from the generated domstack manifest instead of a handwritten list. + +## Cache Inputs + +- Page HTML, including `/docs/` +- Global CSS and JavaScript bundles +- Shared chunks +- Static icons and the web app manifest + +## Cache Exclusions + +- `/api/**` requests +- `/admin/**` routes +- `/blog/**` routes +- Source maps and Domstack metadata diff --git a/examples/workbox-pwa/src/globals/domstack.js b/examples/workbox-pwa/src/globals/domstack.js new file mode 100644 index 00000000..25f46f51 --- /dev/null +++ b/examples/workbox-pwa/src/globals/domstack.js @@ -0,0 +1,4 @@ +export const DOMSTACK_MANIFEST_URL = process.env.DOMSTACK_MANIFEST_URL ?? '/domstack-manifest.json' +export const DOMSTACK_MANIFEST_ENABLED = process.env.DOMSTACK_MANIFEST_ENABLED === 'true' +export const DOMSTACK_SERVICE_WORKER_URL = process.env.DOMSTACK_SERVICE_WORKER_URL ?? '/service-worker.js' +export const DOMSTACK_SERVICE_WORKER_SCOPE = process.env.DOMSTACK_SERVICE_WORKER_SCOPE ?? '/' diff --git a/examples/workbox-pwa/src/globals/global.client.js b/examples/workbox-pwa/src/globals/global.client.js new file mode 100644 index 00000000..f6bbf9b5 --- /dev/null +++ b/examples/workbox-pwa/src/globals/global.client.js @@ -0,0 +1,189 @@ +import { + DOMSTACK_MANIFEST_ENABLED, + DOMSTACK_MANIFEST_URL, + DOMSTACK_SERVICE_WORKER_SCOPE, + DOMSTACK_SERVICE_WORKER_URL, +} from './domstack.js' +import { + CACHE_PREFIX, + RESET_PARAM, +} from '../settings/cache-policy.js' + +const LOG_PREFIX = '[domstack-workbox-pwa]' + +initializeWorkboxPwa().catch(err => { + console.warn('Unable to initialize the Domstack Workbox PWA example runtime', err) +}) + +async function initializeWorkboxPwa () { + log('initializing runtime', { + manifestEnabled: DOMSTACK_MANIFEST_ENABLED, + manifestUrl: DOMSTACK_MANIFEST_URL, + serviceWorkerScope: DOMSTACK_SERVICE_WORKER_SCOPE, + serviceWorkerUrl: DOMSTACK_SERVICE_WORKER_URL, + }) + + trackOnlineState() + + if (!('serviceWorker' in navigator)) { + setStatus('Service workers unavailable') + return + } + + if (new URLSearchParams(location.search).has(RESET_PARAM)) { + await resetServiceWorkers() + location.replace(location.pathname || '/') + return + } + + if (!DOMSTACK_MANIFEST_ENABLED || !DOMSTACK_SERVICE_WORKER_URL || !DOMSTACK_SERVICE_WORKER_SCOPE) { + setStatus('PWA disabled for this build') + return + } + + if (isLocalOrigin()) { + console.info( + `${LOG_PREFIX} local manifest-enabled build detected; registering the Workbox service worker.\n` + + 'Use `npm run serve` for PWA testing and `npm run watch` for sticky-cache-free development.' + ) + console.info(`${LOG_PREFIX} reset service workers and caches with:\n${location.origin}/?${RESET_PARAM}=1`) + } + + navigator.serviceWorker.addEventListener('message', event => { + handleWorkerMessage(event.data) + }) + + const registration = await navigator.serviceWorker.register(DOMSTACK_SERVICE_WORKER_URL, { + scope: DOMSTACK_SERVICE_WORKER_SCOPE, + updateViaCache: 'none', + }) + + setStatus(navigator.serviceWorker.controller ? 'Worker active, checking cache' : 'Installing') + log('service worker registered', describeRegistration(registration)) + + registration.addEventListener('updatefound', () => { + const installing = registration.installing + if (!installing) return + setStatus('Installing update') + installing.addEventListener('statechange', () => { + log('installing worker state changed', { state: installing.state }) + if (installing.state === 'installed' && navigator.serviceWorker.controller) { + setStatus('Update installed, reloading') + installing.postMessage({ type: 'SKIP_WAITING' }) + } + }) + }) + + navigator.serviceWorker.addEventListener('controllerchange', () => { + log('service-worker controller changed, reloading') + window.location.reload() + }, { once: true }) + + await registration.update() + const ready = await navigator.serviceWorker.ready + log('service worker ready', describeRegistration(ready)) + setStatus('Worker active, checking cache') + requestCacheStatus() +} + +function requestCacheStatus () { + const controller = navigator.serviceWorker.controller + if (!controller) return + controller.postMessage({ type: 'GET_STATUS' }) + window.setTimeout(() => { + if (getStatus() === 'Worker active, checking cache') { + setStatus('Cache status pending') + } + }, 5000) +} + +/** + * @param {unknown} data + */ +function handleWorkerMessage (data) { + if (!data || typeof data !== 'object') return + const message = /** @type {{ type?: unknown, version?: unknown, error?: unknown }} */ (data) + log('message from service worker', message) + + if (message.type === 'PRECACHE_CURRENT') { + setStatus('Offline cache current') + setVersion(message.version) + } + + if (message.type === 'PRECACHE_FAILED') { + setStatus('Offline cache failed') + console.warn('Workbox precache failed', message.error) + } +} + +async function resetServiceWorkers () { + const registrations = await navigator.serviceWorker.getRegistrations() + await Promise.all(registrations.map(registration => registration.unregister())) + const names = await caches.keys() + await Promise.all( + names.filter(name => name.startsWith(CACHE_PREFIX)).map(name => caches.delete(name)) + ) + setStatus('PWA reset complete') +} + +function trackOnlineState () { + const render = () => { + const node = document.querySelector('[data-online-state]') + if (node) node.textContent = navigator.onLine ? 'Online' : 'Offline' + log('network state changed', { online: navigator.onLine }) + } + + render() + window.addEventListener('online', render) + window.addEventListener('offline', render) +} + +/** + * @param {ServiceWorkerRegistration} registration + */ +function describeRegistration (registration) { + return { + active: Boolean(registration.active), + controlled: Boolean(navigator.serviceWorker.controller), + installing: Boolean(registration.installing), + scope: registration.scope, + waiting: Boolean(registration.waiting), + } +} + +/** + * @param {string} value + */ +function setStatus (value) { + const node = document.querySelector('[data-pwa-status]') + if (node) node.textContent = value +} + +function getStatus () { + const node = document.querySelector('[data-pwa-status]') + return node?.textContent ?? '' +} + +/** + * @param {unknown} value + */ +function setVersion (value) { + const node = document.querySelector('[data-pwa-version]') + if (node && typeof value === 'string') node.textContent = value.slice(0, 12) +} + +function isLocalOrigin () { + return ['localhost', '127.0.0.1', '[::1]'].includes(location.hostname) +} + +/** + * @param {string} message + * @param {unknown} [details] + */ +function log (message, details) { + if (details === undefined) { + console.info(LOG_PREFIX, message) + } else { + console.info(LOG_PREFIX, message, details) + } +} diff --git a/examples/workbox-pwa/src/globals/global.css b/examples/workbox-pwa/src/globals/global.css new file mode 100644 index 00000000..78a78b9a --- /dev/null +++ b/examples/workbox-pwa/src/globals/global.css @@ -0,0 +1,176 @@ +:root { + color-scheme: light; + --bg: #f7f4ed; + --surface: #fffaf0; + --ink: #1f261f; + --muted: #637064; + --accent: #3f5f46; + --accent-ink: #ffffff; + --border: #d9d1bd; + --ok: #dff3df; + --warn: #f8e9b6; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--ink); + background: var(--bg); + font-family: system-ui, sans-serif; + line-height: 1.5; +} + +main, +body > article, +body > section { + width: min(72rem, calc(100% - 2rem)); + margin: 0 auto; + padding: 2rem 0 4rem; +} + +a { + color: var(--accent); +} + +button, +.button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid var(--accent); + border-radius: 999px; + padding: 0.55rem 0.9rem; + color: var(--accent-ink); + background: var(--accent); + font: inherit; + font-weight: 700; + text-decoration: none; + cursor: pointer; +} + +button.secondary, +.button.secondary { + color: var(--accent); + background: transparent; +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +label { + display: grid; + gap: 0.25rem; + margin-block: 0.75rem; + font-weight: 700; +} + +input { + width: min(100%, 28rem); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.6rem; + font: inherit; +} + +.app-layout { + display: grid; + gap: 2rem; +} + +.app-summary, +.route-card, +.pwa-update { + border: 1px solid var(--border); + border-radius: 1rem; + background: var(--surface); + box-shadow: 0 0.5rem 1.5rem rgb(31 38 31 / 0.08); +} + +.app-summary { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem); + gap: 1.5rem; + padding: 1.5rem; +} + +.status-list { + display: grid; + gap: 0.75rem; +} + +.status-row { + display: flex; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid var(--border); + padding-block: 0.5rem; +} + +.route-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; +} + +.route-card { + display: grid; + gap: 0.75rem; + align-content: start; + padding: 1rem; +} + +.route-card h2, +.route-card p { + margin: 0; +} + +.pill-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.pill { + border-radius: 999px; + padding: 0.2rem 0.55rem; + color: var(--ink); + background: #ece7da; + font-size: 0.85rem; + font-weight: 700; +} + +.pill.ok { + background: var(--ok); +} + +.pill.warn { + background: var(--warn); +} + +.pwa-update { + position: fixed; + inset-inline: 1rem; + bottom: 1rem; + max-width: 34rem; + margin-inline: auto; + padding: 1rem; +} + +.pwa-update__actions { + display: flex; + gap: 0.75rem; +} + +@media (max-width: 48rem) { + .app-summary { + grid-template-columns: 1fr; + } +} diff --git a/examples/workbox-pwa/src/globals/global.vars.js b/examples/workbox-pwa/src/globals/global.vars.js new file mode 100644 index 00000000..808830a4 --- /dev/null +++ b/examples/workbox-pwa/src/globals/global.vars.js @@ -0,0 +1,6 @@ +export default { + siteName: 'Domstack Workbox PWA Example', + lang: 'en', + precache: true, + offline: true, +} diff --git a/examples/workbox-pwa/src/legal/README.md b/examples/workbox-pwa/src/legal/README.md new file mode 100644 index 00000000..bf823ef9 --- /dev/null +++ b/examples/workbox-pwa/src/legal/README.md @@ -0,0 +1,9 @@ +--- +title: Legal +--- + +# Legal + +This page represents static legal, policy, help, or support content that can be safely included in the first offline cache. + +The service worker discovers it through `/domstack-manifest.json`; the route is not hard-coded in the worker. diff --git a/examples/workbox-pwa/src/login/README.md b/examples/workbox-pwa/src/login/README.md new file mode 100644 index 00000000..5cf6910d --- /dev/null +++ b/examples/workbox-pwa/src/login/README.md @@ -0,0 +1,20 @@ +--- +title: Login +--- + +# Login + +Auth shells can be precached so the interface opens offline, while form submissions remain network-only. + +
+ + +

Checking network state.

+ +
diff --git a/examples/workbox-pwa/src/manifest.webmanifest b/examples/workbox-pwa/src/manifest.webmanifest new file mode 100644 index 00000000..6b7d46e2 --- /dev/null +++ b/examples/workbox-pwa/src/manifest.webmanifest @@ -0,0 +1,11 @@ +{ + "name": "Domstack Workbox PWA Example", + "short_name": "Workbox PWA", + "description": "A Domstack static PWA example using Workbox with the unstable domstack manifest preview.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f7f4ed", + "theme_color": "#3f5f46", + "icons": [] +} diff --git a/examples/workbox-pwa/src/offline/README.md b/examples/workbox-pwa/src/offline/README.md new file mode 100644 index 00000000..740cc579 --- /dev/null +++ b/examples/workbox-pwa/src/offline/README.md @@ -0,0 +1,9 @@ +--- +title: Offline +--- + +# Offline + +You are offline, and the requested page was not in the static cache. + +Return to the [app shell](/) or reconnect to continue browsing network-only routes. diff --git a/examples/workbox-pwa/src/private/README.md b/examples/workbox-pwa/src/private/README.md new file mode 100644 index 00000000..513fddd2 --- /dev/null +++ b/examples/workbox-pwa/src/private/README.md @@ -0,0 +1,9 @@ +--- +title: Private +precache: false +offline: false +--- + +# Private + +This route demonstrates page-level cache policy. The path is static, but `precache: false` and `offline: false` keep it out of the generated PWA cache plan. diff --git a/examples/workbox-pwa/src/service-worker.js b/examples/workbox-pwa/src/service-worker.js new file mode 100644 index 00000000..4cef886c --- /dev/null +++ b/examples/workbox-pwa/src/service-worker.js @@ -0,0 +1,106 @@ +import { clientsClaim, setCacheNameDetails } from 'workbox-core' +import { + PrecacheFallbackPlugin, + cleanupOutdatedCaches, + precacheAndRoute, +} from 'workbox-precaching' +import { NavigationRoute, registerRoute } from 'workbox-routing' +import { NetworkOnly } from 'workbox-strategies' +import { + CACHE_PREFIX, + OFFLINE_FALLBACK_URL, +} from './settings/cache-policy.js' + +const serviceWorker = /** @type {ServiceWorkerGlobalScope & typeof globalThis} */ (globalThis) +const LOG_PREFIX = '[domstack-workbox-pwa:sw]' +const DOMSTACK_PRECACHE_VERSION = '__DOMSTACK_PRECACHE_VERSION__' + +setCacheNameDetails({ + prefix: CACHE_PREFIX, +}) + +// Domstack writes `domstack-manifest.json`; scripts/inject-domstack-manifest.js +// converts that manifest into Workbox precache entries and replaces this +// placeholder after Domstack bundles the service worker. +precacheAndRoute(self.__WB_MANIFEST, { + ignoreURLParametersMatching: [/.*/], +}) +cleanupOutdatedCaches() + +registerRoute( + new NavigationRoute( + new NetworkOnly({ + plugins: [ + new PrecacheFallbackPlugin({ + fallbackURL: OFFLINE_FALLBACK_URL, + }), + ], + }) + ) +) + +serviceWorker.skipWaiting() +clientsClaim() + +serviceWorker.addEventListener('activate', event => { + event.waitUntil(postPrecacheCurrent()) +}) + +serviceWorker.addEventListener('message', event => { + const data = event.data + if (!data || typeof data !== 'object') return + const message = /** @type {{ type?: unknown }} */ (data) + + if (message.type === 'SKIP_WAITING') { + event.waitUntil(serviceWorker.skipWaiting()) + return + } + + if (message.type === 'GET_STATUS') { + event.waitUntil(postPrecacheCurrent(event.source)) + } +}) + +/** + * @param {Client | ServiceWorker | MessagePort | null | undefined} [source] + */ +async function postPrecacheCurrent (source) { + const message = { + type: 'PRECACHE_CURRENT', + version: getPrecacheVersion(), + } + + if (source && 'postMessage' in source && typeof source.postMessage === 'function') { + source.postMessage(message) + } + + await postToClients(message) + log('reported precache status', { version: message.version }) +} + +function getPrecacheVersion () { + return DOMSTACK_PRECACHE_VERSION.startsWith('__') ? undefined : DOMSTACK_PRECACHE_VERSION +} + +/** + * @param {Record} message + */ +async function postToClients (message) { + const clients = await serviceWorker.clients.matchAll({ + includeUncontrolled: true, + type: 'window', + }) + for (const client of clients) client.postMessage(message) +} + +/** + * @param {string} message + * @param {unknown} [details] + */ +function log (message, details) { + if (details === undefined) { + console.info(LOG_PREFIX, message) + } else { + console.info(LOG_PREFIX, message, details) + } +} diff --git a/examples/workbox-pwa/src/settings/cache-policy.js b/examples/workbox-pwa/src/settings/cache-policy.js new file mode 100644 index 00000000..6e97e98a --- /dev/null +++ b/examples/workbox-pwa/src/settings/cache-policy.js @@ -0,0 +1,50 @@ +export const CACHE_PREFIX = 'domstack-workbox-pwa-example' +export const OFFLINE_FALLBACK_URL = '/offline/' +export const RESET_PARAM = 'reset-sw' + +export const manifestExclude = [ + 'admin/**', + 'blog/**', + '**/*.map', + 'domstack-esbuild-meta.json', + 'domstack-manifest.json', + 'service-worker.js', +] + +export const excludedPathPrefixes = [ + '/admin/', + '/api/', + '/blog/', +] + +export const excludedKinds = new Set([ + 'metadata', + 'sourcemap', + 'service-worker', +]) + +/** + * Shared application policy for deciding which Domstack output entries may be + * precached. Node runs this through domstack-manifest.settings.js; the service + * worker runs it again defensively when reading the emitted manifest. + * + * @param {{ url: string, revision?: string | null, kind?: string, page?: { vars?: { precache?: unknown, offline?: unknown } } }} entry + * @param {string | URL} origin + */ +export function shouldIncludeManifestEntry (entry, origin) { + if (!entry.revision) return false + if (entry.kind && excludedKinds.has(entry.kind)) return false + if (entry.page?.vars?.precache === false || entry.page?.vars?.offline === false) return false + + const url = new URL(entry.url, origin) + if (url.origin !== new URL(origin).origin) return false + + return !excludedPathPrefixes.some(prefix => url.pathname.startsWith(prefix)) +} + +/** + * @param {URL} url + */ +export function isNetworkOnlyPath (url) { + return excludedPathPrefixes.some(prefix => url.pathname.startsWith(prefix)) +} diff --git a/examples/workbox-pwa/src/settings/domstack-manifest.settings.js b/examples/workbox-pwa/src/settings/domstack-manifest.settings.js new file mode 100644 index 00000000..7bc295d4 --- /dev/null +++ b/examples/workbox-pwa/src/settings/domstack-manifest.settings.js @@ -0,0 +1,22 @@ +/** + * @import { DomstackManifestEntry } from '@domstack/static' + */ + +import { manifestExclude, shouldIncludeManifestEntry } from './cache-policy.js' + +const origin = 'https://example.com' + +export default { + exclude: manifestExclude, + includeEntry, +} + +/** + * Keep Workbox precache policy close to the application while still letting + * Domstack emit a normal domstack manifest for the service worker to consume. + * + * @param {DomstackManifestEntry} entry + */ +function includeEntry (entry) { + return shouldIncludeManifestEntry(entry, origin) +} diff --git a/package.json b/package.json index 746ddcc2..4a3b1f76 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "example:string-layouts": "npm --workspace @domstack/string-layouts-example run build", "example:default-layout": "npm --workspace @domstack/default-layout-example run build", "example:nested-dest": "npm --workspace @domstack/nested-dest-example run build", + "example:workbox-pwa": "npm --workspace @domstack/workbox-pwa-example run build", "example:uhtml-isomorphic": "npm --workspace @domstack/uhtml-isomorphic-example run build", "start": "npm run watch" },