Skip to content

Add build output manifest for static caching#262

Open
bcomnes wants to merge 1 commit into
masterfrom
staic-client-cache
Open

Add build output manifest for static caching#262
bcomnes wants to merge 1 commit into
masterfrom
staic-client-cache

Conversation

@bcomnes

@bcomnes bcomnes commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Summary

This adds an unstable-preview Domstack manifest pipeline for static caching and first-class site service-worker builds.

The manifest pipeline produces a normalized, revisioned view of build outputs for manifest hooks and optional public JSON output.

The main service-worker integration point is hooks.manifestBuilt, where applications can derive cache policy from the finalized manifest and inject it into the final /service-worker.js bundle with context.defineServiceWorkerConstant().

Public domstack-manifest.json writing is opt-in.

Apps that only need the manifest for service-worker policy can consume it through the hook without shipping a runtime manifest file.

Warning

The domstack manifest, domstack-manifest.settings.*, first-class service-worker.* entries, manifest hooks, generated browser process.env.DOMSTACK_* defines, and service-worker define injection are preview APIs.
Their names, option shapes, manifest schema, generated output, and runtime semantics may change outside of a major version while the API is validated with real PWA use cases.
Consumers should pin @domstack/static to an exact version before relying on this contract.

Goals

The goal is to let a domstack app build a fully static client and derive cache policy from a finalized, revisioned list of emitted files.

Domstack provides build facts and service-worker build plumbing.

The application still owns service-worker registration, update UX, route filtering, offline behavior, runtime caching decisions, and cache policy.

What Changed

  • Add an unstable Domstack manifest pipeline for revisioned build output metadata.
  • Return results.domstackManifest from one-shot programmatic builds when a manifest consumer exists.
  • Make public domstack-manifest.json writing opt-in with --domstackManifest, domstackManifest: true, or { write: true }.
  • Track outputs from esbuild, pages, templates, static copies, and configured copy dirs through shared report.outputs records.
  • Reconcile those records once at the end of the build into public manifest entries with URLs, revisions, byte sizes, kinds, content types, integrity values, and optional source/page metadata.
  • Add a JSON Schema for the manifest with a versioned unpkg $schema URL.
  • Derive and export public manifest types from the schema.
  • Add domstack-manifest.settings.{js,mjs,cjs,ts,mts,cts} for manifest filtering, manifest vars, root policy, and manifest lifecycle hooks.
  • Add hooks.manifestBuilt with context.manifest, context.writeFile(), and context.defineServiceWorkerConstant().
  • Add first-class site service-worker entries where one service-worker.{js,mjs,cjs,ts,mts,cts} anywhere under src builds to stable root /service-worker.js.
  • Build the final service-worker bundle after manifest hooks run so hooks can inject finalized policy and manifest version data.
  • Add domstack-owned browser defines for service workers and client bundles.
  • Omit the built-in manifest file and site service worker from manifest entries so the finalized manifest version can be injected into /service-worker.js without a circular hash dependency.
  • Keep watch mode simple by disabling manifest output and manifest-return behavior while still rebundling the site service worker.
  • Add domstack --serve for one-shot build plus static serving, which is useful for service-worker and PWA lifecycle testing.
  • Replace .npmignore with a package.json#files allowlist.
  • Add static PWA examples showing app-owned registration, update UX, manifest filtering, service-worker policy injection, cache inspection, and cache lifecycle handling.

New Preview APIs

Build Results

Programmatic one-shot builds return results.domstackManifest when a manifest consumer exists.

A manifest consumer is either explicit domstackManifest configuration or a domstack-manifest.settings.* file.

Watch mode does not return a domstack manifest.

const results = await site.build()
console.log(results.domstackManifest)

CLI Options

domstack --domstackManifest
domstack --serve

--domstackManifest writes the standard domstack-manifest.json file.

--serve runs a one-shot build and serves the destination directory without watch mode or live reload snippets.

Use --serve for testing service-worker cache lifecycle behavior against stable build bytes.

There is intentionally no custom manifest filename CLI shortcut.

Custom public manifest-like files should be written from a manifestBuilt hook with context.writeFile().

Programmatic Options

const site = new DomStack('src', 'public', {
  domstackManifest: {
    write: true,
    exclude: ['blog/**', '**/*.map'],
    manifestVars: ['offline', 'precache'],
    policy: {
      offlineFallbackUrl: '/offline/',
    },
    hooks: {
      manifestBuilt: [context => {
        context.defineServiceWorkerConstant('__APP_CACHE_POLICY__', {
          version: context.manifest.version,
          urls: context.manifest.entries.map(entry => entry.url),
        })
      }],
    },
  },
})

