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 output manifest for static app caching and pairs it with first-class site service-worker support.

Warning

The domstack manifest, domstack-manifest.settings.*, first-class service-worker.* entries, and related browser process.env.DOMSTACK_* defines 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.

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-manifest.json, written by one-shot builds by default.
  • Return results.domstackManifest from programmatic builds.
  • 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, and optional source/page metadata.
  • Add a committed JSON Schema for the manifest with a versioned unpkg $schema URL.
  • Derive/export public manifest types through @domstack/static/types.js / #types using json-schema-to-ts.
  • Add domstack-manifest.settings.{js,mjs,cjs,ts,mts,cts} for 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 domstack manifests are not written or returned in watch mode.
  • Replace .npmignore with a package.json#files allowlist.
  • Add a PWA example showing app-owned registration, update UX, manifest filtering, and cache lifecycle handling.

New Preview APIs

Build Results

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

results.domstackManifest is still returned when writing the JSON file is disabled with domstackManifest: false. Watch mode does not return a domstack manifest.

CLI Options

domstack --customDomstackManifestName custom-domstack-manifest.json
domstack --noDomstackManifest
domstack --serve

--customDomstackManifestName changes the written manifest filename from the default domstack-manifest.json. --noDomstackManifest disables writing the JSON file. --serve runs a one-shot build and then serves the output without watch mode, which is useful for testing service-worker cache lifecycle behavior with a manifest-enabled build.

Programmatic Options

const site = new DomStack('src', 'public', {
  domstackManifest: {
    filename: 'custom-domstack-manifest.json',
    exclude: ['blog/**', '**/*.map'],
  },
})

Supported shape:

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

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 {
  exclude: ['admin/**', 'blog/**', '**/*.map'],
  includeEntry (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
  },
}

Filtering affects both the written domstack-manifest.json and results.domstackManifest, and it affects manifest.version because versioning is based on final cache-relevant entries.

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

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 Public URL of the written domstack manifest, usually /domstack-manifest.json
process.env.DOMSTACK_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

Compatibility Notes

  • This is an unstable preview feature and can change outside a major version.
  • copy is now the output kind for files copied by the generic copy step.
  • Watch mode intentionally does not write domstack-manifest.json or return results.domstackManifest; it does still build and rebundle the site service worker.

Testing

  • npm test
  • npm run test:tsc
  • npm run test:neostandard
  • npm run build:declaration
  • npm run build:schema
  • 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

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
@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
await writeFile(join(dest, 'domstack-esbuild-meta.json'), JSON.stringify(buildResults.metafile, null, ' '))
const serviceWorkerBuildOpts = createServiceWorkerBuildOpts({ buildOpts: extendedBuildOpts, src, siteData })
const serviceWorkerBuildResults = serviceWorkerBuildOpts
? await esbuild.build(serviceWorkerBuildOpts)

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.

Does the service worker have its own build?

// @ts-ignore This is fine
buildOpts: extendedBuildOpts,
outputs,
},

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.

Does it make sense to make report a single key object. Why not just return outputs as results.

* @param {...(esbuild.BuildResult | undefined)} results
* @returns {esbuild.BuildResult}
*/
function mergeBuildResults (...results) {

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 done efficiently? Would a reduce or for look get this down tone a single iteration?


return {
inputs: Object.assign({}, ...metafiles.map(metafile => metafile.inputs)),
outputs: Object.assign({}, ...metafiles.map(metafile => metafile.outputs)),

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 object adding instead of just spreads

* @param {boolean} params.includeMetafileRecord
* @returns {DomstackManifestRecord[]}
*/
export function createEsbuildOutputRecords ({ src, dest, siteData, buildResults, includeMetafileRecord }) {

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 audit for efficiency.

* @param {PageData<any, any, any>} page
*/
function extractPrecacheVars (page) {
/** @type {{ precache?: unknown, offline?: unknown }} */

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 seems like a peak concern sneaking in to what really should be a manifest collecting feature.

template,
outputRecords,
})
} else if (

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.

Are we using exhaustive type narrowing here.

* @param {string} value
*/
function toPosix (value) {
return value.split(sep).join('/')

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.

There is a version of this that's exported somewhere. Make sure this isn't a double define.

* @param {string} dest
* @param {string} filepath
*/
function assertInsideDest (dest, filepath) {

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.

Should be a generic helper lib.

Comment thread lib/build-pages/index.js
* pages: Awaited<ReturnType<typeof pageWriter>>[]
* templates: Awaited<ReturnType<typeof templateBuilder>>[]
* outputs: DomstackManifestRecord[]
* }} PageBuilderReport

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 did this collapse. Double check we should just be dealing with what's in outputs.

Comment thread lib/build-static/index.js
try {
const report = await copy(getCopyGlob(src), dest, ...(opts?.ignore ? [{ ignore: opts.ignore }] : []))
results.report = report
results.report.outputs = createCopiedDomstackManifestRecords({

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.

Double check what this is doing.


// These schema objects are the source of truth for both runtime manifest validation and
// the public TypeScript/JSDoc types derived below with json-schema-to-ts.
/** @satisfies {JSONSchema} */

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.

I don't think this syntax is right this needs to be inline with the type const.

],
})

/** @satisfies {JSONSchema} */

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.

Same. Bad satisfies syntax.

precache: {
description: 'Application-defined page precache policy metadata. Domstack records this value but does not interpret it.',
},
offline: {

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.

Seems odd to predefne this capture. Maybe let users configure which keys to capture.

additionalProperties: false,
})

/** @satisfies {JSONSchema} */

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.

Every schema has bad syntax I'm fairly certain.

* A build step writes these as it emits files. Reconciliation turns them into
* revisioned manifest entries.
*
* @typedef {object} DomstackManifestRecord

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.

These should have descriptions right?

// TODO: If a concrete client needs Workbox integration, consider adding an
// optional derived artifact that projects manifest entries to Workbox's
// `{ url, revision }` precache shape. Keep the domstack manifest as the richer
// source of truth until that use case can validate the exact API.

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.

When this is done also confirm we support the right lifecycle hook for workbox. I think it relies on full code gen for the service worker.

// `{ url, revision }` precache shape. Keep the domstack manifest as the richer
// source of truth until that use case can validate the exact API.

const KIND_PRIORITY = new Map([

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 for?

* @returns {Promise<DomstackManifest>}
*/
export async function buildDomstackManifest ({ dest, records = [], entries: existingEntries = [], options = {} }) {
/** @type {Map<string, DomstackManifestRecord & { url: string, revision: string | null, bytes: number | null }>} */

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 this type ugly. Why don't we have a clean type to use here.

setEntry(entryMap, entry)
}

entryMap.delete(toPosix(options.filename ?? DEFAULT_DOMSTACK_MANIFEST_FILENAME))

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.

Should we be deleting records?


entryMap.delete(toPosix(options.filename ?? DEFAULT_DOMSTACK_MANIFEST_FILENAME))

let finalEntries = Array.from(entryMap.values())

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 try to make this all happen in a single pass rather than multiple iterations.

* Create a normalized record for a file inside dest.
*
* @param {object} params
* @param {string} params.dest

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 no descriptions?

kind,
url: url ?? outputRelnameToUrl(normalizedOutputRelname),
...(sourceRelname ? { sourceRelname: toPosix(sourceRelname) } : {}),
...(entryPoint ? { entryPoint: normalizeEntryPoint(entryPoint) } : {}),

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 is kinda knarly

*/
export function createCopiedDomstackManifestRecords ({ src, dest, report, kind }) {
return extractCopiedFiles(report).map(copiedFile => {
const filepath = resolve(copiedFile.output)

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.

I wonder if this would better live near cpx2 builder and instead have it just conform to a type over in that file.

*/
export function classifyEsbuildOutput ({ outputRelname, entryPoint, workerOutputRelnames, serviceWorkerOutputRelname }) {
const ext = extname(outputRelname)

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.

Same here. Closer to es build builder and use a type

* @param {DomstackManifest} domstackManifest
* @param {string} [filename]
*/
export async function writeDomstackManifest (dest, domstackManifest, filename = DEFAULT_DOMSTACK_MANIFEST_FILENAME) {

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 don't have a generic version of this

/** @type {DomstackManifestOptions} */
const options = {
exclude: [
...((Array.isArray(domstackManifestOpts.exclude) ? domstackManifestOpts.exclude : [])),

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.

Ugly code clean up

* @param {string} relname
*/
export function outputRelnameToUrl (relname) {
const posixRelname = toPosix(relname)

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.

Double check for dupes

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.

That we didn't define this elsewhere.

* @param {string} value
*/
export function toPosix (value) {
return value.split(sep).join('/')

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.

Generic helpers should be inserted generic file location

* @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?

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