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
132 changes: 132 additions & 0 deletions examples/static-mpa-offline/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Static MPA Offline Service Worker Design Notes

## Status

These notes describe the current vanilla offline service-worker example in `examples/static-mpa-offline`.

The example is intentionally small and does not use Workbox at runtime.
It still borrows Workbox's useful mental model: build a manifest from emitted files, precache revisioned assets during `install`, clean old cache entries during `activate`, and use a deliberate update UX.

## Current architecture

Domstack builds a stable root service worker at `/service-worker.js`.
The service worker is omitted from the Domstack manifest so its own output does not create a manifest/hash cycle.
After the final manifest is built, `hooks.manifestBuilt` injects the manifest-derived policy into the final service-worker bundle with `defineServiceWorkerConstant()`.

The service worker does not fetch `/domstack-manifest.json` at runtime.
The example also does not emit a separate service-worker policy JSON file.

The injected policy keeps the Domstack manifest entry shape:

```ts
type StaticMpaOfflineServiceWorkerPolicy = {
version: string
entries: DomstackManifestEntry<StaticMpaOfflineManifestVars>[]
offlineFallbackUrl: string
}
```

That lets the worker derive cache behavior from the same fields the manifest already owns:

- `entry.url`
- `entry.revision`
- `entry.urlRevisioned`
- `entry.integrity`
- `entry.bytes`
- `entry.kind`
- `entry.role`
- `entry.static`
- `entry.manifestVars`

## Manifest vars and policy

The app exposes two page/layout vars to the manifest:

```ts
manifestVars: ['offline', 'precache']
```

The variable cascade is page → layout → global → default.
Layout modules use this to define route-section defaults.
Page vars or frontmatter can override those defaults for a single page.

The root app policy carries the single offline fallback route:

```ts
policy: {
offlineFallbackUrl: '/offline/',
}
```

The worker uses that route for failed offline navigations.
It does not need per-route fallback target metadata.

## Cache model

The worker uses a stable precache cache name and revisioned cache keys.
Unhashed URLs get a `?__DOMSTACK_REVISION__=<revision>` cache key.
Hashed URLs can use their URL as the cache key because the URL already changes when contents change.

The worker uses a separate runtime cache for progressive-cache pages.
Pages with `offline: true` and `precache: false` are not install-cached.
They become available offline after their first successful online visit.

Chunks are precached in production builds so dynamic imports used by cached pages stay available offline.
Watch builds disable splitting so the watch-mode service worker remains self-contained.

## Watch mode

Watch mode is for editing, not offline testing.
A watch build does not inject the production policy constant.
When the watch worker sees that no policy was injected, it skips caching, unregisters itself, and clears this example's caches.
The browser client also unregisters existing workers and clears owned caches when running in watch mode.

Use `npm --workspace @domstack/static-mpa-offline-example run serve` for offline-cache testing.

## Update lifecycle

The service worker does not unconditionally call `skipWaiting()` in the production path.
The browser client detects waiting updates and shows an update prompt.
If the user accepts, the client sends `{ type: 'SKIP_WAITING' }` to the waiting worker.
The page reloads once after `controllerchange` so the page and service worker move to the same version together.

This avoids mixed-version pages where old HTML or JavaScript is controlled by a new cache manifest.

## Fetch behavior

The worker handles same-origin `GET` requests only.
Navigations first try the precache, then the runtime cache, then the network, then the offline fallback.
Static subresources first try the precache.
Progressive-cache subresources can be stored in the runtime cache after the requesting page is visited online.

Navigation preload may be enabled during activation, but cache wins are allowed to settle the preload promise with `event.waitUntil()` so browsers do not report abandoned preload work.

## Recovery paths

The example includes two recovery tiers.

For recoverable mistakes where page JavaScript still runs, `?reset-sw` unregisters service workers, deletes this example's caches, removes the query parameter, and reloads.
For severe mistakes, deploy `rescue-service-worker.js` at `/service-worker.js` to replace the broken worker with a no-op worker at the same registration URL.

Do not change the service-worker URL during recovery.
A no-op worker at a different URL leaves the broken registration active at the old URL.

## Deployment headers

Recommended production headers:

```txt
/service-worker.js
Cache-Control: no-cache

/**/*.html
Cache-Control: no-cache

/assets/content-hashed-files...
Cache-Control: public, max-age=31536000, immutable
```

`no-cache` allows browser/storage revalidation.
It does not mean "never store".
Do not serve `/service-worker.js` with long-lived immutable caching.
Content-hashed CSS, JavaScript, and chunks can be immutable because their URL changes when contents change.
175 changes: 175 additions & 0 deletions examples/static-mpa-offline/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Static MPA Offline Example

This example shows a small static multi-page app that can load selected static assets offline with a production-oriented service-worker lifecycle.

- Domstack emits a stable root `/service-worker.js`.
- `src/globals/domstack-manifest/domstack-manifest.settings.ts` uses `hooks.manifestBuilt` to inject finalized manifest data into `/service-worker.js`.
- The service worker consumes the injected Domstack manifest entries directly.
- Selected pages, scripts, styles, chunks, and small static assets are precached.
- Layout vars define section-wide offline policy, and page vars or frontmatter can override that policy through domstack's normal variable cascade.
- The client prompts before activating an update discovered during an active session.
- Watch mode unregisters service workers and clears this example's caches to avoid half-cached development state.
- `?reset-sw` and `rescue-service-worker.js` are included as recovery paths.

## Running

```sh
npm --workspace @domstack/static-mpa-offline-example run serve
```

Then open the served localhost URL, wait for the offline cache to be ready, and use DevTools to test offline reloads.

Use watch mode only for editing:

```sh
npm --workspace @domstack/static-mpa-offline-example run watch
```

Watch mode does not inject production manifest policy into the service worker, so the watch worker unregisters itself and clears owned caches.
Use `serve` for offline-cache testing.

## Pages in the sample app

- `/` — home page and test instructions.
- `/about/` — normal precached offline page.
- `/offline/` — offline fallback page.
- `/admin/` — admin page excluded from precache; offline reload should show `/offline/`.
- `/progressive-cache/assets/` — page excluded from install-time precache but cached after the first online visit.
- `/progressive-cache/assets/details/` — second progressive-cache page with its own image subresource.
- `/progressive-cache/override/` — progressive-cache layout section page that opts back into precache.
- `/cache-inspector/` — diagnostic page that asks the service worker for cache contents.

## Layout and page policy

This example uses two user-facing manifest vars:

```ts
manifestVars: ['offline', 'precache']
```

The root layout defaults to `offline: true` and `precache: true`.
The progressive-cache layout defaults to `offline: true` and `precache: false`.
The admin layout defaults to `offline: false` and `precache: false`.

The cascade is page → layout → global → default.
That means a layout can set a policy for a route section, and an individual page can still override that policy with `page.vars.ts` or frontmatter.

The manifest settings also define one app-level fallback route:

```ts
policy: {
offlineFallbackUrl: '/offline/',
}
```

The service worker uses that fallback for failed offline navigations instead of carrying route-specific fallback rules.

## Progressive caching after first visit

The `/progressive-cache/assets/` pages use the `progressive-cache` layout vars.
Those pages are available offline only after a successful online visit.

To test it:

1. Load `/progressive-cache/assets/` and `/progressive-cache/assets/details/` while online.
2. Switch DevTools to offline.
3. Reload those pages.
4. Their HTML and SVG image subresources should be served from the runtime cache.

The `/progressive-cache/override/` page opts back into install-time precache from inside the progressive-cache section.

## Client and service-worker structure

The browser client and service worker are split by concern:

```txt
src/
globals/
global.css
global.vars.ts
global-client/
global.client.ts # bootstrap, config, and dependency wiring
connection-status.ts # online/offline detection
service-worker-events.ts # push/sync/periodic sync messages
service-worker-registration.ts # registration, updates, reload flow
service-worker-reset.ts # reset query param and watch cleanup
status-banner.ts # in-page status/indicator rendering
domstack-manifest/
domstack-manifest.settings.ts # manifest vars, policy, include filter, and hook registration
policy-build.ts # injects finalized manifest data into /service-worker.js
service-worker/
service-worker.ts # event wiring entrypoint
service-worker-settings.ts # shared app settings and app-defined types
background-events.ts # push/sync/periodic sync demo handlers
cache-inspection.ts # cache-inspector message handling
clients.ts # window client messaging helpers
fetch-handlers.ts # navigation and subresource fetch handling
lifecycle.ts # install/activate/reset/watch cleanup
precache.ts # manifest-entry-derived precache keys and cleanup
runtime-cache.ts # first-visit runtime caching
```

`global-client/global.client.ts` owns the example-specific browser wiring and creates the status banner.
The service-worker modules receive the config and policy they need as arguments.
This keeps lifecycle, connectivity state, recovery, fetch handling, and UI rendering separate enough to reuse or replace independently.

## Push, sync, and periodic sync hooks

`src/globals/service-worker/service-worker.ts` includes conservative handlers for:

- `push`
- one-off Background Sync: `sync`
- Periodic Background Sync: `periodicsync`

These handlers are extension points only.
They do not request notification permission, subscribe users to push, register sync jobs, or cache app data.
When triggered from DevTools, they post messages to open windows so the example can display/log that the service-worker event fired.
Push events show a notification only if the user has already granted notification permission.

Full push/sync support is app-specific and usually also requires server-side push subscription storage, permission UX, retry policy, and privacy/security review.

## Recovery paths

For recoverable mistakes where pages still load, visit:

```txt
/?reset-sw
```

The client unregisters service workers, deletes this example's offline caches, removes the query parameter, and reloads.

For a truly bad service worker that breaks page loads, deploy `rescue-service-worker.js` at the exact production service-worker URL:

```txt
/service-worker.js
```

This mirrors Workbox's recommended no-op recovery worker: same URL, immediate `skipWaiting()`, no `fetch` handler, and cache cleanup.

## Research references

If you are revisiting this example in a fresh context window, these are the docs and source files that informed the implementation.

### Core docs

- <https://web.dev/learn/pwa/workbox>
- <https://developer.chrome.com/docs/workbox/>
- <https://developer.chrome.com/docs/workbox/service-worker-lifecycle>
- <https://developer.chrome.com/docs/workbox/service-worker-deployment>
- <https://developer.chrome.com/docs/workbox/handling-service-worker-updates>
- <https://developer.chrome.com/docs/workbox/precaching-dos-and-donts>
- <https://developer.chrome.com/docs/workbox/remove-buggy-service-workers>
- <https://developer.chrome.com/docs/workbox/modules/workbox-precaching>
- <https://developer.chrome.com/docs/workbox/modules/workbox-strategies>
- <https://developer.chrome.com/docs/workbox/modules/workbox-window>
- <https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API>
- <https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers>

### Local context

- `README.md` at the repo root — domstack manifest and first-class service-worker docs.
- `examples/static-mpa-offline/DESIGN.md` — design notes and rationale for this example.
- `examples/static-mpa-workbox-offline/README.md` — the same app structure implemented with Workbox caching APIs.
- `plans/domstack-manifest.md` — manifest hook and service-worker integration plan.
- `plans/standard-static-mpa-service-worker.md` — standard static MPA service-worker plan.
- `plans/workbox-workflow-integration.md` — implemented Workbox policy injection workflow.
27 changes: 27 additions & 0 deletions examples/static-mpa-offline/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@domstack/static-mpa-offline-example",
"version": "0.0.0",
"description": "Static MPA offline service worker example for domstack",
"type": "module",
"imports": {
"#service-worker-settings": "./src/globals/service-worker/service-worker-settings.ts"
},
"scripts": {
"start": "npm run serve",
"build": "npm run clean && domstack",
"clean": "rm -rf public && mkdir -p public",
"serve": "npm run clean && domstack --serve",
"watch": "npm run clean && domstack --watch"
},
"keywords": [
"domstack",
"service-worker",
"offline",
"pwa"
],
"author": "Bret Comnes <bcomnes@gmail.com> (https://bret.io/)",
"license": "MIT",
"dependencies": {
"@domstack/static": "file:../../."
}
}
31 changes: 31 additions & 0 deletions examples/static-mpa-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-static-mpa-precache', 'domstack-static-mpa-runtime']

self.addEventListener('install', () => {
self.skipWaiting()
})

self.addEventListener('activate', event => {
event.waitUntil((async () => {
const cacheNames = await caches.keys()
await Promise.all(
cacheNames
.filter(name => CACHE_PREFIXES.some(prefix => name.startsWith(prefix)))
.map(name => caches.delete(name))
)

const windowClients = await self.clients.matchAll({ type: 'window' })
await Promise.all(
windowClients.map(windowClient => windowClient.navigate(windowClient.url))
)
})())
})
3 changes: 3 additions & 0 deletions examples/static-mpa-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-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-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-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')
Loading