Supported shape:

type DomstackManifestOption = boolean | {
  write?: boolean
  exclude?: string[]
  manifestVars?: string[] | DomstackManifestTransform
  policy?: Record<string, unknown> | DomstackManifestPolicyTransform
  hooks?: DomstackManifestHooks
}

Use domstackManifest: true when you only want to write the standard public domstack-manifest.json file.

Use the object form when you need exclude, manifestVars, policy, or hooks.

Add write: true to the object form only when you also want Domstack to write domstack-manifest.json.

Leaving domstackManifest unset skips the manifest pipeline unless a domstack-manifest.settings.* file exists.

A settings file enables the pipeline, returns results.domstackManifest, and runs manifest hooks, but it does not write a public JSON file unless writing is explicitly enabled.

domstack-manifest.settings.*

Apps can configure the generated manifest from a dedicated settings file anywhere under src:

domstack-manifest.settings.js
domstack-manifest.settings.mjs
domstack-manifest.settings.cjs
domstack-manifest.settings.ts
domstack-manifest.settings.mts
domstack-manifest.settings.cts

The default export can be an object, a sync function, or an async function returning an object.

export default {
  manifestVars: ['offline', 'precache'],
  policy: {
    offlineFallbackUrl: '/offline/',
  },
  exclude: ['admin/**', 'blog/**', '**/*.map'],
  includeEntry (entry) {
    if (entry.kind === 'metadata') return false
    if (entry.kind === 'sourcemap') return false
    if (entry.manifestVars?.precache === false) return false
    if (entry.manifestVars?.offline === false) return false
    return true
  },
  hooks: {
    manifestBuilt: [context => {
      const precacheEntries = context.manifest.entries.map(entry => ({
        url: entry.url,
        revision: entry.urlRevisioned ? null : entry.revision,
      }))

      context.defineServiceWorkerConstant('__APP_PRECACHE__', precacheEntries)
    }],
  },
}

domstackManifest.exclude from programmatic options and domstack-manifest.settings.* exclude values are combined.

Exclude patterns are checked against both entry.url and entry.outputRelname.

Excludes run before includeEntry(entry).

The includeEntry(entry) hook receives the public manifest entry shape, not local filesystem paths.

Manifest Built Hooks

hooks.manifestBuilt runs after the manifest is finalized and before the final service-worker bundle is emitted.

type DomstackManifestBuiltHookContext<Policy, ManifestVars> = {
  dest: string
  manifest: DomstackManifest<Policy, ManifestVars>
  defineServiceWorkerConstant: (identifier: string, value: unknown) => void
  writeFile: (outputRelname: string, contents: string | Uint8Array) => Promise<void>
}

defineServiceWorkerConstant() serializes value with JSON.stringify() and passes it to esbuild's define option for the final service-worker build.

Use it for precomputed service-worker policy, such as a vanilla precache list or a Workbox-shaped precacheManifest, when you do not want a runtime fetch or generated global script.

writeFile() writes generated artifacts under dest.

Use it for custom public integration data when a runtime file is intentionally desired.

Service Worker Support

Domstack reserves one site service-worker source filename:

service-worker.js
service-worker.mjs
service-worker.cjs
service-worker.ts
service-worker.mts
service-worker.cts

The source may live anywhere under src, but only one is allowed.

Multiple matches fail with DOM_STACK_ERROR_DUPLICATE_SERVICE_WORKER.

The service worker is bundled through esbuild and emitted with a stable root output path:

/service-worker.js

The final service-worker build receives manifest hook defines and process.env.DOMSTACK_MANIFEST_VERSION.

Domstack does not inject registration into the default layout.

Registration timing, update prompts, local-development opt-outs, poisoned-cache recovery, and offline route behavior remain application policy.

Browser Defines

Define Value
process.env.DOMSTACK_MANIFEST_URL Standard public URL for the built-in domstack manifest, /domstack-manifest.json
process.env.DOMSTACK_MANIFEST_VERSION Finalized manifest version in /service-worker.js; "" in other bundles
process.env.DOMSTACK_MANIFEST_ENABLED "true" for one-shot builds with an enabled manifest pipeline, "false" when disabled or in watch mode
process.env.DOMSTACK_SERVICE_WORKER_URL Public URL of the site service worker, usually /service-worker.js, or "" when no service worker is present
process.env.DOMSTACK_SERVICE_WORKER_SCOPE Registration scope for the site service worker, usually /, or "" when no service worker is present

