Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions examples/static-mpa-workbox-offline/DESIGN.md
Original file line number Diff line number Diff line change
@@ -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.
108 changes: 108 additions & 0 deletions examples/static-mpa-workbox-offline/README.md
Original file line number Diff line number Diff line change
@@ -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

- <https://web.dev/learn/pwa/workbox>
- <https://developer.chrome.com/docs/workbox/>
- <https://developer.chrome.com/docs/workbox/modules/workbox-precaching>
- <https://developer.chrome.com/docs/workbox/modules/workbox-routing>
- <https://developer.chrome.com/docs/workbox/modules/workbox-strategies>
- <https://developer.chrome.com/docs/workbox/modules/workbox-build>
- <https://developer.chrome.com/docs/workbox/handling-service-worker-updates>
- <https://developer.chrome.com/docs/workbox/remove-buggy-service-workers>
- <https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API>
- `plans/workbox-workflow-integration.md`
- `examples/static-mpa-offline/README.md`
35 changes: 35 additions & 0 deletions examples/static-mpa-workbox-offline/package.json
Original file line number Diff line number Diff line change
@@ -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 <bcomnes@gmail.com> (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"
}
}
31 changes: 31 additions & 0 deletions examples/static-mpa-workbox-offline/rescue-service-worker.js
Original file line number Diff line number Diff line change
@@ -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))
)
})())
})
3 changes: 3 additions & 0 deletions examples/static-mpa-workbox-offline/src/about/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { markPageClientLoaded } from '../mark-page-client-loaded.ts'

markPageClientLoaded('about')
6 changes: 6 additions & 0 deletions examples/static-mpa-workbox-offline/src/about/page.md
Original file line number Diff line number Diff line change
@@ -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.

5 changes: 5 additions & 0 deletions examples/static-mpa-workbox-offline/src/about/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import "../page-style.css";

main {
--page-accent: seagreen;
}
3 changes: 3 additions & 0 deletions examples/static-mpa-workbox-offline/src/admin/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { markPageClientLoaded } from '../mark-page-client-loaded.ts'

markPageClientLoaded('admin')
23 changes: 23 additions & 0 deletions examples/static-mpa-workbox-offline/src/admin/page.md
Original file line number Diff line number Diff line change
@@ -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.

5 changes: 5 additions & 0 deletions examples/static-mpa-workbox-offline/src/admin/page.vars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { StaticMpaWorkboxPageVars } from '#service-worker-settings'

export default {
layout: 'admin',
} satisfies StaticMpaWorkboxPageVars
5 changes: 5 additions & 0 deletions examples/static-mpa-workbox-offline/src/admin/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import "../page-style.css";

main {
--page-accent: crimson;
}
48 changes: 48 additions & 0 deletions examples/static-mpa-workbox-offline/src/cache-inspector/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/// <reference lib="dom" />

import { markPageClientLoaded } from '../mark-page-client-loaded.ts'

markPageClientLoaded('cache inspector')

const workboxCacheInspectorButton = document.querySelector<HTMLButtonElement>('#inspect-caches')
const workboxCacheInspectorOutput = document.querySelector<HTMLPreElement>('#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<unknown> {
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()
})
}
Loading