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
8 changes: 0 additions & 8 deletions .npmignore

This file was deleted.

297 changes: 292 additions & 5 deletions README.md

Large diffs are not rendered by default.

47 changes: 45 additions & 2 deletions bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@
* @import { BuildStepWarnings, DomStackOpts as DomStackOpts } from './lib/builder.js'
* @import { ArgscloptsParseArgsOptionsConfig } from 'argsclopts'
* @import { Logger as PinoLogger } from 'pino'
* @import { BsInstance } from '@domstack/sync'
*/

import { readFile } from 'node:fs/promises'
import { resolve, join, relative } from 'node:path'
import { basename, resolve, join, relative } from 'node:path'
import { parseArgs } from 'node:util'
import { printHelpText } from 'argsclopts'
import readline from 'node:readline'
import process from 'process'
// @ts-expect-error
import tree from 'pretty-tree'
import { inspect } from 'util'
import { createServer } from '@domstack/sync'
import { packageDirectory } from 'package-directory'
import { readPackage } from 'read-pkg'
import { addPackageDependencies } from 'write-package'
Expand Down Expand Up @@ -61,12 +63,20 @@ const options = {
target: {
type: 'string',
short: 't',
help: 'comma separated target strings for esbuild',
help: 'comma separated esbuild targets, e.g. es2022,chrome120; see https://esbuild.github.io/api/#target',
},
noEsbuildMeta: {
type: 'boolean',
help: 'skip writing the esbuild metafile to disk',
},
customDomstackManifestName: {
type: 'string',
help: 'custom domstack manifest filename (default: domstack-manifest.json)',
},
noDomstackManifest: {
type: 'boolean',
help: 'disable writing the domstack manifest to disk',
},
eject: {
type: 'boolean',
short: 'e',
Expand All @@ -81,6 +91,10 @@ const options = {
type: 'boolean',
help: 'watch and build the src folder without serving',
},
serve: {
type: 'boolean',
help: 'build once and serve the destination directory without watching',
},
copy: {
type: 'string',
help: 'path to directories to copy into dist; can be used multiple times',
Expand Down Expand Up @@ -207,6 +221,16 @@ domstack eject actions:
if (argv['ignore']) opts.ignore = String(argv['ignore']).split(',')
if (argv['target']) opts.target = String(argv['target']).split(',')
if (argv['noEsbuildMeta']) opts.metafile = false
if (argv['noDomstackManifest'] && argv['customDomstackManifestName']) {
throw new Error('--customDomstackManifestName cannot be combined with --noDomstackManifest')
}
if (argv['noDomstackManifest']) opts.domstackManifest = false
if (argv['customDomstackManifestName']) {
opts.domstackManifest = {
...(typeof opts.domstackManifest === 'object' ? opts.domstackManifest : {}),
filename: String(argv['customDomstackManifestName']),
}
}
if (argv['drafts']) opts.buildDrafts = true
if (argv['copy']) {
const copyPaths = Array.isArray(argv['copy']) ? argv['copy'] : [argv['copy']]
Expand All @@ -217,6 +241,12 @@ domstack eject actions:
const logger = createDomStackLogger()
opts.logger = logger
const domStack = new DomStack(src, dest, opts)
/** @type {BsInstance | null} */
let buildServer = null

if (argv['serve'] && (argv['watch'] || argv['watch-only'])) {
throw new Error('--serve cannot be combined with --watch or --watch-only')
}

process.once('SIGINT', quit)
process.once('SIGTERM', quit)
Expand All @@ -226,6 +256,11 @@ domstack eject actions:
await domStack.stopWatching()
logger.info('Watching stopped')
}
if (buildServer) {
await buildServer.exit()
buildServer = null
logger.info('Server stopped')
}
logger.info('Quitting cleanly')
process.exit(0)
}
Expand All @@ -236,6 +271,14 @@ domstack eject actions:
logger.info(tree(generateTreeData(cwd, src, dest, results)))
logWarnings(logger, results?.warnings)
logger.info('\nBuild Success!\n\n')
if (argv['serve']) {
buildServer = await createServer({
server: dest,
files: basename(dest),
logger: logger.child({ component: 'sync', logPrefix: '[domstack-sync]' }),
})
logger.info(`Serving ${relative(cwd, dest)} without watching. Press Ctrl-C to stop.`)
}
} catch (err) {
if (!(err instanceof Error || err instanceof AggregateError)) throw new Error('Non-error thrown', { cause: err })
if (err instanceof DomStackAggregateError) {
Expand Down
6 changes: 4 additions & 2 deletions examples/markdown-settings/src/markdown-it.settings.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/**
* @import MarkdownIt from 'markdown-it'
*
* Custom Markdown-it Configuration
*
* This file demonstrates how to extend DOMStack's markdown rendering
Expand Down Expand Up @@ -44,8 +46,8 @@ function createContainer (name, defaultTitle, cssClass) {
/**
* Customize the markdown-it instance with additional plugins and renderers
*
* @param {import('markdown-it')} md - The markdown-it instance
* @returns {import('markdown-it')} - The modified markdown-it instance
* @param {MarkdownIt} md - The markdown-it instance
* @returns {Promise<MarkdownIt>} - The modified markdown-it instance
*/
export default async function markdownItSettingsOverride (md) {
// =====================================================
Expand Down
60 changes: 60 additions & 0 deletions examples/pwa/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# DOMStack PWA Example

This example shows a production-style static PWA using Domstack's domstack manifest and first-class service-worker build support.

> [!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`.
- A `domstack-manifest.settings.js` file that filters the generated domstack manifest.
- A global client runtime that registers the worker, handles update prompts, and disables sticky caches during local watch development.
- 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`.
- Verbose console logging in the window runtime and service worker so the lifecycle is easy to inspect while learning or testing.

## Running

```bash
cd examples/pwa
npm install
npm run serve
```

Service workers require a secure origin. `localhost` is allowed by browsers, and `npm run serve`
runs a manifest-enabled build so the PWA path works there. Use `npm run watch` for development
without a sticky service worker cache. To clear all example workers and caches:

```txt
/?reset-sw=1
```

## Files

```txt
src/
global.client.js # Registers the service worker through pwa/runtime.js
global.css # Site styles
global.vars.js # Shared page variables
domstack-manifest.settings.js # Filters the written domstack manifest
manifest.webmanifest # Web app manifest copied as a static asset
service-worker.js # Site service worker entry
pwa/
cache-policy.js # Shared domstack manifest filtering and constants
runtime.js # Browser registration/update/recovery behavior
sw/
*.js # Service-worker install, update, cache, and fetch helpers
```

## Production Pattern

The worker fetches `/domstack-manifest.json` during installation and uses the revisioned URLs in that file as its cache plan. The manifest is generated after Domstack reconciles pages, bundles, chunks, worker output, copied static assets, and templates. Application policy stays in app code:

- `domstack-manifest.settings.js` decides what can ever enter the manifest.
- `service-worker.js` decides how to install, activate, update, and serve cached responses.
- `global.client.js` decides when to register, prompt, apply updates, or recover from a bad cache.

Watch mode does not write the domstack manifest, so use `npm run serve` when testing the offline lifecycle.
22 changes: 22 additions & 0 deletions examples/pwa/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@domstack/pwa-example",
"version": "0.0.0",
"description": "DOMStack PWA offline cache example",
"type": "module",
"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 && dom --watch"
},
"keywords": ["domstack", "pwa", "service-worker", "offline"],
"author": "",
"license": "MIT",
"dependencies": {
"@domstack/static": "file:../../."
},
"devDependencies": {
"npm-run-all2": "^9.0.0"
}
}
79 changes: 79 additions & 0 deletions examples/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>Static PWA shell</h1>
<p>This page is built as ordinary static HTML, then the service worker uses Domstack's domstack manifest to cache the static shell for offline launches.</p>
<div class="pill-row" aria-label="Cache policy">
<span class="pill ok">Pages</span>
<span class="pill ok">Bundles</span>
<span class="pill ok">Static assets</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>Excluded navigations fall back to a small static page.</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/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.
13 changes: 13 additions & 0 deletions examples/pwa/src/blog/README.md
Original file line number Diff line number Diff line change
@@ -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 `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/)
9 changes: 9 additions & 0 deletions examples/pwa/src/blog/first-post/README.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions examples/pwa/src/docs/README.md
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions examples/pwa/src/domstack-manifest.settings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* @import { DomstackManifestEntry } from '@domstack/static'
*/

import { pwaManifestExclude, shouldIncludePwaOutput } from './pwa/cache-policy.js'

const origin = 'https://example.com'

export default {
exclude: pwaManifestExclude,
includeEntry,
}

/**
* Keep PWA cache 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 shouldIncludePwaOutput(entry, origin)
}
5 changes: 5 additions & 0 deletions examples/pwa/src/global.client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { initializePwa } from './pwa/runtime.js'

initializePwa().catch(err => {
console.warn('Unable to initialize the Domstack PWA example runtime', err)
})
Loading