Compatibility Notes

  • This is an unstable preview feature and can change outside a major version.
  • The CLI writes domstack-manifest.json only when --domstackManifest is set.
  • Programmatic builds write domstack-manifest.json only with domstackManifest: true or { write: true }.
  • The manifest pipeline can still run without writing a public JSON file when a settings file or programmatic manifest config exists.
  • Watch mode does not write or return the domstack manifest.
  • Watch mode still builds and rebundles the site service worker.
  • The built-in manifest file is never included in its own entries.
  • Site service workers are omitted from manifest entries so manifest.version can be embedded into /service-worker.js without a circular hash dependency.
  • copy is the output kind for files copied by the generic copy step.
  • Workbox support is not a built-in Domstack mode.
  • Apps that want Workbox can derive Workbox's { url, revision } precache shape from context.manifest inside a manifest hook.

Testing

  • npm test
  • npm run test:tsc
  • npm run test:neostandard
  • node --test lib/identify-pages.test.js test-cases/general-features/index.test.js test-cases/type-exports/index.test.ts test-cases/watch/index.test.js test-cases/template-output-escape/index.test.js
  • git diff --check

@github-actions

Copy link
Copy Markdown

Coverage Report for CI Build 27524398777

Coverage increased (+0.3%) to 92.204%

Details

  • Coverage increased (+0.3%) from the base build.
  • Patch coverage: 47 uncovered changes across 2 files (939 of 986 lines covered, 95.23%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
lib/build-output-manifest/index.js 553 520 94.03%
index.js 63 49 77.78%
Total (12 files) 986 939 95.23%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 5066
Covered Lines: 4806
Line Coverage: 94.87%
Relevant Branches: 886
Covered Branches: 682
Branch Coverage: 76.98%
Branches in Coverage %: Yes
Coverage Strength: 106.32 hits per line

💛 - Coveralls

@socket-security

socket-security Bot commented Jun 15, 2026

Copy link
Copy Markdown

All alerts resolved. Learn more about Socket for GitHub.

This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored.

View full report

@bcomnes bcomnes marked this pull request as ready for review June 15, 2026 19:50
Copilot AI review requested due to automatic review settings June 15, 2026 19:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@bcomnes bcomnes force-pushed the staic-client-cache branch 2 times, most recently from 31311d7 to 620a4bc Compare June 15, 2026 19:52
@bcomnes bcomnes requested a review from Copilot June 15, 2026 19:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@bcomnes bcomnes force-pushed the staic-client-cache branch 3 times, most recently from fb90546 to e8c79b8 Compare June 16, 2026 18:02
@socket-security

socket-security Bot commented Jun 16, 2026

Copy link
Copy Markdown

@bcomnes bcomnes force-pushed the staic-client-cache branch from e8c79b8 to f665681 Compare June 17, 2026 03:31
@bcomnes bcomnes force-pushed the staic-client-cache branch 8 times, most recently from a3079c4 to 260af7a Compare June 29, 2026 03:50
Comment thread README.md Outdated
Comment thread README.md
Comment thread README.md
Comment thread README.md
Comment thread README.md
@bcomnes bcomnes force-pushed the staic-client-cache branch 2 times, most recently from 0fe8310 to 714e90d Compare June 29, 2026 03:58
Comment thread README.md Outdated
Comment thread lib/domstack-manifest/index.js Outdated
Comment thread lib/domstack-manifest/index.js Outdated
* @param {string[]} exclude
*/
function applyExclude (entries, exclude) {
if (exclude.length === 0) return entries

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is manifest dealing with this. Shouldnt it be totally downstream from ignores?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manifest excludes are separate from source/build ignores. ignore/copy filtering decides what gets discovered or copied upstream; domstackManifest.exclude and includeEntry filter the reconciled public output entries after all build steps have reported outputs, so this logic belongs at manifest reconciliation time.

@bcomnes bcomnes force-pushed the staic-client-cache branch 8 times, most recently from 2576a7f to 01f6d43 Compare July 7, 2026 22:59
@bcomnes bcomnes requested a review from Copilot July 8, 2026 01:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 200 out of 207 changed files in this pull request and generated 6 comments.

Comment thread lib/helpers/type-guards.js
const serviceWorkerStat = await stat(path.join(dest, 'service-worker.js'))
assert.ok(serviceWorkerStat.isFile(), 'watch mode builds site service-worker entries')
const serviceWorkerContent = await readFile(path.join(dest, 'service-worker.js'), 'utf8')
assert.ok(!serviceWorkerContent.includes('process.env.DOMSTACK_MANIFEST_ENABLED'), 'watch service worker receives the domstack manifest enabled define')
Comment on lines +1 to +5
{
"name": "@domstack/static-mpa-workbox-offline-example",
"version": "0.0.0",
"description": "Static MPA offline Workbox service worker example for domstack",
"type": "module",
Comment thread examples/static-mpa-offline/src/layouts/root.layout.ts
@@ -0,0 +1,33 @@
import type { HtmlResult } from 'fragtml/types.ts'
import type { LayoutFunction } from '@domstack/static/types.ts'
Comment on lines +598 to +601
defineServiceWorkerConstant: (identifier, value) => {
serviceWorkerDefines[identifier] = JSON.stringify(value)
},
writeFile: (outputRelname, contents) => writeGeneratedManifestFile(dest, outputRelname, contents),
@bcomnes bcomnes force-pushed the staic-client-cache branch from 01f6d43 to f35b11d Compare July 8, 2026 01:46
Adds the unstable Domstack manifest preview, service-worker build support, documentation, migration notes, tests, and workspace-aware example infrastructure without the PWA example implementations.

Address manifest review feedback

Keep service worker build single file

Revert "Keep service worker build single file"

This reverts commit 3c6beff.

Working static PWA cache example

PWA
@bcomnes bcomnes force-pushed the staic-client-cache branch from f35b11d to 43de599 Compare July 8, 2026 02:00
Comment thread docs/v12-migration.md
page/frontmatter vars > page.vars.* > layout vars > global.data/global.vars > domstack defaults
```

This is additive for most sites. If a layout module already exported a named `vars` value for another purpose, that value will now participate in page variable resolution. Rename that export if it was not intended as layout defaults.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also acknowledge the previous strategy of just mixing in variables at the layout level. Those can be moved to the cars cascade explicitly now. We should sk mention why it was added (manifest policy defsults)

Comment thread docs/v12-migration.md
})
```

If `domstackManifest` is unset, Domstack skips the manifest pipeline unless a `domstack-manifest.settings.*` file exists.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The settings file is the primary interface the programatic one should come second or only mentioned in the docs.

Comment thread docs/v12-migration.md
Apps can add one `domstack-manifest.settings.*` file anywhere under `src`:

```txt
domstack-manifest.settings.js

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to find a convention not to have to write every file type out.

Comment thread docs/v12-migration.md
```

Use `manifestVars` to explicitly expose selected page, layout, global, or default vars on manifest entries.
Use `policy` for root-level application policy.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explain what this is and why you would use it.

Comment thread docs/v12-migration.md
### Prefer manifest hooks over runtime manifest fetches

The primary service-worker integration point is `hooks.manifestBuilt`.
This hook receives the finalized manifest before the final `/service-worker.js` bundle is emitted.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Service worker support is new too. We should introduce that before the manifet since you need that before you set this up.

Comment thread docs/v12-migration.md
}
```

This avoids shipping a runtime manifest file just so a service worker can discover the build output list.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This copy is too reflective of our desingn path. Getting the manifest into the service worker is standard practice.

interface Window {
domstackUtils: DomstackUtils;
const globalWindow = window as Window & {
domstackUtils?: DomstackUtils

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this?

*
* This module owns browser registration, waiting-worker prompts, and the
* one-time reload after a user-approved update takes control. It receives a UI
* interface from the entrypoint instead of importing a banner implementation.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment needs to be cleaned up.

const registration = await navigator.serviceWorker.register(url, {
scope,
type: 'module',
updateViaCache: 'none',

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to look up what this does.

*
* See MDN Client.postMessage(): https://developer.mozilla.org/en-US/docs/Web/API/Client/postMessage
*/
export async function postToWindowClients (message: Record<string, unknown>): Promise<void> {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool where is this used?


if (request.method !== 'GET') return

const url = new URL(request.url)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this not already a url object?

preloadResponsePromise: Promise<Response | undefined>,
event: FetchEvent
): Promise<Response> {
event.waitUntil(preloadResponsePromise.catch(() => undefined))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to understand this.

withoutIgnoredParams.hash = ''
for (const param of Array.from(withoutIgnoredParams.searchParams.keys())) {
if (/^utm_/.test(param) || param === 'fbclid') {
withoutIgnoredParams.searchParams.delete(param)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this about I don't think we need this.

return policyEntry ? getRuntimeStrategy(config, policyEntry) === 'runtime' : false
}

function getRequestPolicyEntry (config: ActiveServiceWorkerConfig, request: Request): StaticMpaOfflineServiceWorkerPolicyEntry | undefined {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need JSDoc explaining all of these functions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants