Skip to content
Merged
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
78 changes: 48 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ domstack v11 is a major release that renames the project from `top-bun` to `@dom
- **CLI**: `top-bun`/`tb` → `domstack`/`dom`
- **Programmatic API**: `TopBun` class → `DomStack`, all `TopBun*` types/errors/warnings renamed to `DomStack*`
- **`postVars` removed**: migrate `postVars` exports from `page.vars.js` files to a single `global.data.js` with a default export
- **New reserved filenames**: `global.data.js`, `markdown-it.settings.js`, `page.md`, `*.worker.{js,ts}` are now special — rename any colliding files
- **New reserved filenames**: `global.data.js`, `markdown-it.settings.js`, `page.md`, `service-worker.*`, `*.worker.{js,ts}` are now special — rename any colliding files
- **Default layout**: switched from `uhtml-isomorphic` to `preact`; add `uhtml-isomorphic` to your own deps if you import it directly
- **Output paths**: `top-bun-esbuild-meta.json` → `domstack-esbuild-meta.json`, `top-bun-defaults/` → `domstack-defaults/`
- **Conflict now throws**: using both `browser` in `global.vars.js` and `define` in `esbuild.settings.js` is now a hard error
Expand Down Expand Up @@ -128,7 +128,8 @@ src % tree
│ ├── global.vars.ts # site wide variables get defined in global.vars.ts
│ ├── global.data.ts # optional file to derive and aggregate data from all pages before rendering
│ ├── markdown-it.settings.ts # You can customize the markdown-it instance used to render markdown
│ └── esbuild.settings.ts # You can even customize the build settings passed to esbuild
│ ├── esbuild.settings.ts # You can even customize the build settings passed to esbuild
│ └── service-worker.ts # a site service worker builds to /service-worker.js.
├── page.md # The top level page can also be a page.md (or README.md) file.
├── client.ts # the top level page can define a page scoped js client.
├── style.css # the top level page can define a page scoped css style.
Expand Down Expand Up @@ -980,8 +981,9 @@ type BuildOutputEntry = {
url: string
outputRelname: string
filepath: string
kind: 'page' | 'template' | 'script' | 'style' | 'chunk' | 'worker' |
'worker-manifest' | 'static' | 'copy' | 'sourcemap' | 'metadata'
kind: 'page' | 'template' | 'script' | 'style' | 'chunk' |
'service-worker' | 'worker' | 'worker-manifest' | 'static' |
'copy' | 'sourcemap' | 'metadata'
revision: string | null
bytes: number | null
sourceRelname?: string
Expand Down Expand Up @@ -1048,29 +1050,37 @@ automatically apply those flags; service workers and deployment tools can use th

### Service workers

Service workers do not need build-time access to the manifest. A stable `service-worker.js` can be
written as a normal template or copied static file, then fetch `domstack-output-manifest.json` during
installation:
Put one site service worker source file anywhere under `src` and domstack will build it to a stable
root `/service-worker.js` output:

```txt
src/
globals/
service-worker.js
```

When Node's TypeScript support is available, the same convention also supports
`service-worker.ts`, `service-worker.mts`, and `service-worker.cts`. JavaScript projects can use
`service-worker.js`, `service-worker.mjs`, or `service-worker.cjs`.

Only one site service worker source is allowed. If multiple `service-worker.*` sources are present,
domstack fails with `DOM_STACK_ERROR_DUPLICATE_SERVICE_WORKER`. Service workers are bundled by
esbuild, so imports work the same way they do for client bundles and page-scoped web workers. The
entry filename is intentionally not content-hashed because browser service-worker update checks need
a stable URL.

Service workers do not need build-time access to the manifest. The built worker can fetch
`domstack-output-manifest.json` during installation:

```js
/**
* @import { TemplateFunction } from '@domstack/static'
*/

/**
* @type {TemplateFunction}
*/
export default function serviceWorkerTemplate () {
return {
outputName: 'service-worker.js',
content: `const DOMSTACK_MANIFEST_URL = '/domstack-output-manifest.json'
const DOMSTACK_MANIFEST_URL = '/domstack-output-manifest.json'
const CACHE_PREFIX = 'domstack-precache-'

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

self.addEventListener('fetch', event => {
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return
event.respondWith(cacheFirst(event.request))
})
Expand All @@ -1092,18 +1102,27 @@ async function cacheFirst (request) {
const cached = await caches.match(request)
return cached || fetch(request)
}
`,
}
```

Register the built service worker from your site client code, usually `global.client.js`:

```js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
}
```

domstack does not inject this into the default layout. Registration timing, update prompts,
development opt-outs, and recovery behavior are application policy, so keep that logic in your
global client or an imported client module.

This keeps domstack's build pipeline to one page/template pass and one manifest reconciliation. Use
`buildManifest.exclude` or `buildManifest.includeOutput(entry)` to keep entries such as source maps,
admin routes, or blog pages out of the written manifest before the service worker sees it.

Watch mode renders normal service-worker templates but does not write `domstack-output-manifest.json`
or return `results.outputManifest`. Use one-shot builds when testing service-worker and PWA cache
behavior.
Watch mode builds and rebundles site service-worker entries, but it does not write
`domstack-output-manifest.json` or return `results.outputManifest`. Use one-shot builds when testing
service-worker and PWA cache behavior.

## Global Assets

Expand Down Expand Up @@ -1665,10 +1684,9 @@ When you run `domstack --watch` (or `domstack -w`), domstack performs an initial

**chokidar watch** — Page files, layouts, templates, and config files are watched by chokidar. When a file changes, domstack determines the minimal set of pages to rebuild using dependency tracking maps built at startup.

Output manifests are build-only artifacts. Watch mode renders normal templates, including a
`service-worker.js` template if your site has one, but it does not write
`domstack-output-manifest.json` or return `results.outputManifest`. Use a normal build when testing
PWA cache lifecycle behavior.
Output manifests are build-only artifacts. Watch mode builds and rebundles site service-worker
entries, but it does not write `domstack-output-manifest.json` or return `results.outputManifest`.
Use a normal build when testing PWA cache lifecycle behavior.

#### What triggers what

Expand All @@ -1683,7 +1701,7 @@ PWA cache lifecycle behavior.
| `markdown-it.settings.*` | All `.md` pages |
| `global.data.*` | All pages and templates |
| `global.vars.*` or `esbuild.settings.*` | Full rebuild (esbuild restart + all pages) |
| `client.js`, `style.css`, `*.layout.css`, `*.layout.client.*`, `global.client.*`, `global.css`, `*.worker.*` | esbuild handles it — no page rebuild |
| `client.js`, `style.css`, `*.layout.css`, `*.layout.client.*`, `global.client.*`, `global.css`, `*.worker.*`, `service-worker.*` | esbuild handles it — no page rebuild |
| Adding or removing an esbuild entry point (e.g. creating a new `client.js`) | esbuild restart + only the affected page(s) |
| Adding or removing any other file | Full rebuild |

Expand Down
17 changes: 13 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* @import { TemplateFunctionParams } from './lib/build-pages/page-builders/template-builder.js'
* @import { GlobalDataFunction, AsyncGlobalDataFunction, WorkerBuildStepResult, GlobalDataFunctionParams } from './lib/build-pages/index.js'
* @import { BuildOptions, BuildContext } from 'esbuild'
* @import { PageInfo, TemplateInfo } from './lib/identify-pages.js'
* @import { PageInfo, ServiceWorkerInfo, TemplateInfo } from './lib/identify-pages.js'
* @import { BuildOutputManifest } from './lib/build-output-manifest/index.js'
* @import { BuildOutputEntry } from './lib/build-output-manifest/index.js'
* @import { BuildOutputEntryPageMeta } from './lib/build-output-manifest/index.js'
Expand Down Expand Up @@ -49,6 +49,7 @@ import {
globalStyleNames,
pageStyleName,
pageWorkerSuffixs,
serviceWorkerNames,
} from './lib/identify-pages.js'
import { resolveVars } from './lib/build-pages/resolve-vars.js'
import { ensureDest } from './lib/helpers/ensure-dest.js'
Expand Down Expand Up @@ -128,6 +129,10 @@ export {
* @typedef {TemplateInfo} TemplateInfo
*/

/**
* @typedef {ServiceWorkerInfo} ServiceWorkerInfo
*/

/**
* @typedef {BuildOutputManifest} BuildOutputManifest
*/
Expand Down Expand Up @@ -441,13 +446,15 @@ export class DomStack {
*/
async #handleAddUnlink (changedPath, event) {
const changedBasename = basename(changedPath)
const changedDir = relative(this.#src, dirname(changedPath))

// Check if this is an esbuild entry point by basename pattern
const isEsbuildEntry = (
pageClientNames.includes(changedBasename) ||
layoutClientSuffixs.some(s => changedBasename.endsWith(s)) ||
changedBasename.endsWith(layoutStyleSuffix) ||
pageWorkerSuffixs.some(s => changedBasename.endsWith(s)) ||
serviceWorkerNames.includes(changedBasename) ||
globalClientNames.includes(changedBasename) ||
globalStyleNames.includes(changedBasename) ||
changedBasename === pageStyleName
Expand Down Expand Up @@ -476,9 +483,10 @@ export class DomStack {
this.#siteData = siteData

// Determine which pages are affected by this entry point change
const changedDir = relative(this.#src, dirname(changedPath))

if (globalClientNames.includes(changedBasename) || globalStyleNames.includes(changedBasename)) {
if (serviceWorkerNames.includes(changedBasename)) {
// Service workers are site-level esbuild entries and do not affect page HTML.
console.log(`"${changedBasename}" ${event}, no page rebuild needed.`)
} else if (globalClientNames.includes(changedBasename) || globalStyleNames.includes(changedBasename)) {
// Global asset: rebuild all pages
logRebuildTree(changedBasename, new Set(siteData.pages))
await this.#runPageBuild(siteData)
Expand Down Expand Up @@ -665,6 +673,7 @@ export class DomStack {
const esbuildEntryPoints = /** @type {Set<string>} */ (new Set())
if (siteData.globalClient) esbuildEntryPoints.add(resolve(siteData.globalClient.filepath))
if (siteData.globalStyle) esbuildEntryPoints.add(resolve(siteData.globalStyle.filepath))
if (siteData.serviceWorker) esbuildEntryPoints.add(resolve(siteData.serviceWorker.filepath))
for (const page of siteData.pages) {
if (page.clientBundle) esbuildEntryPoints.add(resolve(page.clientBundle.filepath))
if (page.pageStyle) esbuildEntryPoints.add(resolve(page.pageStyle.filepath))
Expand Down
98 changes: 90 additions & 8 deletions lib/build-esbuild/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ function updateSiteDataOutputPaths (outputMap, siteData) {
}
}

if (siteData.serviceWorker) {
const outputRelname = outputMap[siteData.serviceWorker.relname]
if (outputRelname) {
siteData.serviceWorker.outputRelname = outputRelname
siteData.serviceWorker.outputName = basename(outputRelname)
}
}

for (const layout of Object.values(siteData.layouts)) {
if (layout.layoutStyle) {
const outputRelname = outputMap[layout.layoutStyle.relname]
Expand Down Expand Up @@ -142,6 +150,16 @@ async function assembleBuildOpts (src, dest, siteData, opts, modeOpts = {}) {
const entryPoints = []
if (siteData.globalClient) entryPoints.push(join(src, siteData.globalClient.relname))
if (siteData.globalStyle) entryPoints.push(join(src, siteData.globalStyle.relname))
if (modeOpts.watch && siteData.serviceWorker) {
// The source may live anywhere under src, but the site service worker emits
// at /service-worker.js so it gets root scope without Service-Worker-Allowed
// headers. Production uses a separate stable-name build below because normal
// production entry names are content-hashed; watch keeps it in the live context.
entryPoints.push({
in: join(src, siteData.serviceWorker.relname),
out: 'service-worker',
})
}
if (siteData.defaultLayout) {
entryPoints.push(
{ in: join(__dirname, '../defaults/default.style.css'), out: join(DOM_STACK_DEFAULTS_PREFIX, 'default.style.css') },
Expand Down Expand Up @@ -231,27 +249,31 @@ export async function buildEsbuild (src, dest, siteData, opts) {
try {
const extendedBuildOpts = await assembleBuildOpts(src, dest, siteData, opts, { watch: false })

// @ts-ignore This actually works fine
const buildResults = await esbuild.build(extendedBuildOpts)

if (buildResults.metafile && opts?.metafile !== false) {
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)
: undefined
const combinedBuildResults = mergeBuildResults(buildResults, serviceWorkerBuildResults)

if (combinedBuildResults.metafile && opts?.metafile !== false) {
await writeFile(join(dest, 'domstack-esbuild-meta.json'), JSON.stringify(combinedBuildResults.metafile, null, ' '))
}

const outputMap = buildResults.metafile ? extractOutputMap(buildResults.metafile, src, dest) : {}
const outputMap = combinedBuildResults.metafile ? extractOutputMap(combinedBuildResults.metafile, src, dest) : {}
updateSiteDataOutputPaths(outputMap, siteData)
const outputs = createEsbuildOutputRecords({
src,
dest,
siteData,
buildResults,
buildResults: combinedBuildResults,
includeMetafileRecord: opts?.metafile !== false,
})

return {
type: 'esbuild',
errors: buildResults.errors,
warnings: buildResults.warnings,
errors: combinedBuildResults.errors,
warnings: combinedBuildResults.warnings,
report: {
outputs,
},
Expand All @@ -270,6 +292,62 @@ export async function buildEsbuild (src, dest, siteData, opts) {
}
}

/**
* Production entry filenames are content-hashed globally. Service workers need
* a stable root URL, so they get a tiny second build with a fixed entry name.
* Emitting at /service-worker.js also gives the worker root scope by default.
*
* @param {object} params
* @param {esbuild.BuildOptions} params.buildOpts
* @param {string} params.src
* @param {SiteData} params.siteData
* @returns {esbuild.BuildOptions | null}
*/
function createServiceWorkerBuildOpts ({ buildOpts, src, siteData }) {
if (!siteData.serviceWorker) return null

return {
...buildOpts,
entryPoints: [
{
in: join(src, siteData.serviceWorker.relname),
out: 'service-worker',
},
],
entryNames: '[name]',
}
}

/**
* @param {...(esbuild.BuildResult | undefined)} results
* @returns {esbuild.BuildResult}
*/
function mergeBuildResults (...results) {
const buildResults = /** @type {esbuild.BuildResult[]} */ (results.filter(Boolean))
const metafiles = /** @type {esbuild.Metafile[]} */ (
buildResults.map(result => result.metafile).filter(Boolean)
)

return /** @type {esbuild.BuildResult} */ ({
errors: buildResults.flatMap(result => result.errors),
warnings: buildResults.flatMap(result => result.warnings),
metafile: mergeMetafiles(...metafiles),
})
}

/**
* @param {...esbuild.Metafile} metafiles
* @returns {esbuild.Metafile | undefined}
*/
function mergeMetafiles (...metafiles) {
if (metafiles.length === 0) return undefined

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

/**
* Create an esbuild watch context with stable (unhashed) output filenames.
* Calls onEnd after each rebuild. Returns the context for disposal.
Expand Down Expand Up @@ -352,6 +430,9 @@ export function createEsbuildOutputRecords ({ src, dest, siteData, buildResults,
if (worker.outputRelname) workerOutputRelnames.add(toPosix(worker.outputRelname))
}
}
const serviceWorkerOutputRelname = siteData.serviceWorker?.outputRelname
? toPosix(siteData.serviceWorker.outputRelname)
: undefined

for (const [outputPath, outputMeta] of Object.entries(metafile.outputs)) {
const filepath = resolve(outputPath)
Expand All @@ -360,6 +441,7 @@ export function createEsbuildOutputRecords ({ src, dest, siteData, buildResults,
outputRelname,
entryPoint: outputMeta.entryPoint,
workerOutputRelnames,
serviceWorkerOutputRelname,
})

outputs.push(createOutputRecord({
Expand Down
6 changes: 5 additions & 1 deletion lib/build-output-manifest/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const buildOutputKindSchema = /** @type {const} */ ({
'script',
'style',
'chunk',
'service-worker',
'worker',
'worker-manifest',
'static',
Expand Down Expand Up @@ -148,6 +149,7 @@ export function getBuildOutputManifestSchemaId (version) {

const KIND_PRIORITY = new Map([
['page', 100],
['service-worker', 95],
['template', 90],
['worker-manifest', 80],
['worker', 70],
Expand Down Expand Up @@ -299,12 +301,14 @@ export function createCopiedOutputRecords ({ src, dest, report, kind }) {
* @param {string} params.outputRelname
* @param {string | undefined} params.entryPoint
* @param {Set<string>} params.workerOutputRelnames
* @param {string | undefined} [params.serviceWorkerOutputRelname]
* @returns {BuildOutputKind}
*/
export function classifyEsbuildOutput ({ outputRelname, entryPoint, workerOutputRelnames }) {
export function classifyEsbuildOutput ({ outputRelname, entryPoint, workerOutputRelnames, serviceWorkerOutputRelname }) {
const ext = extname(outputRelname)

if (ext === '.map') return 'sourcemap'
if (serviceWorkerOutputRelname && outputRelname === serviceWorkerOutputRelname) return 'service-worker'
if (workerOutputRelnames.has(outputRelname)) return 'worker'
if (ext === '.css') return 'style'
if (ext === '.js' && entryPoint) return 'script'
Expand Down
1 change: 1 addition & 0 deletions lib/build-output-manifest/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"script",
"style",
"chunk",
"service-worker",
"worker",
"worker-manifest",
"static",
Expand Down
Loading