Skip to content

Add build output manifest for static caching#262

Open
bcomnes wants to merge 6 commits into
masterfrom
staic-client-cache
Open

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

Conversation

@bcomnes

@bcomnes bcomnes commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Summary

This adds a first-class build output manifest for static app caching and pairs it with site service-worker support.

The goal is to let a domstack app build a fully static client, then have its own service worker consume a normalized, revisioned list of emitted files. Domstack provides the build facts; the application still owns service-worker registration, update UX, route filtering, offline behavior, and cache policy.

What Changed

  • Add domstack-output-manifest.json, written by one-shot builds by default.
  • Return results.outputManifest from programmatic builds.
  • Track outputs from esbuild, pages, templates, static copies, and configured copy dirs through a shared report.outputs record collection.
  • Reconcile those records once at the end of the build into public manifest entries with URLs, revisions, byte sizes, kinds, and optional source/page metadata.
  • Add a committed JSON Schema for the manifest with a versioned unpkg $schema URL.
  • Derive/export the public manifest types from the schema with json-schema-to-ts.
  • Add manifest.settings.{js,mjs,cjs,ts,mts,cts} for build-output-manifest filtering policy.
  • Add first-class site service-worker entries: one service-worker.{js,mjs,cjs,ts,mts,cts} anywhere under src builds to stable root /service-worker.js.
  • Add domstack-owned browser defines for service workers and client bundles.
  • Keep watch mode simple: service workers still rebundle in watch mode, but output manifests are not written or returned in watch mode.
  • Replace .npmignore with a package.json#files allowlist.

How The Manifest Works

Each build step records files as it emits them instead of trying to reconstruct the build from loosely shaped downstream reports. At the end of a one-shot build, domstack reconciles those records:

  1. Normalize each output path relative to dest.
  2. Assert recorded files are inside the output directory.
  3. Convert output names into public URLs.
  4. Hash file contents with SHA-256 for revision.
  5. Record bytes, kind, and optional source/page metadata.
  6. De-dupe by output path using kind priority.
  7. Apply configured exclude patterns and includeOutput(entry) hooks.
  8. Generate a stable manifest version from cache-relevant fields: url, revision, kind, and page-level precache / offline vars.
  9. Write the public JSON manifest with $schema, version, generatedAt, and entries.

The manifest intentionally does not expose local filesystem paths. It is a public serialized contract for deploy tools, service workers, and application code.

Manifest Shape

The written file looks like:

{
  "$schema": "https://unpkg.com/@domstack/static@x.y.z/lib/build-output-manifest/schema.json",
  "version": "sha256-of-cache-relevant-output-metadata",
  "generatedAt": "2026-06-16T00:00:00.000Z",
  "entries": [
    {
      "outputRelname": "index.html",
      "kind": "page",
      "url": "/",
      "revision": "sha256-file-content",
      "bytes": 1234
    }
  ]
}

Entry kind values are:

page, template, script, style, chunk, service-worker, worker,
worker-manifest, static, copy, sourcemap, metadata

New Public APIs

Build Results

Programmatic builds now return:

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

results.outputManifest is still returned when writing the JSON file is disabled with outputManifest: false. Watch mode does not return an output manifest.

CLI Options

domstack --outputManifest build-output.json
domstack --noOutputManifest

Programmatic Options

const site = new DomStack('src', 'public', {
  outputManifest: {
    filename: 'build-output.json',
    exclude: ['blog/**', '**/*.map'],
  },
})

Supported shape:

type OutputManifestOption = false | {
  write?: boolean
  filename?: string
  exclude?: string[]
}

manifest.settings.*

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

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

This follows the existing Domstack settings-file pattern used by esbuild.settings.* and markdown-it.settings.*. The default export can be an object, a sync function, or an async function returning an object.

Use this when the app wants its build output manifest to reflect application policy, for example keeping admin pages, blog content, source maps, feeds, or other deployment-only files out of a PWA precache list.

export default {
  exclude: [
    'admin/**',
    'blog/**',
    '**/*.map',
  ],

  includeOutput (entry) {
    if (entry.kind === 'metadata') return false
    if (entry.kind === 'sourcemap') return false
    if (entry.page?.vars?.precache === false) return false
    if (entry.page?.vars?.offline === false) return false
    return true
  },
}

Dynamic settings are supported too:

export default async function manifestSettings () {
  return {
    exclude: process.env.INCLUDE_BLOG_OFFLINE === '1'
      ? ['**/*.map']
      : ['blog/**', '**/*.map'],
  }
}

Supported shape:

type ManifestSettings = {
  exclude?: string[]
  includeOutput?: (entry: BuildOutputEntry) => boolean | Promise<boolean>
}

How it is applied:

  • outputManifest.exclude from CLI/programmatic config and manifest.settings.* exclude values are combined.
  • Exclude patterns use ignore-style matching against both entry.url and entry.outputRelname.
  • Excludes run before includeOutput(entry).
  • includeOutput(entry) receives the public manifest entry shape, not internal filesystem details.
  • Filtering affects both the written domstack-output-manifest.json and results.outputManifest.
  • Filtering also affects manifest.version, because versioning is based on the final cache-relevant entries.
  • Watch mode intentionally ignores this file because watch mode does not write or return an output manifest.

Useful entry fields for policy decisions:

entry.url            // public URL, such as "/docs/" or "/chunks/app-ABC.js"
entry.outputRelname  // dest-relative output name, such as "docs/index.html"
entry.kind           // page, script, style, chunk, copy, service-worker, etc.
entry.sourceRelname  // source-relative path when available
entry.page?.vars     // page-level precache/offline vars when present

Domstack records page-level precache and offline vars onto page entries when present, but it does not interpret those flags itself. They are metadata for service workers, deployment tools, or includeOutput(entry) policies.

For example, a page can opt out of an app's offline policy:

export const vars = {
  precache: false,
}

Then the app's includeOutput(entry) hook can decide whether that page entry should remain in the output manifest.

Schema And Types

Domstack now exports:

import {
  BUILD_OUTPUT_MANIFEST_SCHEMA_ID,
  BUILD_OUTPUT_MANIFEST_SCHEMA_PATH,
  buildOutputManifestSchema,
  buildOutputEntrySchema,
  buildOutputEntryPageMetaSchema,
  buildOutputKindSchema,
  getBuildOutputManifestSchemaId,
} from '@domstack/static'

The public BuildOutputManifest, BuildOutputEntry, BuildOutputEntryPageMeta, and BuildOutputKind types are derived from those schemas and exported through the package type surface.

Service Worker Support

Domstack now 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 like the rest of the browser-side code, but it is emitted with a stable root output path:

/service-worker.js

That stable root URL matters because browsers check service-worker updates at the script URL, and root placement gives the worker root scope without requiring a Service-Worker-Allowed header.

The built service worker is included in:

  • the merged esbuild metafile
  • the output manifest as kind: 'service-worker'

Browser Defines

Domstack injects these build facts into browser-side bundles:

Define Value
process.env.DOMSTACK_OUTPUT_MANIFEST_URL Public URL of the written manifest, usually /domstack-output-manifest.json
process.env.DOMSTACK_OUTPUT_MANIFEST_ENABLED "true" for one-shot builds that write the manifest, "false" when disabled or in watch mode
process.env.DOMSTACK_SERVICE_WORKER_URL Public service-worker URL, usually /service-worker.js, or "" when absent
process.env.DOMSTACK_SERVICE_WORKER_SCOPE Registration scope, usually /, or "" when absent

A service worker can use the manifest URL at install time:

const manifestUrl = process.env.DOMSTACK_OUTPUT_MANIFEST_URL

self.addEventListener('install', (event) => {
  event.waitUntil(precache())
})

async function precache () {
  if (process.env.DOMSTACK_OUTPUT_MANIFEST_ENABLED !== 'true') return

  const response = await fetch(manifestUrl, { cache: 'no-store' })
  const manifest = await response.json()
  const urls = manifest.entries
    .filter(entry => entry.revision)
    .filter(entry => entry.kind !== 'sourcemap')
    .filter(entry => entry.kind !== 'metadata')
    .map(entry => entry.url)

  const cache = await caches.open('static-' + manifest.version)
  await cache.addAll(urls)
}

Application client code can register the worker from global.client.js:

const serviceWorkerUrl = process.env.DOMSTACK_SERVICE_WORKER_URL
const serviceWorkerScope = process.env.DOMSTACK_SERVICE_WORKER_SCOPE

if (serviceWorkerUrl && serviceWorkerScope && 'serviceWorker' in navigator) {
  navigator.serviceWorker.register(serviceWorkerUrl, { scope: serviceWorkerScope })
}

Domstack does not inject registration into the default layout. Registration timing, update prompts, local-development opt-outs, poisoned-cache recovery, and offline route behavior are application policy.

Compatibility Notes

  • copy is now the output kind for files copied by the generic copy step.
  • The manifest entry shape includes the Workbox-compatible url / revision pair, but this PR does not add a Workbox-specific generated artifact. There is a TODO to consider a derived Workbox manifest only after a concrete use case validates the API.
  • Watch mode intentionally does not write domstack-output-manifest.json or return results.outputManifest; it does still build and rebundle the site service worker.

Testing

  • node --test lib/identify-pages.test.js test-cases/general-features/index.test.js
  • npm run test:tsc
  • npm run test:neostandard
  • npm run build:declaration
  • npm run build:schema
  • 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

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​json-schema-to-ts@​3.1.110010010080100

View full report

@bcomnes bcomnes force-pushed the staic-client-cache branch from e8c79b8 to f665681 Compare June 17, 2026 03:31
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