Skip to content
Draft
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
4 changes: 3 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ updates:
- "react-dom"
- "@types/react"
- "@types/react-dom"

workbox:
patterns:
- "workbox-*"
schedule:
interval: "daily"

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:

Expand Down
74 changes: 74 additions & 0 deletions examples/workbox-pwa/README.md
Original file line number Diff line number Diff line change
@@ -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 <http://localhost:3001> 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.
32 changes: 32 additions & 0 deletions examples/workbox-pwa/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
65 changes: 65 additions & 0 deletions examples/workbox-pwa/scripts/inject-domstack-manifest.js
Original file line number Diff line number Diff line change
@@ -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')
}
103 changes: 103 additions & 0 deletions examples/workbox-pwa/scripts/serve.js
Original file line number Diff line number Diff line change
@@ -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'
}
}
79 changes: 79 additions & 0 deletions examples/workbox-pwa/src/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
---
title: App Shell
---

<section class="app-layout">
<div class="app-summary">
<div>
<h1>Workbox PWA shell</h1>
<p>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.</p>
<div class="pill-row" aria-label="Cache policy">
<span class="pill ok">Domstack manifest</span>
<span class="pill ok">Workbox precache</span>
<span class="pill ok">Offline shell</span>
<span class="pill warn">No API cache</span>
</div>
</div>
<div class="status-list" aria-live="polite">
<div class="status-row">
<span>Worker</span>
<strong data-pwa-status>Not registered</strong>
</div>
<div class="status-row">
<span>Cache version</span>
<strong data-pwa-version>Waiting for domstack manifest</strong>
</div>
<div class="status-row">
<span>Network</span>
<strong data-online-state>Checking</strong>
</div>
</div>
</div>

<ul class="route-grid" aria-label="Example routes">
<li class="route-card">
<h2>Docs</h2>
<p>Docs are part of the first offline bundle.</p>
<a class="button" href="/docs/">Open docs</a>
<div class="pill-row"><span class="pill ok">Precached</span></div>
</li>
<li class="route-card">
<h2>Legal</h2>
<p>Legal-style static pages use the same offline policy as docs.</p>
<a class="button" href="/legal/">Open legal</a>
<div class="pill-row"><span class="pill ok">Precached</span></div>
</li>
<li class="route-card">
<h2>Login</h2>
<p>Auth shells can load offline while submissions remain network-only.</p>
<a class="button" href="/login/">Open login</a>
<div class="pill-row"><span class="pill ok">Precached</span></div>
</li>
<li class="route-card">
<h2>Offline fallback</h2>
<p>Network-only navigations fall back to a small static page when offline.</p>
<a class="button" href="/offline/">Open fallback</a>
<div class="pill-row"><span class="pill ok">Precached</span></div>
</li>
<li class="route-card">
<h2>Blog</h2>
<p>Blog pages are intentionally left out to reduce first install cost.</p>
<a class="button secondary" href="/blog/">Open blog</a>
<div class="pill-row"><span class="pill warn">Excluded</span></div>
</li>
<li class="route-card">
<h2>Admin</h2>
<p>Protected routes should stay network-only.</p>
<a class="button secondary" href="/admin/">Open admin</a>
<div class="pill-row"><span class="pill warn">Excluded</span></div>
</li>
<li class="route-card">
<h2>Opted-out</h2>
<p>Page vars can opt a static route out of precaching.</p>
<a class="button secondary" href="/private/">Open page</a>
<div class="pill-row"><span class="pill warn">Excluded</span></div>
</li>
</ul>

<aside class="pwa-update" data-pwa-update hidden aria-live="polite"></aside>
</section>
11 changes: 11 additions & 0 deletions examples/workbox-pwa/src/admin/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading