diff --git a/README.md b/README.md index ac62c4ae..929c026c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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 @@ -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)) }) @@ -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 @@ -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 @@ -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 | diff --git a/index.js b/index.js index 236785d6..5a99f85a 100644 --- a/index.js +++ b/index.js @@ -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' @@ -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' @@ -128,6 +129,10 @@ export { * @typedef {TemplateInfo} TemplateInfo */ +/** + * @typedef {ServiceWorkerInfo} ServiceWorkerInfo + */ + /** * @typedef {BuildOutputManifest} BuildOutputManifest */ @@ -441,6 +446,7 @@ 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 = ( @@ -448,6 +454,7 @@ export class DomStack { 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 @@ -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) @@ -665,6 +673,7 @@ export class DomStack { const esbuildEntryPoints = /** @type {Set} */ (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)) diff --git a/lib/build-esbuild/index.js b/lib/build-esbuild/index.js index 1a30d48d..c0b6846f 100644 --- a/lib/build-esbuild/index.js +++ b/lib/build-esbuild/index.js @@ -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] @@ -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') }, @@ -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, }, @@ -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. @@ -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) @@ -360,6 +441,7 @@ export function createEsbuildOutputRecords ({ src, dest, siteData, buildResults, outputRelname, entryPoint: outputMeta.entryPoint, workerOutputRelnames, + serviceWorkerOutputRelname, }) outputs.push(createOutputRecord({ diff --git a/lib/build-output-manifest/index.js b/lib/build-output-manifest/index.js index 3f561527..347f3934 100644 --- a/lib/build-output-manifest/index.js +++ b/lib/build-output-manifest/index.js @@ -28,6 +28,7 @@ export const buildOutputKindSchema = /** @type {const} */ ({ 'script', 'style', 'chunk', + 'service-worker', 'worker', 'worker-manifest', 'static', @@ -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], @@ -299,12 +301,14 @@ export function createCopiedOutputRecords ({ src, dest, report, kind }) { * @param {string} params.outputRelname * @param {string | undefined} params.entryPoint * @param {Set} 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' diff --git a/lib/build-output-manifest/schema.json b/lib/build-output-manifest/schema.json index bf8588d6..1e19b19a 100644 --- a/lib/build-output-manifest/schema.json +++ b/lib/build-output-manifest/schema.json @@ -32,6 +32,7 @@ "script", "style", "chunk", + "service-worker", "worker", "worker-manifest", "static", diff --git a/lib/helpers/domstack-error.js b/lib/helpers/domstack-error.js index bb8a6d3b..65765252 100644 --- a/lib/helpers/domstack-error.js +++ b/lib/helpers/domstack-error.js @@ -1,4 +1,4 @@ -/** @typedef { 'DOM_STACK_ERROR_DUPLICATE_PAGE' } DomStackErrorCode */ +/** @typedef { 'DOM_STACK_ERROR_DUPLICATE_PAGE' | 'DOM_STACK_ERROR_DUPLICATE_SERVICE_WORKER' } DomStackErrorCode */ /** * Domstack Duplicate Page Error @@ -31,6 +31,33 @@ export class DomStackDuplicatePageError extends Error { } } +/** + * Domstack Duplicate Service Worker Error + * @extends {Error} + */ +export class DomStackDuplicateServiceWorkerError extends Error { + duplicates + + /** + * Constructs a new DomStackDuplicateServiceWorkerError instance. + * + * @param {string} message - The error message + * @param {{ files: string[] }} duplicates - Extra params + * @param {ErrorOptions} [opts] - The opts object from the Error class + */ + constructor (message, duplicates, opts) { + super(message, opts) + this.duplicates = duplicates + } + + /** + * @returns {DomStackErrorCode} + */ + get code () { + return 'DOM_STACK_ERROR_DUPLICATE_SERVICE_WORKER' + } +} + /** @typedef { 'DOM_STACK_WARNING_DUPLICATE_LAYOUT' } DomStackWarningCode */ /** diff --git a/lib/helpers/generate-tree-data.js b/lib/helpers/generate-tree-data.js index 85a8f43a..50bb29fa 100644 --- a/lib/helpers/generate-tree-data.js +++ b/lib/helpers/generate-tree-data.js @@ -35,6 +35,7 @@ export function generateTreeData (cwd, src, dest, results) { leaf: { globalStyle: results?.siteData?.globalStyle?.outputRelname, globalClient: results?.siteData?.globalClient?.outputRelname, + serviceWorker: results?.siteData?.serviceWorker?.outputRelname, globalVars: results?.siteData?.globalVars?.basename, esbuildSettings: results?.siteData?.esbuildSettings?.basename, markdownItSettings: results?.siteData?.markdownItSettings?.basename, diff --git a/lib/identify-pages.js b/lib/identify-pages.js index cec555ae..4032ef4f 100644 --- a/lib/identify-pages.js +++ b/lib/identify-pages.js @@ -6,7 +6,7 @@ import { asyncFolderWalker } from 'async-folder-walker' import assert from 'node:assert' import { resolve, relative, join, basename } from 'path' import { pageBuilders } from './build-pages/index.js' -import { DomStackDuplicatePageError } from './helpers/domstack-error.js' +import { DomStackDuplicatePageError, DomStackDuplicateServiceWorkerError } from './helpers/domstack-error.js' import { nodeHasTS } from './helpers/has-ts.js' import { computePageUrl } from './build-pages/compute-page-url.js' @@ -74,6 +74,13 @@ export const globalClientNames = [ 'global.client.mjs', 'global.client.cjs' ] +export const serviceWorkerNames = nodeHasTS + ? [ + 'service-worker.ts', 'service-worker.mts', 'service-worker.cts', + 'service-worker.js', 'service-worker.mjs', 'service-worker.cjs' + ] + : ['service-worker.js', 'service-worker.mjs', 'service-worker.cjs'] + export const globalVarsNames = nodeHasTS ? [ 'global.vars.ts', 'global.vars.mts', 'global.vars.cts', @@ -171,6 +178,10 @@ const shaper = ({ * @property {string} outputName - The derived output name of the template file. Might be overridden. */ +/** + * @typedef {PageFileAsset} ServiceWorkerInfo + */ + /** * Identifies the pages, layouts, templates, and other relevant data from a given source directory. * @@ -240,6 +251,27 @@ export async function identifyPages (src, opts = {}) { /** @type {Error[]} */ const errors = [] + /** @type {PageFileAsset[]} */ + const serviceWorkerMatches = [] + for (const files of Object.values(dirs)) { + for (const serviceWorkerName of serviceWorkerNames) { + const file = files[serviceWorkerName] + if (file) serviceWorkerMatches.push(file) + } + } + + /** @type {PageFileAsset | undefined } */ + const serviceWorker = serviceWorkerMatches[0] + + if (serviceWorkerMatches.length > 1) { + errors.push(new DomStackDuplicateServiceWorkerError( + 'Conflicting service worker sources: Only one site service-worker file is supported.', + { + files: serviceWorkerMatches.map(file => file.relname), + } + )) + } + /** @type {string[]} */ // const nonPageFolders = [] @@ -495,8 +527,6 @@ export async function identifyPages (src, opts = {}) { } } - // const rootFiles = dirs[''] ?? {} - let defaultLayout = false if (!layouts['root']) { @@ -534,6 +564,7 @@ export async function identifyPages (src, opts = {}) { const results = { globalStyle, globalClient, + serviceWorker, globalVars, globalData, esbuildSettings, diff --git a/lib/identify-pages.test.js b/lib/identify-pages.test.js index 3bfb45c7..e62ee6c5 100644 --- a/lib/identify-pages.test.js +++ b/lib/identify-pages.test.js @@ -1,8 +1,10 @@ import { test } from 'node:test' import assert from 'node:assert' -import { resolve } from 'path' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'path' -import { identifyPages } from './identify-pages.js' +import { identifyPages, serviceWorkerNames } from './identify-pages.js' const __dirname = import.meta.dirname @@ -26,5 +28,48 @@ test.describe('identify-pages', () => { assert.equal(results.pages.find(p => p.path === 'page-md-page')?.pageFile?.type, 'md', 'page-md-page is type md') assert.equal(results.pages.find(p => p.path === 'page-md-page')?.pageFile?.basename, 'page.md', 'page-md-page uses page.md') assert.equal(results.pages.find(p => p.path === 'page-md-precedence')?.pageFile?.basename, 'page.md', 'page.md takes precedence over README.md') + assert.equal(results.serviceWorker?.relname, 'globals/service-worker.mts', 'site service worker is found') + }) + + test('identifies supported site service worker entry filenames', async (t) => { + assert.ok(serviceWorkerNames.includes('service-worker.mjs'), 'mjs service worker names are supported') + assert.ok(serviceWorkerNames.includes('service-worker.cjs'), 'cjs service worker names are supported') + assert.ok(serviceWorkerNames.includes('service-worker.mts'), 'mts service worker names are supported') + assert.ok(serviceWorkerNames.includes('service-worker.cts'), 'cts service worker names are supported') + + const tmp = await mkdtemp(join(tmpdir(), 'domstack-service-worker-')) + t.after(async () => { + await rm(tmp, { recursive: true, force: true }) + }) + + for (const serviceWorkerName of serviceWorkerNames) { + const src = join(tmp, serviceWorkerName) + const assets = join(src, 'global-assets') + await rm(src, { recursive: true, force: true }) + await mkdir(assets, { recursive: true }) + await writeFile(join(assets, serviceWorkerName), 'self.addEventListener("install", () => {})\n') + + const results = await identifyPages(src) + + assert.equal(results.serviceWorker?.basename, serviceWorkerName, `${serviceWorkerName} is detected`) + assert.equal(results.serviceWorker?.relname, `global-assets/${serviceWorkerName}`, `${serviceWorkerName} can live below src`) + assert.equal(results.errors.length, 0, `${serviceWorkerName} does not produce errors`) + } + }) + + test('errors on duplicate site service worker entries', async (t) => { + const src = await mkdtemp(join(tmpdir(), 'domstack-service-worker-dupe-')) + t.after(async () => { + await rm(src, { recursive: true, force: true }) + }) + + await mkdir(join(src, 'global-assets'), { recursive: true }) + await writeFile(join(src, 'global-assets/service-worker.mjs'), 'self.addEventListener("install", () => {})\n') + await writeFile(join(src, 'service-worker.cjs'), 'self.addEventListener("install", () => {})\n') + + const results = await identifyPages(src) + const error = results.errors.find(error => 'code' in error && error.code === 'DOM_STACK_ERROR_DUPLICATE_SERVICE_WORKER') + + assert.ok(error, 'duplicate site service worker sources produce a clear error') }) }) diff --git a/plans/first-class-service-workers.md b/plans/first-class-service-workers.md index dce4d972..aaca2ea2 100644 --- a/plans/first-class-service-workers.md +++ b/plans/first-class-service-workers.md @@ -1,6 +1,6 @@ # First-Class Service Workers -## Status: Planned +## Status: Implemented in the stacked PR ## Goal @@ -25,16 +25,22 @@ different requirements: ## Proposed Source Conventions -Support one site-level service worker entry point: +Support one site-level service worker entry point anywhere under `src`, matching domstack's other +global asset patterns: ```txt src/ - service-worker.js - service-worker.ts + globals/ + service-worker.js + service-worker.ts ``` -Only one service worker entry should be allowed. If both `.js` and `.ts` forms are present, fail with -a clear duplicate-entry error. +Supported JavaScript filenames are `service-worker.js`, `service-worker.mjs`, and +`service-worker.cjs`. When Node's TypeScript support is available, `service-worker.ts`, +`service-worker.mts`, and `service-worker.cts` are also supported. + +Only one service worker entry should be allowed. If multiple forms are present anywhere in `src`, +fail with a clear duplicate-entry error. The output should be: @@ -47,7 +53,7 @@ the normal chunk naming rules. ## Build Pipeline -1. `identifyPages()` detects a root service worker entry and stores it on `siteData.serviceWorker`. +1. `identifyPages()` detects a site service worker entry and stores it on `siteData.serviceWorker`. 2. `buildEsbuild()` adds that entry to esbuild's entry points. 3. esbuild emits the entry as `service-worker.js` at the destination root. 4. `createEsbuildOutputRecords()` classifies it as `kind: 'service-worker'`. @@ -120,7 +126,7 @@ This keeps lifecycle UX, update prompts, and online/offline handling in the appl Add fixture coverage for: -- `service-worker.js` detection and output at `/service-worker.js`. +- `service-worker.js` detection anywhere in `src` and output at `/service-worker.js`. - `service-worker.ts` detection when TypeScript is enabled. - duplicate service worker source files fail clearly. - service worker imports are bundled. @@ -132,7 +138,7 @@ Add fixture coverage for: Breadcrum can replace its service-worker template/static workaround with: ```txt -packages/web/client/src/service-worker.ts +packages/web/client/globals/service-worker.ts ``` That file should fetch `/domstack-output-manifest.json`, cache entries selected by Breadcrum policy, diff --git a/test-cases/general-features/index.test.js b/test-cases/general-features/index.test.js index cc859b76..365b9ecd 100644 --- a/test-cases/general-features/index.test.js +++ b/test-cases/general-features/index.test.js @@ -45,13 +45,20 @@ test.describe('general-features', () => { assert.ok(manifestEntryByUrl.has('/md-page/'), 'output manifest includes nested page URL') assert.ok(manifestEntryByUrl.has('/md-page/loose-md.html'), 'output manifest includes loose markdown URL') assert.ok(manifestEntryByUrl.has('/feeds/feed.json'), 'output manifest includes normal template output') - assert.ok(manifestEntryByUrl.has('/service-worker.js'), 'output manifest includes service worker template output') assert.ok(manifestEntryByUrl.has('/worker-page/workers.json'), 'output manifest includes worker manifest') assert.ok( !manifestEntryByUrl.has('/domstack-output-manifest.json'), 'output manifest does not include itself' ) + const serviceWorkerEntry = manifestEntryByUrl.get('/service-worker.js') + assert.equal(serviceWorkerEntry?.kind, 'service-worker', 'output manifest classifies the site service worker') + assert.equal( + serviceWorkerEntry?.sourceRelname, + 'globals/service-worker.mts', + 'output manifest records the service worker source file' + ) + assert.ok( manifestEntries.some(entry => entry.kind === 'chunk' && entry.url.startsWith('/chunks/js/chunk-')), 'output manifest classifies shared JS chunks' @@ -85,12 +92,16 @@ test.describe('general-features', () => { } const serviceWorkerContent = await readFile(path.join(dest, 'service-worker.js'), 'utf8') - assert.ok(serviceWorkerContent.includes('DOMSTACK_MANIFEST_URL'), 'service worker template was emitted') + assert.ok(serviceWorkerContent.includes('/domstack-output-manifest.json'), 'service worker was bundled') + assert.ok(serviceWorkerContent.includes('cache: "no-store"'), 'service worker fetches the output manifest at runtime') + assert.ok(serviceWorkerContent.includes('caches.match(request)'), 'service worker has cache-first fetch handling') + + const metaContent = await readFile(path.join(dest, 'domstack-esbuild-meta.json'), 'utf8') + const metaData = JSON.parse(metaContent) assert.ok( - serviceWorkerContent.includes("fetch(DOMSTACK_MANIFEST_URL, { cache: 'no-store' })"), - 'service worker fetches the output manifest at runtime' + Object.keys(metaData.outputs).some(outputPath => outputPath.endsWith('/service-worker.js')), + 'esbuild metafile includes the service worker output' ) - assert.ok(serviceWorkerContent.includes('caches.match(request)'), 'service worker has cache-first fetch handling') const stableResults = await siteUp.build() assert.strictEqual( diff --git a/test-cases/general-features/src/globals/service-worker.mts b/test-cases/general-features/src/globals/service-worker.mts new file mode 100644 index 00000000..de74c4ff --- /dev/null +++ b/test-cases/general-features/src/globals/service-worker.mts @@ -0,0 +1,68 @@ +import { CACHE_PREFIX, DOMSTACK_MANIFEST_URL, loadManifest } from '../libs/service-worker-helper.js' + +type BuildOutputEntry = { + revision?: unknown, + kind: string, + url: string +} + +type BuildOutputManifest = { + version: string, + entries: BuildOutputEntry[] +} + +type ServiceWorkerExtendableEvent = Event & { + waitUntil (promise: Promise): void +} + +type ServiceWorkerFetchEvent = Event & { + request: Request, + respondWith (response: Promise): void +} + +self.addEventListener('install', event => { + const installEvent = event as ServiceWorkerExtendableEvent + installEvent.waitUntil(precache()) +}) + +self.addEventListener('activate', event => { + const activateEvent = event as ServiceWorkerExtendableEvent + activateEvent.waitUntil(cleanup()) +}) + +self.addEventListener('fetch', event => { + const fetchEvent = event as ServiceWorkerFetchEvent + if (fetchEvent.request.method !== 'GET') return + fetchEvent.respondWith(cacheFirst(fetchEvent.request)) +}) + +async function precache () { + const manifest = await loadBuildManifest() + const cache = await caches.open(CACHE_PREFIX + manifest.version) + const urls = manifest.entries + .filter(entry => entry.revision) + .filter(entry => entry.kind !== 'sourcemap') + .filter(entry => entry.kind !== 'metadata') + .filter(entry => entry.url !== DOMSTACK_MANIFEST_URL) + .map(entry => entry.url) + + await cache.addAll(urls) +} + +async function cleanup () { + const manifest = await loadBuildManifest() + const current = CACHE_PREFIX + manifest.version + const names = await caches.keys() + await Promise.all(names + .filter(name => name.startsWith(CACHE_PREFIX) && name !== current) + .map(name => caches.delete(name))) +} + +async function cacheFirst (request: Request) { + const cached = await caches.match(request) + return cached || fetch(request) +} + +async function loadBuildManifest (): Promise { + return await loadManifest() as BuildOutputManifest +} diff --git a/test-cases/general-features/src/libs/service-worker-helper.js b/test-cases/general-features/src/libs/service-worker-helper.js new file mode 100644 index 00000000..4d7c3833 --- /dev/null +++ b/test-cases/general-features/src/libs/service-worker-helper.js @@ -0,0 +1,8 @@ +export const DOMSTACK_MANIFEST_URL = '/domstack-output-manifest.json' +export const CACHE_PREFIX = 'domstack-precache-' + +export async function loadManifest () { + const response = await fetch(DOMSTACK_MANIFEST_URL, { cache: 'no-store' }) + if (!response.ok) throw new Error('Unable to load domstack output manifest') + return response.json() +} diff --git a/test-cases/general-features/src/pwa.template.js b/test-cases/general-features/src/pwa.template.js deleted file mode 100644 index cda5f85e..00000000 --- a/test-cases/general-features/src/pwa.template.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @import { TemplateFunction } from '../../../index.js' - */ - -/** - * @type {TemplateFunction>} - */ -export default async function pwaTemplate () { - return { - outputName: 'service-worker.js', - content: `const DOMSTACK_MANIFEST_URL = '/domstack-output-manifest.json' -const CACHE_PREFIX = 'domstack-precache-' - -self.addEventListener('install', event => { - event.waitUntil(precache()) -}) - -self.addEventListener('activate', event => { - event.waitUntil(cleanup()) -}) - -self.addEventListener('fetch', event => { - if (event.request.method !== 'GET') return - event.respondWith(cacheFirst(event.request)) -}) - -async function loadManifest () { - const response = await fetch(DOMSTACK_MANIFEST_URL, { cache: 'no-store' }) - if (!response.ok) throw new Error('Unable to load domstack output manifest') - return response.json() -} - -async function precache () { - const manifest = await loadManifest() - const cache = await caches.open(CACHE_PREFIX + manifest.version) - const urls = manifest.entries - .filter(entry => entry.revision) - .filter(entry => entry.kind !== 'sourcemap') - .filter(entry => entry.kind !== 'metadata') - .map(entry => entry.url) - await cache.addAll(urls) -} - -async function cleanup () { - const manifest = await loadManifest() - const current = CACHE_PREFIX + manifest.version - const names = await caches.keys() - await Promise.all(names - .filter(name => name.startsWith(CACHE_PREFIX) && name !== current) - .map(name => caches.delete(name))) -} - -async function cacheFirst (request) { - const cached = await caches.match(request) - return cached || fetch(request) -} -`, - } -} diff --git a/test-cases/watch/index.test.js b/test-cases/watch/index.test.js index 63f2e4c8..f17c6145 100644 --- a/test-cases/watch/index.test.js +++ b/test-cases/watch/index.test.js @@ -68,7 +68,7 @@ test.describe('watch', () => { 'watch mode does not write an output manifest' ) const serviceWorkerStat = await stat(path.join(dest, 'service-worker.js')) - assert.ok(serviceWorkerStat.isFile(), 'watch mode renders normal service-worker templates') + assert.ok(serviceWorkerStat.isFile(), 'watch mode builds site service-worker entries') // ── Chunks have hashed names in watch mode ─────────────────────── // html-page/client.js, js-page/client.js, and md-page/client.js all import @@ -157,6 +157,27 @@ test.describe('watch', () => { ) }) + // ── service worker change → esbuild rebuilds, no page rebuild ─── + await t.test('service worker change does not rebuild pages', async () => { + mockLog.mock.resetCalls() + + const serviceWorkerFile = path.join(src, 'globals/service-worker.mts') + const original = await readFile(serviceWorkerFile, 'utf8') + await writeFile(serviceWorkerFile, original + '\n// touch') + + await settle(siteUp) + + const logs = getLogLines(mockLog) + assert.ok( + logs.some(l => l.includes('JS/CSS rebuild complete.')), + 'esbuild rebundled the service worker' + ) + assert.ok( + !logs.some(l => l.includes('Pages built')), + 'no page rebuild was triggered' + ) + }) + // ── esbuild dep change → esbuild rebuilds, no page rebuild ───── await t.test('changing a client.js dependency triggers esbuild rebuild only', async () => { mockLog.mock.resetCalls()