From 9bb794657bc7ea764eedd0f4c7dde47ecd62e743 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sun, 13 Sep 2026 09:20:24 -0700 Subject: [PATCH 1/4] Add bundle roots for independent esbuild graphs --- docs/settings/README.md | 39 +++ lib/build-esbuild/bundle-roots.test.js | 415 +++++++++++++++++++++++++ lib/build-esbuild/index.js | 361 +++++++++++++++++++-- 3 files changed, 786 insertions(+), 29 deletions(-) create mode 100644 lib/build-esbuild/bundle-roots.test.js diff --git a/docs/settings/README.md b/docs/settings/README.md index 0bce521a..d66e16d1 100644 --- a/docs/settings/README.md +++ b/docs/settings/README.md @@ -119,6 +119,45 @@ DOMStack preserves its reserved `define` values after the override runs. These options also form the basis of the [service-worker](../workers/#service-workers) build. DOMStack replaces the service-worker entry point and filename and disables code splitting, while options such as plugins, loaders, `target`, and JSX configuration carry over. +### Bundle roots + +Export `bundleRoots` from `esbuild.settings.ts` when parts of a site should have independent code-splitting graphs. +This is useful for isolating an admin application from public pages so dependencies shared within the admin area are not factored into public chunks. +Each bundle root is a directory path relative to `src`. + +```typescript +import type { BuildOptions } from '@domstack/static/types.js' + +export const bundleRoots = ['admin', 'account/internal'] + +export default function esbuildSettings (options: BuildOptions): BuildOptions { + return options +} +``` + +DOMStack applies the default settings function once and then assigns its effective entry points to bundle-root builds. +When bundle roots are configured, entry points must be explicit file paths; glob entry points and `stdin` are rejected. +Entries outside every configured root remain together in the default build. +When roots overlap, an entry uses the deepest matching root, so `admin/reports/client.ts` belongs to `admin/reports` rather than `admin` when both are configured. +Matching uses path segments, so a root named `admin` does not include `administrator`. +Absolute paths, paths outside `src`, empty roots, and duplicate normalized roots are rejected. + +Each non-empty group receives an independent esbuild build in production and an independent esbuild context in watch mode. +Entry output paths remain source-relative, while a named root's generated chunks and file-loader assets are written beneath that root to avoid collisions between contexts. +For example, the `admin` root writes its shared chunks under `admin/chunks/` instead of the default `chunks/` directory. +The root service worker remains a separate self-contained build and is never assigned to a bundle root. + +DOMStack still writes one `domstack-esbuild-meta.json` containing the merged metadata from every browser group. +Programmatic build results retain the combined `report.buildResults` and `report.outputMap` fields, the unpartitioned settings in `report.buildOpts`, and exact per-group details in `report.builds`. +Multiple builds do not expose a combined `mangleCache`, because independently generated mappings can conflict; use each group's `buildResults.mangleCache` instead. +A settings plugin is configured on every resulting context, so plugins with shared mutable state must support multiple `setup()` calls. +Dependencies imported across roots are bundled independently by design, which trades some duplicate output and build work for isolation. +Bundle roots control code splitting, not access permissions or import boundaries; explicitly shared global or layout bundles can still be loaded by pages in multiple roots. +Changes to the settings file itself are reloaded when watch mode restarts its builds, but changes to that file's imported dependencies are not independently reloaded. +Conflicting outputs from custom naming settings fail the build, but output writes are not transactional, so a failed build can leave partial output in the destination. + +### Customizing build options + You can return a shallow copy that modifies the defaults when you only need a small change. For example, this keeps DOMStack's default asset loaders and adds a custom loader for `.wasm` files: diff --git a/lib/build-esbuild/bundle-roots.test.js b/lib/build-esbuild/bundle-roots.test.js new file mode 100644 index 00000000..2edb8dfb --- /dev/null +++ b/lib/build-esbuild/bundle-roots.test.js @@ -0,0 +1,415 @@ +/** + * @import { TestContext } from 'node:test' + * @import { BuildOptions } from 'esbuild' + */ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +import { pathToFileURL } from 'node:url' +import { setTimeout as delay } from 'node:timers/promises' +import { identifyPages } from '../identify-pages.js' +import { tmpdir } from 'node:os' +import test from 'node:test' +import { DomStack } from '../../index.js' +import { buildEsbuild, buildEsbuildWatch, createBundleBuilds, normalizeBundleRoots } from './index.js' + +/** + * @param {TestContext} t + * @param {string} settings + */ +async function createFixture (t, settings) { + const root = await mkdtemp(join(tmpdir(), 'domstack-bundle-roots-')) + const src = join(root, 'src') + const dest = join(root, 'public') + await mkdir(src, { recursive: true }) + await Promise.all(Object.entries({ + 'global.vars.js': "export default { layout: 'root' }\n", + 'root.layout.js': 'export default ({ children }) => children\n', + 'shared.js': "export const shared = 'shared bundle marker'\n", + 'page.js': "export default () => 'Public one'\n", + 'client.js': "import { shared } from './shared.js'; console.log(shared)\n", + 'other/page.js': "export default () => 'Public two'\n", + 'other/client.js': "import { shared } from '../shared.js'; console.log(shared)\n", + 'admin/page.js': "export default () => 'Admin one'\n", + 'admin/client.js': "import { shared } from '../shared.js'; console.log(shared)\n", + 'admin/other/page.js': "export default () => 'Admin two'\n", + 'admin/other/client.js': "import { shared } from '../../shared.js'; console.log(shared)\n", + 'esbuild.settings.js': settings, + }).map(async ([relname, content]) => { + const filepath = join(src, relname) + await mkdir(dirname(filepath), { recursive: true }) + await writeFile(filepath, content) + })) + + t.after(() => rm(root, { recursive: true, force: true })) + return { root, src, dest } +} + +/** @param {BuildOptions['entryPoints']} entryPoints */ +function entryPointInputs (entryPoints) { + if (!entryPoints) return [] + if (Array.isArray(entryPoints)) { + return entryPoints.map(entry => typeof entry === 'string' ? entry : entry.in) + } + return Object.values(entryPoints) +} + +test('rejects unsupported root inputs without changing no-root options', () => { + for (const root of ['C:admin', 'C:../outside', 'c:', './C:admin']) { + assert.throws(() => normalizeBundleRoots([root]), /must be relative/) + } + for (const entryPoints of [['**/client.js'], [{ in: 'admin/*.js', out: 'app' }], { app: '**/client.js' }]) { + const options = { entryPoints } + assert.throws(() => createBundleBuilds(options, tmpdir(), ['admin']), /glob entryPoints/) + assert.equal(createBundleBuilds(options, tmpdir(), [])[0]?.buildOpts, options) + } + for (const entryPoints of [[], ['client.js']]) { + const options = { entryPoints, stdin: { contents: 'console.log(1)' } } + assert.throws(() => createBundleBuilds(options, tmpdir(), ['admin']), /stdin/) + assert.equal(createBundleBuilds(options, tmpdir(), [])[0]?.buildOpts, options) + } +}) + +test('normalizes and validates bundle roots', () => { + assert.deepEqual( + normalizeBundleRoots(['admin/reports/', 'members\\settings']), + ['admin/reports', 'members/settings'] + ) + assert.throws(() => normalizeBundleRoots('admin'), /must be an array/) + assert.throws(() => normalizeBundleRoots(['']), /non-empty string/) + assert.throws(() => normalizeBundleRoots(['/admin']), /must be relative/) + assert.throws(() => normalizeBundleRoots(['C:\\admin']), /must be relative/) + assert.throws(() => normalizeBundleRoots(['.']), /inside the source directory/) + assert.throws(() => normalizeBundleRoots(['../admin']), /inside the source directory/) + assert.throws(() => normalizeBundleRoots(['admin', 'admin/']), /duplicate paths/) +}) + +test('assigns entries to the deepest matching bundle root without prefix collisions', () => { + const src = join(tmpdir(), 'bundle-root-assignment', 'src') + const builds = createBundleBuilds({ + entryPoints: [ + join(src, 'client.js'), + join(src, 'admin', 'client.js'), + join(src, 'admin', 'reports', 'client.js'), + join(src, 'administrator', 'client.js'), + ], + chunkNames: 'chunks/[name]-[hash]', + }, src, ['admin', 'admin/reports']) + + assert.deepEqual(builds.map(build => build.bundleRoot), [null, 'admin', 'admin/reports']) + assert.deepEqual( + entryPointInputs(builds[0]?.buildOpts.entryPoints), + [join(src, 'client.js'), join(src, 'administrator', 'client.js')] + ) + assert.deepEqual(entryPointInputs(builds[1]?.buildOpts.entryPoints), [join(src, 'admin', 'client.js')]) + assert.deepEqual(entryPointInputs(builds[2]?.buildOpts.entryPoints), [join(src, 'admin', 'reports', 'client.js')]) + assert.equal(builds[1]?.buildOpts.chunkNames, 'admin/chunks/[name]-[hash]') + assert.equal(builds[2]?.buildOpts.assetNames, 'admin/reports/[name]-[hash]') +}) + +test('builds named roots in isolated graphs and merges their reports', async t => { + const { src, dest } = await createFixture(t, ` +export const bundleRoots = ['admin'] +export default options => options +`) + const domstack = new DomStack(src, dest) + const results = await domstack.build() + const builds = results.esbuildResults.report.builds ?? [] + + assert.deepEqual(builds.map(build => build.bundleRoot), [null, 'admin']) + assert.ok(entryPointInputs(builds[0]?.buildOpts.entryPoints).every(input => !input.startsWith(join(src, 'admin')))) + assert.ok(entryPointInputs(builds[1]?.buildOpts.entryPoints).every(input => input.startsWith(join(src, 'admin')))) + + const defaultOutputs = Object.keys(builds[0]?.buildResults.metafile?.outputs ?? {}) + const adminOutputs = Object.keys(builds[1]?.buildResults.metafile?.outputs ?? {}) + assert.ok(defaultOutputs.some(output => output.includes('/chunks/js/'))) + assert.ok(adminOutputs.some(output => output.includes('/admin/chunks/js/'))) + assert.ok(!defaultOutputs.some(output => output.includes('/admin/chunks/js/'))) + + const mergedMetafile = JSON.parse(await readFile(join(dest, 'domstack-esbuild-meta.json'), 'utf8')) + assert.deepEqual( + Object.keys(mergedMetafile.outputs).sort(), + [...defaultOutputs, ...adminOutputs].sort() + ) + assert.ok(Object.keys(results.esbuildResults.report.outputMap ?? {}).some(input => input === 'client.js')) + assert.ok(Object.keys(results.esbuildResults.report.outputMap ?? {}).some(input => input === 'admin/client.js')) + + const rootPage = results.siteData.pages.find(page => page.path === '') + const adminPage = results.siteData.pages.find(page => page.path === 'admin') + assert.match(rootPage?.clientBundle?.outputRelname ?? '', /^client-.+\.js$/) + assert.match(adminPage?.clientBundle?.outputRelname ?? '', /^admin\/client-.+\.js$/) +}) + +test('watch creates and disposes one browser context per bundle root group', async t => { + const { src, dest } = await createFixture(t, ` +import { appendFileSync } from 'node:fs' +export const bundleRoots = ['admin'] +export default options => ({ + ...options, + plugins: [{ + name: 'bundle-root-context-lifecycle', + setup (build) { + appendFileSync(import.meta.dirname + '/context-events.txt', 'setup\\n') + build.onDispose(() => appendFileSync(import.meta.dirname + '/context-events.txt', 'dispose\\n')) + }, + }], +}) +`) + const domstack = new DomStack(src, dest) + t.after(async () => { + if (domstack.watching) await domstack.stopWatching() + }) + + await domstack.watch({ serve: false }) + assert.equal(await readFile(join(src, 'context-events.txt'), 'utf8'), 'setup\nsetup\n') + await domstack.stopWatching() + const eventsPath = join(src, 'context-events.txt') + for (let i = 0; i < 100; i++) { + if ((await readFile(eventsPath, 'utf8')).endsWith('dispose\ndispose\n')) break + await new Promise(resolve => setTimeout(resolve, 10)) + } + assert.equal(await readFile(eventsPath, 'utf8'), 'setup\nsetup\ndispose\ndispose\n') +}) + +/** @param {() => boolean | Promise} predicate */ +async function until (predicate) { + for (let i = 0; i < 300; i++) { + if (await predicate()) return + await delay(10) + } + assert.fail('Timed out waiting for build event') +} + +/** + * Expose per-fixture plugin controls without global state or esbuild mocks. + * @param {TestContext} t + * @param {string} [extra] + */ +async function controlledFixture (t, extra = '') { + const settings = `export const bundleRoots = ['admin'] +export let plugin +export function setPlugin(value) { plugin = value } +export default options => ({ ...options, ${extra} plugins: plugin ? [plugin] : [] }) +` + const fixture = await createFixture(t, settings) + await mkdir(fixture.dest) + const url = pathToFileURL(join(fixture.src, 'esbuild.settings.js')) + + const controls = await import(url.href) + const siteData = await identifyPages(fixture.src) + return { ...fixture, siteData, controls } +} + +test('explicit entries retain ownership across dynamic-import copies in production and watch', async t => { + const { src, dest, siteData } = await controlledFixture(t) + await writeFile(join(src, 'admin/client.js'), 'import("../client.js").then(console.log)') + const production = await buildEsbuild(src, dest, siteData, {}) + assert.deepEqual(production.errors, []) + const groups = production.report.builds ?? [] + assert.match(groups[1]?.outputMap['client.js'] ?? '', /^admin\/chunks\//) + assert.equal(production.report.outputMap?.['client.js'], groups[0]?.outputMap['client.js']) + assert.match(siteData.pages.find(page => page.path === '')?.clientBundle?.outputRelname ?? '', /^client-/) + const watch = await buildEsbuildWatch(src, dest, siteData, {}) + try { + assert.equal(watch.outputMap['client.js'], 'client.js') + } finally { + await watch.context.dispose() + } +}) + +test('aggregate preserves outputFiles, omits absent metafiles and keeps mangle caches per group', async t => { + const { src, dest, siteData } = await controlledFixture(t, 'write: false, metafile: false, mangleCache: {}, mangleProps: /_$/,') + await writeFile(join(src, 'client.js'), 'console.log({ public_: 1 }.public_)') + await writeFile(join(src, 'admin/client.js'), 'console.log({ admin_: 1 }.admin_)') + const result = await buildEsbuild(src, dest, siteData, {}) + assert.deepEqual(result.errors, []) + const groups = result.report.builds ?? [] + assert.equal(groups.length, 2) + assert.deepEqual(result.report.buildResults?.outputFiles, groups.flatMap(group => group.buildResults.outputFiles ?? [])) + assert.ok(result.report.buildResults?.outputFiles?.length) + assert.equal(result.report.buildResults?.metafile, undefined) + assert.equal(result.report.buildResults?.mangleCache, undefined) + assert.ok(groups.every(group => group.buildResults.mangleCache)) + await assert.rejects(readFile(join(dest, 'domstack-esbuild-meta.json')), { code: 'ENOENT' }) +}) + +test('no-root stdin builds keep exact esbuild results including mangleCache', async t => { + const { src, dest } = await createFixture(t, `export default options => ({ ...options, + entryPoints: [], stdin: { contents: 'console.log({ value_: 1 }.value_)' }, + write: false, mangleProps: /_$/, mangleCache: {}, + })`) + await mkdir(dest) + const result = await buildEsbuild(src, dest, await identifyPages(src), {}) + assert.deepEqual(result.errors, []) + assert.equal(result.report.buildResults, result.report.builds?.[0]?.buildResults) + assert.ok(result.report.buildResults?.outputFiles?.length) + assert.ok(result.report.buildResults?.mangleCache) + assert.ok(result.report.buildResults?.metafile) +}) + +for (const form of ['advanced', 'record']) { + test(`explicit ownership works with ${form} entryPoints`, async t => { + const { src, dest } = await createFixture(t, `export const bundleRoots = ['admin'] +export default options => { + const entries = [ + { in: options.outbase + '/client.js', out: 'public-alias' }, + { in: options.outbase + '/admin/client.js', out: 'admin-alias' }, + ] + return { ...options, entryPoints: ${form === 'advanced' ? 'entries' : 'Object.fromEntries(entries.map(entry => [entry.out, entry.in]))'} } +}`) + await mkdir(dest) + await writeFile(join(src, 'admin/client.js'), 'import("../client.js").then(console.log)') + const result = await buildEsbuild(src, dest, await identifyPages(src), {}) + assert.deepEqual(result.errors, []) + assert.match(result.report.outputMap?.['client.js'] ?? '', /^public-alias-/) + assert.match(result.report.outputMap?.['admin/client.js'] ?? '', /^admin-alias-/) + }) +} + +test('production drains other groups before returning a failure', { timeout: 10000 }, async t => { + const { src, dest, siteData, controls } = await controlledFixture(t) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + let completed = false + controls.setPlugin({ + name: 'gated-failure', + setup (/** @type {import('esbuild').PluginBuild} */ build) { + const admin = entryPointInputs(build.initialOptions.entryPoints).some(input => input.includes(join('admin', 'client.js'))) + build.onStart(async () => { + if (admin) { + entered.resolve(undefined) + await release.promise + } else { + await entered.promise + return { errors: [{ text: 'intentional failure' }] } + } + }) + if (admin) build.onEnd(() => { completed = true }) + }, + }) + let returned = false + const pending = buildEsbuild(src, dest, siteData, {}).then(result => { returned = true; return result }) + try { + await entered.promise + await delay(100) + assert.equal(returned, false) + } finally { + release.resolve(undefined) + } + const result = await pending + assert.equal(result.errors.length, 1) + assert.equal(completed, true) +}) + +test('watch startup never publishes partial metadata and rebuilds retain every group', { timeout: 15000 }, async t => { + const { src, dest, siteData, controls } = await controlledFixture(t) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + let publicBuilds = 0 + let rebuilds = 0 + controls.setPlugin({ + name: 'gated-startup', + setup (/** @type {import('esbuild').PluginBuild} */ build) { + const admin = entryPointInputs(build.initialOptions.entryPoints).some(input => input.includes(join('admin', 'client.js'))) + if (admin) build.onStart(async () => { entered.resolve(undefined); await release.promise }) + else build.onEnd(() => { publicBuilds++ }) + }, + }) + const starting = buildEsbuildWatch(src, dest, siteData, {}, { onEnd () { rebuilds++ } }) + try { + await entered.promise + await writeFile(join(src, 'client.js'), 'console.log("public changed during startup")') + await until(() => publicBuilds >= 2 && rebuilds >= 1) + await assert.rejects(readFile(join(dest, 'domstack-esbuild-meta.json')), { code: 'ENOENT' }) + } finally { + release.resolve(undefined) + } + const watch = await starting + try { + const metaPath = join(dest, 'domstack-esbuild-meta.json') + const initial = JSON.parse(await readFile(metaPath, 'utf8')) + const publicOutputs = Object.keys(initial.outputs).filter(path => !path.includes('/admin/')) + assert.ok(publicOutputs.length) + const previous = rebuilds + await writeFile(join(src, 'admin/client.js'), 'console.log("admin changed after startup")') + await until(() => rebuilds > previous) + const rebuilt = JSON.parse(await readFile(metaPath, 'utf8')) + for (const output of publicOutputs) assert.deepEqual(rebuilt.outputs[output], initial.outputs[output]) + assert.ok(Object.keys(rebuilt.outputs).some(path => path.endsWith('/admin/client.js'))) + } finally { + await watch.context.dispose() + } +}) + +test('failed later watch startup disposes all acquired contexts', { timeout: 10000 }, async t => { + const { src, dest, siteData, controls } = await controlledFixture(t) + let setups = 0 + let disposals = 0 + controls.setPlugin({ + name: 'failed-startup', + setup (/** @type {import('esbuild').PluginBuild} */ build) { + setups++ + build.onDispose(() => { disposals++ }) + if (entryPointInputs(build.initialOptions.entryPoints).some(input => input.includes(join('admin', 'client.js')))) { + build.onStart(() => ({ errors: [{ text: 'intentional startup failure' }] })) + } + }, + }) + await assert.rejects(buildEsbuildWatch(src, dest, siteData, {}), /build failed/) + await until(() => disposals === 2) + assert.equal(setups, 2) + await assert.rejects(readFile(join(dest, 'domstack-esbuild-meta.json')), { code: 'ENOENT' }) +}) + +test('settings changes reload bundleRoots and reuse the module for identical contents', async t => { + const first = "import { appendFileSync } from 'node:fs'; appendFileSync(import.meta.dirname + '/loads.txt', 'load\\n'); export const bundleRoots = ['admin']; export const calls = []; export default o => { calls.push(o); return o }" + const second = first.replace("['admin']", "['other']") + const { src, dest } = await createFixture(t, first) + await mkdir(dest) + const settingsDir = join(src, 'settings #') + await mkdir(settingsDir) + await rm(join(src, 'esbuild.settings.js')) + const path = join(settingsDir, 'esbuild.settings.js') + await writeFile(path, first) + const siteData = await identifyPages(src) + const original = await import(pathToFileURL(path).href) + let originalCalls = 0 + for (const [contents, expected] of /** @type {const} */ ([[first, 'admin'], [second, 'other'], [second, 'other'], [first, 'admin']])) { + await writeFile(path, contents) + const watch = await buildEsbuildWatch(src, dest, siteData, {}) + try { + const metafile = JSON.stringify(watch.buildResults.metafile) + assert.equal(metafile.includes('/admin/chunks/js/'), expected === 'admin') + if (contents === first) originalCalls++ + assert.equal(original.calls.length, originalCalls, 'initial and reverted content share the ordinary import state') + } finally { + await watch.context.dispose() + } + } + const result = await buildEsbuild(src, dest, siteData, {}) + assert.deepEqual(result.errors, []) + assert.deepEqual(result.report.builds?.map(build => build.bundleRoot), [null, 'admin']) + assert.equal(original.calls.length, originalCalls + 1, 'production reuses the same first-content module') + assert.equal(await import(pathToFileURL(path).href), original) + assert.equal(await readFile(join(settingsDir, 'loads.txt'), 'utf8'), 'load\nload\n') +}) + +test('CommonJS settings reload their named bundleRoots export', async t => { + const { src, dest } = await createFixture(t, '') + await mkdir(dest) + await rm(join(src, 'esbuild.settings.js')) + const settings = join(src, 'esbuild.settings.cjs') + let original + let originalCalls = 0 + for (const root of ['admin', 'other', 'admin']) { + await writeFile(settings, `module.exports = options => { module.exports.calls.push(options); return options }; module.exports.calls = []; module.exports.bundleRoots = ['${root}']`) + original ??= await import(pathToFileURL(settings).href) + const result = await buildEsbuild(src, dest, await identifyPages(src), {}) + assert.deepEqual(result.errors, []) + assert.deepEqual(result.report.builds?.map(build => build.bundleRoot), [null, root]) + if (root === 'admin') originalCalls++ + assert.equal(original.default.calls.length, originalCalls) + } + assert.equal(original.default.calls.length, 2) +}) diff --git a/lib/build-esbuild/index.js b/lib/build-esbuild/index.js index 7dacd392..28558c9b 100644 --- a/lib/build-esbuild/index.js +++ b/lib/build-esbuild/index.js @@ -4,8 +4,11 @@ * @import { Logger as PinoLogger } from 'pino' */ -import { writeFile } from 'fs/promises' -import { join, relative, basename, resolve, extname } from 'path' +import { readFile, writeFile } from 'fs/promises' +import { createHash } from 'node:crypto' +import { pathToFileURL } from 'node:url' +import { createRequire } from 'node:module' +import { join, relative, basename, resolve, extname, posix, win32 } from 'path' import esbuild from 'esbuild' import { globalBundleAssets, pageBundleAssets, layoutBundleAssets } from '../file-conventions.js' import { resolveVars } from '../build-pages/resolve-vars.js' @@ -18,6 +21,9 @@ import { toPosix } from '../helpers/path.js' import { createDomStackLogger } from '../logger.js' const __dirname = import.meta.dirname +const require = createRequire(import.meta.url) +/** @type {Map>} */ +const settingsContentUrls = new Map() const DOM_STACK_DEFAULTS_PREFIX = 'domstack-defaults' const SERVICE_WORKER_OUTPUT_RELNAME = 'service-worker.js' @@ -28,10 +34,15 @@ const SERVICE_WORKER_OUTPUT_RELNAME = 'service-worker.js' * @typedef {esbuild.BuildOptions} EsbuildBuildOptions * @typedef {{ dispose: () => Promise }} DisposableBuildContext * @typedef {{ define?: Record, manifestVersion?: string }} ServiceWorkerBuildDefines + * @typedef {NonNullable} EsbuildEntryPoints + * @typedef {{ bundleRoot: string | null, buildResults: esbuild.BuildResult, buildOpts: EsbuildBuildOptions, outputMap: OutputMap }} EsbuildGroupReport + * @typedef {{ bundleRoot: string | null, buildOpts: EsbuildBuildOptions }} BrowserBuild + * @typedef {{ buildOpts: EsbuildBuildOptions, builds: BrowserBuild[] }} BrowserBuildConfiguration * @typedef {{ * buildResults?: esbuild.BuildResult, * buildOpts?: EsbuildBuildOptions, - * outputMap?: OutputMap + * outputMap?: OutputMap, + * builds?: EsbuildGroupReport[] * }} EsbuildReport * @typedef {BuildStep< @@ -168,9 +179,9 @@ function updateOutputFileInfo (outputMap, fileInfo) { * @param {SiteData} siteData * @param {DomStackOpts | null} opts * @param {{ watch?: boolean }} [modeOpts] - * @returns {Promise} + * @returns {Promise} */ -async function createBrowserBuildOpts (src, dest, siteData, opts, modeOpts = {}) { +async function createBrowserBuildConfiguration (src, dest, siteData, opts, modeOpts = {}) { const entryPoints = /** @type {(string | { in: string, out: string })[]} */ (globalBundleAssets(siteData).map(asset => join(src, asset.relname))) if (siteData.defaultLayout) { @@ -246,10 +257,17 @@ async function createBrowserBuildOpts (src, dest, siteData, opts, modeOpts = {}) } } - const esbuildSettingsExtends = siteData.esbuildSettings - ? (await import(siteData.esbuildSettings.filepath)).default + const esbuildSettings = siteData.esbuildSettings + ? await importSettings(siteData.esbuildSettings.filepath) + : null + const esbuildSettingsExtends = esbuildSettings + ? esbuildSettings.default : (/** @type {typeof buildOpts} */ esbuildOpts) => esbuildOpts + if (typeof esbuildSettingsExtends !== 'function') { + throw new TypeError('esbuild.settings must default-export a function.') + } + const extendedBuildOpts = await esbuildSettingsExtends(buildOpts) if (browserVars && Object.keys(browserVars).length > 0 && extendedBuildOpts.define !== buildOpts.define) { @@ -259,10 +277,190 @@ async function createBrowserBuildOpts (src, dest, siteData, opts, modeOpts = {}) ) } - return { + const effectiveBuildOpts = { ...extendedBuildOpts, define: preserveDomstackDefines(extendedBuildOpts.define, domstackDefines), } + const bundleRoots = normalizeBundleRoots(esbuildSettings?.bundleRoots) + + return { + buildOpts: effectiveBuildOpts, + builds: createBundleBuilds(effectiveBuildOpts, src, bundleRoots), + } +} + +/** @param {string} filepath */ +async function importSettings (filepath) { + const url = pathToFileURL(filepath) + const hash = createHash('sha256').update(await readFile(filepath)).digest('hex') + let contentUrls = settingsContentUrls.get(url.href) + if (!contentUrls) { + contentUrls = new Map() + settingsContentUrls.set(url.href, contentUrls) + } + let contentUrl = contentUrls.get(hash) + if (!contentUrl) { + // First load must share state with callers importing the ordinary file URL. + // Repeated/reverted contents reuse their original identity, not a new module. + if (contentUrls.size > 0) { + url.searchParams.set('domstack', hash) + // A new ESM URL alone does not invalidate Node's underlying CommonJS cache. + delete require.cache[resolve(filepath)] + } + contentUrl = url.href + contentUrls.set(hash, contentUrl) + } + return import(contentUrl) +} + +/** + * Normalize source-relative bundle roots into deterministic POSIX paths. + * + * @param {unknown} value + * @returns {string[]} + */ +export function normalizeBundleRoots (value) { + if (value === undefined) return [] + if (!Array.isArray(value)) throw new TypeError('bundleRoots must be an array of source-relative directory paths.') + + const roots = value.map((root, index) => { + if (typeof root !== 'string' || root.length === 0) { + throw new TypeError(`bundleRoots[${index}] must be a non-empty string.`) + } + + const portableRoot = root.replaceAll('\\', '/') + if (posix.isAbsolute(portableRoot) || win32.isAbsolute(root) || /^[a-z]:/i.test(portableRoot)) { + throw new TypeError(`bundle root "${root}" must be relative to the source directory.`) + } + + const normalized = posix.normalize(portableRoot).replace(/\/$/, '') + if (/^[a-z]:/i.test(normalized)) throw new TypeError(`bundle root "${root}" must be relative to the source directory.`) + if (normalized === '.' || normalized === '..' || normalized.startsWith('../')) { + throw new TypeError(`bundle root "${root}" must name a directory inside the source directory.`) + } + return normalized + }).sort() + + const uniqueRoots = new Set(roots) + if (uniqueRoots.size !== roots.length) throw new TypeError('bundleRoots must not contain duplicate paths.') + return roots +} + +/** + * Partition effective esbuild entry points into a default build and named roots. + * Entries in nested roots use the deepest matching root. + * + * @param {EsbuildBuildOptions} buildOpts + * @param {string} src + * @param {string[]} bundleRoots + * @returns {BrowserBuild[]} + */ +export function createBundleBuilds (buildOpts, src, bundleRoots) { + if (bundleRoots.length === 0) return [{ bundleRoot: null, buildOpts }] + + if (buildOpts.stdin !== undefined) throw new TypeError('bundleRoots does not support stdin. Use explicit entryPoints instead.') + const entryPoints = buildOpts.entryPoints ?? [] + if (entryInputs(entryPoints).some(input => input.includes('*'))) { + throw new TypeError('bundleRoots does not support glob entryPoints. Use explicit file paths instead.') + } + /** @type {Map} */ + const groups = new Map() + groups.set(null, createEmptyEntryPoints(entryPoints)) + for (const root of bundleRoots) groups.set(root, createEmptyEntryPoints(entryPoints)) + + if (Array.isArray(entryPoints)) { + for (const entryPoint of entryPoints) { + const input = typeof entryPoint === 'string' ? entryPoint : entryPoint.in + addEntryPoint(groups.get(findBundleRoot(input, buildOpts, src, bundleRoots)) ?? [], entryPoint) + } + } else { + for (const [out, input] of Object.entries(entryPoints)) { + const entries = groups.get(findBundleRoot(input, buildOpts, src, bundleRoots)) + if (entries && !Array.isArray(entries)) entries[out] = input + } + } + + return [null, ...bundleRoots].flatMap(bundleRoot => { + const groupedEntryPoints = groups.get(bundleRoot) + if (!groupedEntryPoints || entryPointCount(groupedEntryPoints) === 0) return [] + + const groupBuildOpts = { + ...buildOpts, + entryPoints: groupedEntryPoints, + ...(bundleRoot + ? { + chunkNames: prefixOutputTemplate(bundleRoot, buildOpts.chunkNames ?? 'chunks/[ext]/[name]-[hash]'), + assetNames: prefixOutputTemplate(bundleRoot, buildOpts.assetNames ?? '[name]-[hash]'), + } + : {}), + } + return [{ bundleRoot, buildOpts: groupBuildOpts }] + }) +} + +/** @param {esbuild.BuildOptions['entryPoints']} entryPoints */ +function entryInputs (entryPoints) { + return Array.isArray(entryPoints) + ? entryPoints.map(entry => typeof entry === 'string' ? entry : entry.in) + : Object.values(entryPoints ?? {}) +} + +/** + * @param {EsbuildEntryPoints} entryPoints + * @returns {EsbuildEntryPoints} + */ +function createEmptyEntryPoints (entryPoints) { + return Array.isArray(entryPoints) + ? /** @type {EsbuildEntryPoints} */ ([]) + : /** @type {EsbuildEntryPoints} */ ({}) +} + +/** + * @param {esbuild.BuildOptions['entryPoints']} entryPoints + * @param {string | { in: string, out: string }} entryPoint + */ +function addEntryPoint (entryPoints, entryPoint) { + if (Array.isArray(entryPoints)) entryPoints.push(entryPoint) +} + +/** @param {NonNullable} entryPoints */ +function entryPointCount (entryPoints) { + return Array.isArray(entryPoints) ? entryPoints.length : Object.keys(entryPoints).length +} + +/** + * @param {string} input + * @param {EsbuildBuildOptions} buildOpts + * @param {string} src + * @param {string[]} bundleRoots + * @returns {string | null} + */ +function findBundleRoot (input, buildOpts, src, bundleRoots) { + const inputPath = resolve(buildOpts.absWorkingDir ?? process.cwd(), input) + let match = null + for (const root of bundleRoots) { + const relativeInput = relative(resolve(src, root), inputPath) + const insideRoot = relativeInput === '' || ( + relativeInput !== '..' && + !relativeInput.startsWith('../') && + !relativeInput.startsWith('..\\') && + !win32.isAbsolute(relativeInput) + ) + if (insideRoot) { + if (match === null || root.split('/').length > match.split('/').length) match = root + } + } + return match +} + +/** + * Keep chunks and file-loader assets from independent contexts under their bundle root. + * + * @param {string} bundleRoot + * @param {string} template + */ +function prefixOutputTemplate (bundleRoot, template) { + return `${bundleRoot}/${template.replaceAll('\\', '/').replace(/^\/+/, '')}` } /** @@ -327,12 +525,24 @@ function emptyEsbuildReport () { */ export async function buildEsbuild (src, dest, siteData, opts) { try { - const extendedBuildOpts = await createBrowserBuildOpts(src, dest, siteData, opts, { watch: false }) - - const buildResults = await esbuild.build(extendedBuildOpts) + const configuration = await createBrowserBuildConfiguration(src, dest, siteData, opts, { watch: false }) + const settled = await Promise.allSettled(configuration.builds.map(async build => { + const buildResults = await esbuild.build(build.buildOpts) + return { + ...build, + buildResults, + outputMap: buildResults.metafile ? extractOutputMap(buildResults.metafile, src, dest) : {}, + } + })) + const failures = settled.filter(result => result.status === 'rejected').map(result => serializeEsbuildError(result.reason)) + if (failures.length === 1) throw failures[0] + if (failures.length) throw new AggregateError(failures, 'Bundle root builds failed') + const groupReports = settled.flatMap(result => result.status === 'fulfilled' ? [result.value] : []) + const buildResults = aggregateBuildResults(groupReports.map(report => report.buildResults)) await writeMetafile({ dest, result: buildResults, shouldWrite: opts?.metafile !== false }) - const outputMap = applyBuildOutputMap({ dest, result: buildResults, siteData, src }) + const outputMap = browserOutputMap(groupReports, src, dest) + updateSiteDataOutputPaths(outputMap, siteData) const outputs = createEsbuildOutputRecords({ src, dest, @@ -348,8 +558,9 @@ export async function buildEsbuild (src, dest, siteData, opts) { outputs, report: { buildResults, - buildOpts: extendedBuildOpts, + buildOpts: configuration.buildOpts, outputMap, + builds: groupReports, }, } } catch (err) { @@ -365,6 +576,58 @@ export async function buildEsbuild (src, dest, siteData, opts) { } } +/** + * Present independent esbuild builds as one compatibility result and metafile. + * Exact per-context results remain available in report.builds. Multi-build mangle + * caches are intentionally not merged: independent builds may choose conflicting + * renamings. Consumers must persist/reuse caches per group, not as a shared cache. + * + * @param {esbuild.BuildResult[]} results + * @returns {esbuild.BuildResult} + */ +function aggregateBuildResults (results) { + if (results.length === 1) return /** @type {esbuild.BuildResult} */ (results[0]) + + /** @type {esbuild.Metafile} */ + const metafile = { inputs: {}, outputs: {} } + for (const result of results) { + Object.assign(metafile.inputs, result.metafile?.inputs) + for (const [outputPath, outputMeta] of Object.entries(result.metafile?.outputs ?? {})) { + if (Object.hasOwn(metafile.outputs, outputPath)) { + throw new Error(`Bundle roots produced conflicting esbuild output "${outputPath}".`) + } + metafile.outputs[outputPath] = outputMeta + } + } + + return /** @type {esbuild.BuildResult} */ ({ + errors: results.flatMap(result => result.errors), + warnings: results.flatMap(result => result.warnings), + ...(results.some(result => result.metafile) ? { metafile } : {}), + ...(results.some(result => result.outputFiles) ? { outputFiles: results.flatMap(result => result.outputFiles ?? []) } : {}), + }) +} + +/** + * Preserve dynamic-import mappings, but explicit entries always use their owning + * build's output rather than a separately bundled copy in another graph. + * @param {{ buildOpts: EsbuildBuildOptions, buildResults: esbuild.BuildResult }[]} reports + * @param {string} src + * @param {string} dest + */ +function browserOutputMap (reports, src, dest) { + const maps = reports.map(report => report.buildResults.metafile ? extractOutputMap(report.buildResults.metafile, src, dest) : {}) + const outputMap = Object.assign({}, ...maps) + reports.forEach((report, index) => { + for (const input of entryInputs(report.buildOpts.entryPoints)) { + const key = toPosix(relative(src, resolve(report.buildOpts.absWorkingDir ?? process.cwd(), input))) + const output = maps[index]?.[key] + if (output) outputMap[key] = output + } + }) + return /** @type {OutputMap} */ (outputMap) +} + /** * Build the site service worker after the domstack manifest is finalized. * The service worker is deliberately omitted from the domstack manifest so the @@ -508,27 +771,64 @@ function createDomstackDefines ({ opts, siteData, watch }) { */ export async function buildEsbuildWatch (src, dest, siteData, opts, watchOpts = {}) { const logger = watchOpts.logger ?? opts.logger ?? createDomStackLogger() - const extendedBuildOpts = await createBrowserBuildOpts(src, dest, siteData, opts, { watch: true }) - const browserWatch = await createWatchBuild({ - buildOpts: extendedBuildOpts, - dest, - label: 'JS/CSS', - logger, - onEnd: watchOpts.onEnd, - shouldWriteMetafile: opts?.metafile !== false, + const configuration = await createBrowserBuildConfiguration(src, dest, siteData, opts, { watch: true }) + /** @type {esbuild.BuildContext[]} */ + const contexts = [] + /** @type {Map} */ + const latestResults = new Map() + let rebuildProcessing = Promise.resolve() + let initialized = false + const reports = () => configuration.builds.flatMap(build => { + const buildResults = latestResults.get(build) + return buildResults ? [{ ...build, buildResults }] : [] }) - const initialResult = browserWatch.initialResult + const processRebuild = (/** @type {BrowserBuild} */ build, /** @type {esbuild.BuildResult} */ result) => { + const pending = rebuildProcessing.then(async () => { + if (result.errors.length === 0) { + latestResults.set(build, result) + if (initialized) { + const aggregate = aggregateBuildResults(reports().map(report => report.buildResults)) + await writeMetafile({ dest, result: aggregate, shouldWrite: opts?.metafile !== false }) + } + } + await watchOpts.onEnd?.(result) + }) + rebuildProcessing = pending.catch(() => {}) + return pending + } - /** @type {esbuild.BuildContext[]} */ - const contexts = [browserWatch.context] try { - const outputMap = applyBuildOutputMap({ dest, result: initialResult, siteData, src }) + for (const build of configuration.builds) { + const label = build.bundleRoot ? `JS/CSS (${build.bundleRoot})` : 'JS/CSS' + const browserWatch = await createWatchBuild({ + buildOpts: build.buildOpts, + dest, + label, + logger, + onEnd: result => processRebuild(build, result), + shouldWriteMetafile: false, + }) + contexts.push(browserWatch.context) + latestResults.set(build, browserWatch.initialResult) + } + + const initialization = rebuildProcessing.then(async () => { + const currentReports = reports() + const initialResult = aggregateBuildResults(currentReports.map(report => report.buildResults)) + await writeMetafile({ dest, result: initialResult, shouldWrite: opts?.metafile !== false }) + initialized = true + const outputMap = browserOutputMap(currentReports, src, dest) + updateSiteDataOutputPaths(outputMap, siteData) + return { initialResult, outputMap } + }) + rebuildProcessing = initialization.then(() => {}, () => {}) + const { initialResult, outputMap } = await initialization if (siteData.serviceWorker) { // Keep service-worker-only defines and no-policy watch cleanup behavior out of browser bundles. const serviceWorkerBuildOpts = createServiceWorkerBuildOpts({ - buildOpts: extendedBuildOpts, + buildOpts: configuration.buildOpts, defines: {}, serviceWorker: siteData.serviceWorker, src, @@ -550,13 +850,14 @@ export async function buildEsbuildWatch (src, dest, siteData, opts, watchOpts = } return { - context: createDisposableBuildContext(contexts), + context: createDisposableBuildContext(contexts, () => rebuildProcessing), outputMap, buildResults: initialResult, - buildOpts: extendedBuildOpts, + buildOpts: configuration.buildOpts, } } catch (error) { const cleanup = await Promise.allSettled(contexts.map(context => context.dispose())) + await rebuildProcessing const failures = cleanup.filter(result => result.status === 'rejected').map(result => result.reason) if (failures.length) throw new AggregateError([error, ...failures], 'Esbuild watch startup and cleanup failed') throw error @@ -635,12 +936,14 @@ async function createWatchBuild ({ buildOpts, dest, label, logger, onEnd, should /** * @param {esbuild.BuildContext[]} contexts + * @param {() => Promise} [drain] * @returns {DisposableBuildContext} */ -function createDisposableBuildContext (contexts) { +function createDisposableBuildContext (contexts, drain) { return { async dispose () { const results = await Promise.allSettled(contexts.map(context => context.dispose())) + results.push(...await Promise.allSettled([Promise.resolve().then(() => drain?.())])) const errors = results.filter(result => result.status === 'rejected').map(result => result.reason) if (errors.length) throw new AggregateError(errors, 'Esbuild watch cleanup failed') }, From 72312cf755ca09551f7fa38275b5d5adb6f8a53b Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sun, 13 Sep 2026 15:36:49 -0700 Subject: [PATCH 2/4] Harden bundle root errors, collisions, and settings reloads --- docs/settings/README.md | 7 ++- lib/build-esbuild/bundle-roots.test.js | 43 +++++++++++++++++-- lib/build-esbuild/import-settings.js | 40 +++++++++++++++++ lib/build-esbuild/import-settings.test.js | 36 ++++++++++++++++ lib/build-esbuild/index.js | 52 +++++++++-------------- lib/build-esbuild/serialize-error.test.js | 16 +++++++ 6 files changed, 158 insertions(+), 36 deletions(-) create mode 100644 lib/build-esbuild/import-settings.js create mode 100644 lib/build-esbuild/import-settings.test.js diff --git a/docs/settings/README.md b/docs/settings/README.md index d66e16d1..12508cbf 100644 --- a/docs/settings/README.md +++ b/docs/settings/README.md @@ -154,7 +154,12 @@ A settings plugin is configured on every resulting context, so plugins with shar Dependencies imported across roots are bundled independently by design, which trades some duplicate output and build work for isolation. Bundle roots control code splitting, not access permissions or import boundaries; explicitly shared global or layout bundles can still be loaded by pages in multiple roots. Changes to the settings file itself are reloaded when watch mode restarts its builds, but changes to that file's imported dependencies are not independently reloaded. -Conflicting outputs from custom naming settings fail the build, but output writes are not transactional, so a failed build can leave partial output in the destination. +Node retains imported ESM modules for the process lifetime, so DOMStack allows at most 256 distinct settings module versions across all settings paths in one process, including failed imports. +Identical or reverted contents reuse their previous module identity without consuming another version. +After reaching this limit, loading new settings contents fails with an instruction to restart the DOMStack process; stopping and starting watch contexts does not reset the limit. +This limits DOMStack-created settings module identities, not total memory usage or Node's ESM cache, and imported dependencies still require a process restart to refresh. +Conflicting outputs from custom naming settings fail the build when reported through metafiles or in-memory `outputFiles`, but output writes are not transactional, so a failed build can leave partial output in the destination. +With both `write: true` and `metafile: false`, esbuild exposes neither output list, so DOMStack cannot detect collisions; retain metafiles when using custom output names. ### Customizing build options diff --git a/lib/build-esbuild/bundle-roots.test.js b/lib/build-esbuild/bundle-roots.test.js index 2edb8dfb..fe1eefce 100644 --- a/lib/build-esbuild/bundle-roots.test.js +++ b/lib/build-esbuild/bundle-roots.test.js @@ -1,6 +1,6 @@ /** * @import { TestContext } from 'node:test' - * @import { BuildOptions } from 'esbuild' + * @import { BuildOptions, PluginBuild } from 'esbuild' */ import assert from 'node:assert/strict' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -234,6 +234,41 @@ test('aggregate preserves outputFiles, omits absent metafiles and keeps mangle c await assert.rejects(readFile(join(dest, 'domstack-esbuild-meta.json')), { code: 'ENOENT' }) }) +for (const metafile of [false, true]) { + test(`rejects outputFiles collisions with metafile: ${metafile} in production and watch`, async t => { + const { src, dest, siteData } = await controlledFixture(t, `write: false, metafile: ${metafile}, entryNames: '[name]', entryPoints: [options.outbase + '/client.js', options.outbase + '/admin/client.js'],`) + await writeFile(join(src, 'client.js'), 'console.log("public")') + await writeFile(join(src, 'admin/client.js'), 'console.log("admin")') + const result = await buildEsbuild(src, dest, siteData, {}) + assert.equal(result.errors.length, 1) + const error = result.errors[0] + assert.ok(error instanceof Error) + assert.ok(error.cause instanceof Error) + assert.match(error.cause.message, /conflicting esbuild output .*client\.js/) + await assert.rejects(buildEsbuildWatch(src, dest, siteData, {}), /conflicting esbuild output .*client\.js/) + }) +} + +test('multiple production group failures return all diagnostics instead of rejecting', async t => { + const { src, dest, siteData } = await controlledFixture(t) + await writeFile(join(src, 'client.js'), 'public syntax error !!!') + await writeFile(join(src, 'admin/client.js'), 'admin syntax error !!!') + const result = await buildEsbuild(src, dest, siteData, {}) + assert.equal(result.errors.length, 1) + const error = result.errors[0] + assert.ok(error instanceof Error) + assert.ok(error.cause instanceof AggregateError) + assert.equal(error.cause.errors.length, 2) + for (const failure of error.cause.errors) { + assert.ok(failure instanceof Error) + const diagnostics = JSON.parse(JSON.stringify(failure)) + assert.equal(diagnostics.errors.length, 1) + assert.ok(diagnostics.errors[0].location.file.endsWith('client.js')) + assert.ok(Array.isArray(diagnostics.errors[0].notes)) + } + assert.deepEqual(result.outputs, []) +}) + test('no-root stdin builds keep exact esbuild results including mangleCache', async t => { const { src, dest } = await createFixture(t, `export default options => ({ ...options, entryPoints: [], stdin: { contents: 'console.log({ value_: 1 }.value_)' }, @@ -274,7 +309,7 @@ test('production drains other groups before returning a failure', { timeout: 100 let completed = false controls.setPlugin({ name: 'gated-failure', - setup (/** @type {import('esbuild').PluginBuild} */ build) { + setup (/** @type {PluginBuild} */ build) { const admin = entryPointInputs(build.initialOptions.entryPoints).some(input => input.includes(join('admin', 'client.js'))) build.onStart(async () => { if (admin) { @@ -310,7 +345,7 @@ test('watch startup never publishes partial metadata and rebuilds retain every g let rebuilds = 0 controls.setPlugin({ name: 'gated-startup', - setup (/** @type {import('esbuild').PluginBuild} */ build) { + setup (/** @type {PluginBuild} */ build) { const admin = entryPointInputs(build.initialOptions.entryPoints).some(input => input.includes(join('admin', 'client.js'))) if (admin) build.onStart(async () => { entered.resolve(undefined); await release.promise }) else build.onEnd(() => { publicBuilds++ }) @@ -348,7 +383,7 @@ test('failed later watch startup disposes all acquired contexts', { timeout: 100 let disposals = 0 controls.setPlugin({ name: 'failed-startup', - setup (/** @type {import('esbuild').PluginBuild} */ build) { + setup (/** @type {PluginBuild} */ build) { setups++ build.onDispose(() => { disposals++ }) if (entryPointInputs(build.initialOptions.entryPoints).some(input => input.includes(join('admin', 'client.js')))) { diff --git a/lib/build-esbuild/import-settings.js b/lib/build-esbuild/import-settings.js new file mode 100644 index 00000000..f2401e3a --- /dev/null +++ b/lib/build-esbuild/import-settings.js @@ -0,0 +1,40 @@ +import { readFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { pathToFileURL } from 'node:url' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' + +const require = createRequire(import.meta.url) +/** @type {Map>} */ +const settingsContentUrls = new Map() +export const MAX_SETTINGS_MODULE_VERSIONS = 256 +let settingsModuleVersions = 0 + +/** @param {string} filepath */ +export async function importSettings (filepath) { + const url = pathToFileURL(filepath) + const hash = createHash('sha256').update(await readFile(filepath)).digest('hex') + let contentUrls = settingsContentUrls.get(url.href) + let contentUrl = contentUrls?.get(hash) + if (!contentUrl) { + // Node cannot evict ESM modules. Bound identities across all settings paths, + // including failed imports, rather than pretending map eviction frees them. + if (settingsModuleVersions >= MAX_SETTINGS_MODULE_VERSIONS) { + throw new Error(`Cannot load a new version of esbuild settings "${filepath}": the process has reached the limit of ${MAX_SETTINGS_MODULE_VERSIONS} settings module versions. Restart the DOMStack process (not just its watch contexts) to load further settings changes.`) + } + if (!contentUrls) { + contentUrls = new Map() + settingsContentUrls.set(url.href, contentUrls) + } + // First load shares state with ordinary imports; reverted contents reuse it. + if (contentUrls.size > 0) { + url.searchParams.set('domstack', hash) + // A new ESM URL alone does not invalidate Node's underlying CommonJS cache. + delete require.cache[resolve(filepath)] + } + contentUrl = url.href + contentUrls.set(hash, contentUrl) + settingsModuleVersions++ + } + return import(contentUrl) +} diff --git a/lib/build-esbuild/import-settings.test.js b/lib/build-esbuild/import-settings.test.js new file mode 100644 index 00000000..adf87a93 --- /dev/null +++ b/lib/build-esbuild/import-settings.test.js @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { importSettings, MAX_SETTINGS_MODULE_VERSIONS } from './import-settings.js' + +// This file runs in its own test process so exhausting the real process-wide +// budget cannot affect the build fixtures in other test files. +test('settings version budget includes failures and all paths but permits cached contents', async t => { + const root = await mkdtemp(join(tmpdir(), 'domstack-settings-budget-')) + t.after(() => rm(root, { recursive: true, force: true })) + const path = join(root, 'esbuild.settings.mjs') + const otherPath = join(root, 'other.settings.mjs') + await writeFile(path, 'export default 0') + const first = await importSettings(path) + assert.equal(await importSettings(path), first) + + await writeFile(otherPath, 'throw new Error("intentional settings failure")') + await assert.rejects(importSettings(otherPath), /intentional settings failure/) + for (let i = 2; i < MAX_SETTINGS_MODULE_VERSIONS; i++) { + await writeFile(path, `export default ${i}`) + const [a, b] = await Promise.all([importSettings(path), importSettings(path)]) + assert.equal(a.default, i) + assert.equal(a, b) + } + + await writeFile(path, 'export default "over limit"') + await assert.rejects(importSettings(path), /Restart the DOMStack process/) + const newPath = join(root, 'new.settings.mjs') + await writeFile(newPath, 'export default "new path over limit"') + await assert.rejects(importSettings(newPath), /Restart the DOMStack process/) + await assert.rejects(importSettings(otherPath), /intentional settings failure/) + await writeFile(path, 'export default 0') + assert.equal(await importSettings(path), first) +}) diff --git a/lib/build-esbuild/index.js b/lib/build-esbuild/index.js index 28558c9b..295d937d 100644 --- a/lib/build-esbuild/index.js +++ b/lib/build-esbuild/index.js @@ -4,10 +4,8 @@ * @import { Logger as PinoLogger } from 'pino' */ -import { readFile, writeFile } from 'fs/promises' -import { createHash } from 'node:crypto' -import { pathToFileURL } from 'node:url' -import { createRequire } from 'node:module' +import { writeFile } from 'fs/promises' +import { importSettings } from './import-settings.js' import { join, relative, basename, resolve, extname, posix, win32 } from 'path' import esbuild from 'esbuild' import { globalBundleAssets, pageBundleAssets, layoutBundleAssets } from '../file-conventions.js' @@ -21,9 +19,7 @@ import { toPosix } from '../helpers/path.js' import { createDomStackLogger } from '../logger.js' const __dirname = import.meta.dirname -const require = createRequire(import.meta.url) -/** @type {Map>} */ -const settingsContentUrls = new Map() + const DOM_STACK_DEFAULTS_PREFIX = 'domstack-defaults' const SERVICE_WORKER_OUTPUT_RELNAME = 'service-worker.js' @@ -87,6 +83,16 @@ function serializeEsbuildMessage (message) { export function serializeEsbuildError (value) { if (!(value instanceof Error)) return new Error(String(value)) + if (value instanceof AggregateError) { + const serialized = new AggregateError(value.errors.map(serializeEsbuildError), value.message, + Object.hasOwn(value, 'cause') ? { cause: value.cause } : undefined) + serialized.name = value.name + if (value.stack) serialized.stack = value.stack + // Keep nested failures visible to structured loggers as well as inspection. + Object.defineProperty(serialized, 'errors', { enumerable: true }) + return serialized + } + const failure = /** @type {Error & { errors?: esbuild.Message[], warnings?: esbuild.Message[] }} */ (value) if (!Array.isArray(failure.errors) && !Array.isArray(failure.warnings)) return value @@ -289,30 +295,6 @@ async function createBrowserBuildConfiguration (src, dest, siteData, opts, modeO } } -/** @param {string} filepath */ -async function importSettings (filepath) { - const url = pathToFileURL(filepath) - const hash = createHash('sha256').update(await readFile(filepath)).digest('hex') - let contentUrls = settingsContentUrls.get(url.href) - if (!contentUrls) { - contentUrls = new Map() - settingsContentUrls.set(url.href, contentUrls) - } - let contentUrl = contentUrls.get(hash) - if (!contentUrl) { - // First load must share state with callers importing the ordinary file URL. - // Repeated/reverted contents reuse their original identity, not a new module. - if (contentUrls.size > 0) { - url.searchParams.set('domstack', hash) - // A new ESM URL alone does not invalidate Node's underlying CommonJS cache. - delete require.cache[resolve(filepath)] - } - contentUrl = url.href - contentUrls.set(hash, contentUrl) - } - return import(contentUrl) -} - /** * Normalize source-relative bundle roots into deterministic POSIX paths. * @@ -590,7 +572,15 @@ function aggregateBuildResults (results) { /** @type {esbuild.Metafile} */ const metafile = { inputs: {}, outputs: {} } + const outputFilePaths = new Set() for (const result of results) { + for (const file of result.outputFiles ?? []) { + const outputPath = resolve(file.path) + if (outputFilePaths.has(outputPath)) { + throw new Error(`Bundle roots produced conflicting esbuild output "${file.path}".`) + } + outputFilePaths.add(outputPath) + } Object.assign(metafile.inputs, result.metafile?.inputs) for (const [outputPath, outputMeta] of Object.entries(result.metafile?.outputs ?? {})) { if (Object.hasOwn(metafile.outputs, outputPath)) { diff --git a/lib/build-esbuild/serialize-error.test.js b/lib/build-esbuild/serialize-error.test.js index d16fa440..a082a6ed 100644 --- a/lib/build-esbuild/serialize-error.test.js +++ b/lib/build-esbuild/serialize-error.test.js @@ -20,6 +20,22 @@ const diagnostic = { detail: undefined, } +test('serializeEsbuildError preserves nested aggregate failures and metadata', () => { + const failure = Object.assign(new Error('Build failed'), { errors: [diagnostic], warnings: [] }) + const cause = new Error('original cause') + const aggregate = new AggregateError([new AggregateError([failure], 'nested'), new Error('cleanup'), 'non-error'], 'multiple failures', { cause }) + const serialized = serializeEsbuildError(aggregate) + assert.ok(serialized instanceof AggregateError) + assert.equal(serialized.message, aggregate.message) + assert.equal(serialized.stack, aggregate.stack) + assert.equal(serialized.cause, cause) + assert.ok(serialized.errors[0] instanceof AggregateError) + assert.deepEqual(serialized.errors[0].errors[0].errors, [diagnostic]) + assert.equal(serialized.errors[1], aggregate.errors[1]) + assert.equal(serialized.errors[2].message, 'non-error') + assert.deepEqual(JSON.parse(JSON.stringify(serialized)).errors[0].errors[0].errors, [JSON.parse(JSON.stringify(diagnostic))]) +}) + test('serializeEsbuildError materializes diagnostic accessors as plain arrays', () => { const failure = new Error('Build failed with 1 error') Object.defineProperties(failure, { From ac62f1bb5115c5fc6d8f2f72e4c156f09dd69fe9 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sun, 13 Sep 2026 16:47:22 -0700 Subject: [PATCH 3/4] Keep settings reloads outside bundle roots scope --- docs/settings/README.md | 7 ++-- lib/build-esbuild/bundle-roots.test.js | 24 +++++++------- lib/build-esbuild/import-settings.js | 40 ----------------------- lib/build-esbuild/import-settings.test.js | 36 -------------------- lib/build-esbuild/index.js | 4 +-- 5 files changed, 17 insertions(+), 94 deletions(-) delete mode 100644 lib/build-esbuild/import-settings.js delete mode 100644 lib/build-esbuild/import-settings.test.js diff --git a/docs/settings/README.md b/docs/settings/README.md index 12508cbf..63351df5 100644 --- a/docs/settings/README.md +++ b/docs/settings/README.md @@ -153,11 +153,8 @@ Multiple builds do not expose a combined `mangleCache`, because independently ge A settings plugin is configured on every resulting context, so plugins with shared mutable state must support multiple `setup()` calls. Dependencies imported across roots are bundled independently by design, which trades some duplicate output and build work for isolation. Bundle roots control code splitting, not access permissions or import boundaries; explicitly shared global or layout bundles can still be loaded by pages in multiple roots. -Changes to the settings file itself are reloaded when watch mode restarts its builds, but changes to that file's imported dependencies are not independently reloaded. -Node retains imported ESM modules for the process lifetime, so DOMStack allows at most 256 distinct settings module versions across all settings paths in one process, including failed imports. -Identical or reverted contents reuse their previous module identity without consuming another version. -After reaching this limit, loading new settings contents fails with an instruction to restart the DOMStack process; stopping and starting watch contexts does not reset the limit. -This limits DOMStack-created settings module identities, not total memory usage or Node's ESM cache, and imported dependencies still require a process restart to refresh. +Restart the DOMStack process to load changes to `esbuild.settings.ts` (including `bundleRoots`) or its imported dependencies. +Restarting watch contexts alone does not reload the settings module or its dependencies. Conflicting outputs from custom naming settings fail the build when reported through metafiles or in-memory `outputFiles`, but output writes are not transactional, so a failed build can leave partial output in the destination. With both `write: true` and `metafile: false`, esbuild exposes neither output list, so DOMStack cannot detect collisions; retain metafiles when using custom output names. diff --git a/lib/build-esbuild/bundle-roots.test.js b/lib/build-esbuild/bundle-roots.test.js index fe1eefce..c8a6322f 100644 --- a/lib/build-esbuild/bundle-roots.test.js +++ b/lib/build-esbuild/bundle-roots.test.js @@ -397,7 +397,7 @@ test('failed later watch startup disposes all acquired contexts', { timeout: 100 await assert.rejects(readFile(join(dest, 'domstack-esbuild-meta.json')), { code: 'ENOENT' }) }) -test('settings changes reload bundleRoots and reuse the module for identical contents', async t => { +test('watch context restarts and production share the ordinary settings module despite file edits', async t => { const first = "import { appendFileSync } from 'node:fs'; appendFileSync(import.meta.dirname + '/loads.txt', 'load\\n'); export const bundleRoots = ['admin']; export const calls = []; export default o => { calls.push(o); return o }" const second = first.replace("['admin']", "['other']") const { src, dest } = await createFixture(t, first) @@ -410,14 +410,15 @@ test('settings changes reload bundleRoots and reuse the module for identical con const siteData = await identifyPages(src) const original = await import(pathToFileURL(path).href) let originalCalls = 0 - for (const [contents, expected] of /** @type {const} */ ([[first, 'admin'], [second, 'other'], [second, 'other'], [first, 'admin']])) { + for (const contents of [first, second, second, first]) { await writeFile(path, contents) const watch = await buildEsbuildWatch(src, dest, siteData, {}) try { const metafile = JSON.stringify(watch.buildResults.metafile) - assert.equal(metafile.includes('/admin/chunks/js/'), expected === 'admin') - if (contents === first) originalCalls++ - assert.equal(original.calls.length, originalCalls, 'initial and reverted content share the ordinary import state') + assert.ok(metafile.includes('/admin/chunks/js/')) + assert.ok(!metafile.includes('/other/chunks/js/')) + originalCalls++ + assert.equal(original.calls.length, originalCalls, 'watch contexts share the ordinary import state') } finally { await watch.context.dispose() } @@ -425,12 +426,12 @@ test('settings changes reload bundleRoots and reuse the module for identical con const result = await buildEsbuild(src, dest, siteData, {}) assert.deepEqual(result.errors, []) assert.deepEqual(result.report.builds?.map(build => build.bundleRoot), [null, 'admin']) - assert.equal(original.calls.length, originalCalls + 1, 'production reuses the same first-content module') + assert.equal(original.calls.length, originalCalls + 1, 'production shares the ordinary import state') assert.equal(await import(pathToFileURL(path).href), original) - assert.equal(await readFile(join(settingsDir, 'loads.txt'), 'utf8'), 'load\nload\n') + assert.equal(await readFile(join(settingsDir, 'loads.txt'), 'utf8'), 'load\n') }) -test('CommonJS settings reload their named bundleRoots export', async t => { +test('CommonJS settings retain their named bundleRoots export and ordinary module identity after edits', async t => { const { src, dest } = await createFixture(t, '') await mkdir(dest) await rm(join(src, 'esbuild.settings.js')) @@ -442,9 +443,10 @@ test('CommonJS settings reload their named bundleRoots export', async t => { original ??= await import(pathToFileURL(settings).href) const result = await buildEsbuild(src, dest, await identifyPages(src), {}) assert.deepEqual(result.errors, []) - assert.deepEqual(result.report.builds?.map(build => build.bundleRoot), [null, root]) - if (root === 'admin') originalCalls++ + assert.deepEqual(result.report.builds?.map(build => build.bundleRoot), [null, 'admin']) + originalCalls++ assert.equal(original.default.calls.length, originalCalls) } - assert.equal(original.default.calls.length, 2) + assert.equal(original.default.calls.length, 3) + assert.equal(await import(pathToFileURL(settings).href), original) }) diff --git a/lib/build-esbuild/import-settings.js b/lib/build-esbuild/import-settings.js deleted file mode 100644 index f2401e3a..00000000 --- a/lib/build-esbuild/import-settings.js +++ /dev/null @@ -1,40 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { createHash } from 'node:crypto' -import { pathToFileURL } from 'node:url' -import { createRequire } from 'node:module' -import { resolve } from 'node:path' - -const require = createRequire(import.meta.url) -/** @type {Map>} */ -const settingsContentUrls = new Map() -export const MAX_SETTINGS_MODULE_VERSIONS = 256 -let settingsModuleVersions = 0 - -/** @param {string} filepath */ -export async function importSettings (filepath) { - const url = pathToFileURL(filepath) - const hash = createHash('sha256').update(await readFile(filepath)).digest('hex') - let contentUrls = settingsContentUrls.get(url.href) - let contentUrl = contentUrls?.get(hash) - if (!contentUrl) { - // Node cannot evict ESM modules. Bound identities across all settings paths, - // including failed imports, rather than pretending map eviction frees them. - if (settingsModuleVersions >= MAX_SETTINGS_MODULE_VERSIONS) { - throw new Error(`Cannot load a new version of esbuild settings "${filepath}": the process has reached the limit of ${MAX_SETTINGS_MODULE_VERSIONS} settings module versions. Restart the DOMStack process (not just its watch contexts) to load further settings changes.`) - } - if (!contentUrls) { - contentUrls = new Map() - settingsContentUrls.set(url.href, contentUrls) - } - // First load shares state with ordinary imports; reverted contents reuse it. - if (contentUrls.size > 0) { - url.searchParams.set('domstack', hash) - // A new ESM URL alone does not invalidate Node's underlying CommonJS cache. - delete require.cache[resolve(filepath)] - } - contentUrl = url.href - contentUrls.set(hash, contentUrl) - settingsModuleVersions++ - } - return import(contentUrl) -} diff --git a/lib/build-esbuild/import-settings.test.js b/lib/build-esbuild/import-settings.test.js deleted file mode 100644 index adf87a93..00000000 --- a/lib/build-esbuild/import-settings.test.js +++ /dev/null @@ -1,36 +0,0 @@ -import assert from 'node:assert/strict' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import test from 'node:test' -import { importSettings, MAX_SETTINGS_MODULE_VERSIONS } from './import-settings.js' - -// This file runs in its own test process so exhausting the real process-wide -// budget cannot affect the build fixtures in other test files. -test('settings version budget includes failures and all paths but permits cached contents', async t => { - const root = await mkdtemp(join(tmpdir(), 'domstack-settings-budget-')) - t.after(() => rm(root, { recursive: true, force: true })) - const path = join(root, 'esbuild.settings.mjs') - const otherPath = join(root, 'other.settings.mjs') - await writeFile(path, 'export default 0') - const first = await importSettings(path) - assert.equal(await importSettings(path), first) - - await writeFile(otherPath, 'throw new Error("intentional settings failure")') - await assert.rejects(importSettings(otherPath), /intentional settings failure/) - for (let i = 2; i < MAX_SETTINGS_MODULE_VERSIONS; i++) { - await writeFile(path, `export default ${i}`) - const [a, b] = await Promise.all([importSettings(path), importSettings(path)]) - assert.equal(a.default, i) - assert.equal(a, b) - } - - await writeFile(path, 'export default "over limit"') - await assert.rejects(importSettings(path), /Restart the DOMStack process/) - const newPath = join(root, 'new.settings.mjs') - await writeFile(newPath, 'export default "new path over limit"') - await assert.rejects(importSettings(newPath), /Restart the DOMStack process/) - await assert.rejects(importSettings(otherPath), /intentional settings failure/) - await writeFile(path, 'export default 0') - assert.equal(await importSettings(path), first) -}) diff --git a/lib/build-esbuild/index.js b/lib/build-esbuild/index.js index 295d937d..43d994ab 100644 --- a/lib/build-esbuild/index.js +++ b/lib/build-esbuild/index.js @@ -5,7 +5,7 @@ */ import { writeFile } from 'fs/promises' -import { importSettings } from './import-settings.js' +import { pathToFileURL } from 'node:url' import { join, relative, basename, resolve, extname, posix, win32 } from 'path' import esbuild from 'esbuild' import { globalBundleAssets, pageBundleAssets, layoutBundleAssets } from '../file-conventions.js' @@ -264,7 +264,7 @@ async function createBrowserBuildConfiguration (src, dest, siteData, opts, modeO } const esbuildSettings = siteData.esbuildSettings - ? await importSettings(siteData.esbuildSettings.filepath) + ? await import(pathToFileURL(siteData.esbuildSettings.filepath).href) : null const esbuildSettingsExtends = esbuildSettings ? esbuildSettings.default From 539aff07fc6ad2a60db36baf0afbcd72195968c0 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sun, 13 Sep 2026 16:57:14 -0700 Subject: [PATCH 4/4] Warn when esbuild settings edits require a process restart --- docs/implementation/README.md | 7 +++- index.js | 1 + lib/watch-plan.js | 14 ++++++-- lib/watch-plan.test.js | 24 +++++++++++++ test-cases/watch-lifecycle/logging.test.js | 42 +++++++++++++++++++++- 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/docs/implementation/README.md b/docs/implementation/README.md index 0249ca49..55fad5eb 100644 --- a/docs/implementation/README.md +++ b/docs/implementation/README.md @@ -260,11 +260,16 @@ When a targeted build recomputes global data, DOMStack compares top-level values | A module imported by `*.pages.ts` | Generated outputs owned by the importing files, then refresh dependency maps | | `markdown-it.settings.ts` | All source-backed Markdown pages, plus subscribers of any changed global-data keys | | `global.data.ts` | Consumers subscribed to top-level keys whose values changed | -| `global.vars.ts` or `esbuild.settings.ts` | Full rebuild | +| `global.vars.ts` | Full rebuild | +| `esbuild.settings.ts` | Full rebuild with a warning to stop and restart DOMStack to apply settings edits | | `domstack-manifest.settings.ts` | No rebuild. The manifest pipeline is disabled in watch mode | | Existing client, style, Web Worker, or service-worker entry | esbuild only, unless the same module also has server-side consumers | | Static asset under `src` or a file under a `--copy` directory | cpx2 copies or removes the output directly | +Esbuild settings use ordinary Node.js module imports in the main DOMStack process. +Editing an existing `esbuild.settings.*` file triggers a warning because the full rebuild restarts esbuild contexts but does not clear the settings module cache; stop and restart DOMStack to apply changes, including `bundleRoots` changes. +Markdown settings do not require a process restart: each page build runs in a fresh worker, so edits to `markdown-it.settings.*` are loaded when the source-backed Markdown pages rebuild. + Adding or removing a file changes the set of discovered build inputs: | Added or removed file | Rebuild scope | diff --git a/index.js b/index.js index 492ae6c1..7eb4f221 100644 --- a/index.js +++ b/index.js @@ -479,6 +479,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) * @returns {Promise} */ async #executeWatchPlan (plan, event) { + if (plan.kind === 'full' && plan.warning) this.#logger.warn(plan.warning) if (plan.message) this.#logger.info(plan.message) if (plan.kind === 'skip') return if (plan.kind === 'full') { diff --git a/lib/watch-plan.js b/lib/watch-plan.js index 42207622..599634e2 100644 --- a/lib/watch-plan.js +++ b/lib/watch-plan.js @@ -23,10 +23,10 @@ * @property {string} [message] * @property {PageInfo[]} [pages] * @property {TemplateInfo[]} [templates] - * @typedef {PagePlan | {kind: 'skip', message: string} | {kind: 'full', message: string} | {kind: 'restart', message: string}} WatchPlan + * @typedef {PagePlan | {kind: 'skip', message: string} | {kind: 'full', message: string, warning?: string} | {kind: 'restart', message: string}} WatchPlan */ import { basename, dirname, relative } from 'node:path' -import { classifyFile } from './file-conventions.js' +import { classifyFile, esbuildSettingsNames } from './file-conventions.js' /** * @param {'change' | 'added' | 'removed'} type @@ -51,7 +51,15 @@ export function planWatchEvent (state, event) { ? { kind: 'restart', message: `"${name}" ${type}, restarting esbuild...` } : { kind: 'full', message: `"${name}" ${type}, triggering full rebuild...` } } - if (convention?.change === 'full') return { kind: 'full', message: `"${name}" changed, triggering full rebuild...` } + if (convention?.change === 'full') { + return { + kind: 'full', + message: `"${name}" changed, triggering full rebuild...`, + ...(esbuildSettingsNames.includes(name) + ? { warning: `"${name}" changed, but esbuild settings are cached in the DOMStack process. Stop and restart DOMStack to apply these changes; restarting esbuild alone does not reload the settings module.` } + : {}), + } + } if (convention?.change === 'manifest') { return { kind: 'skip', message: `"${name}" changed but domstack manifests are disabled in watch mode, skipping.` } } diff --git a/lib/watch-plan.test.js b/lib/watch-plan.test.js index ba5b8886..a65269aa 100644 --- a/lib/watch-plan.test.js +++ b/lib/watch-plan.test.js @@ -6,6 +6,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { basename, dirname, join } from 'node:path' import { classifyWatchEvent, planWatchEvent, planBundleChange } from './watch-plan.js' +import { esbuildSettingsNames, markdownItSettingsNames } from './file-conventions.js' const src = '/site' @@ -63,6 +64,29 @@ test('settings and untracked changes produce inspectable full, page, and skip pl } }) +test('only esbuild settings edits warn that a process restart is needed', () => { + const { state } = fixture() + for (const name of esbuildSettingsNames) { + for (const pageBuildFailed of [false, true]) { + const plan = planWatchEvent({ ...state, pageBuildFailed }, classifyWatchEvent('change', `/site/${name}`)) + assert.equal(plan.kind, 'full') + if (plan.kind !== 'full') throw new Error('Expected a full rebuild') + assert.ok(plan.warning?.includes(`"${name}"`)) + assert.match(plan.warning ?? '', /Stop and restart DOMStack/) + assert.match(plan.warning ?? '', /restarting esbuild alone does not reload/) + } + } + for (const name of [...markdownItSettingsNames, 'global.vars.js', 'domstack-manifest.settings.js']) { + const plan = planWatchEvent(state, classifyWatchEvent('change', `/site/${name}`)) + assert.ok(!('warning' in plan)) + } + for (const type of /** @type {const} */ (['added', 'removed'])) { + const plan = planWatchEvent(state, classifyWatchEvent(type, '/site/esbuild.settings.js')) + assert.equal(plan.kind, 'full') + assert.ok(!('warning' in plan), 'structural rediscovery remains unchanged') + } +}) + test('layout maps target source pages and generated-page owners', () => { const { state, home, owner } = fixture() for (const name of ['root.layout.js', 'layout-helper.js']) { diff --git a/test-cases/watch-lifecycle/logging.test.js b/test-cases/watch-lifecycle/logging.test.js index 78ab6ed0..74e03ed5 100644 --- a/test-cases/watch-lifecycle/logging.test.js +++ b/test-cases/watch-lifecycle/logging.test.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { setTimeout } from 'node:timers/promises' @@ -17,6 +17,46 @@ async function until (check) { } } +test('settings edits warn for cached esbuild settings but reload Markdown in fresh workers', { timeout: 20000 }, async t => { + const root = await mkdtemp(join(import.meta.dirname, 'logging-workspace-')) + const src = join(root, 'src') + const dest = join(root, 'dest') + await mkdir(src) + const settings = 'export default opts => opts\n' + await Promise.all(Object.entries({ + 'page.md': 'Original text', + 'root.layout.js': 'export default ({children}) => children', + 'global.vars.js': "export default { layout: 'root' }", + 'client.js': 'console.log("initial")', + 'esbuild.settings.js': settings, + 'markdown-it.settings.js': 'export default md => md', + }).map(([file, content]) => writeFile(join(src, file), content))) + /** @type {Array<{level: number, msg: string}>} */ + const records = [] + const logger = pino({ level: 'info' }, { write: chunk => records.push(JSON.parse(chunk)) }) + const site = new DomStack(src, dest, { logger }) + t.after(async () => { + if (site.watching) await site.stopWatching() + await rm(root, { recursive: true, force: true }) + }) + await site.watch({ serve: false }) + assert.match(await readFile(join(dest, 'index.html'), 'utf8'), /Original text/) + records.length = 0 + await writeFile(join(src, 'markdown-it.settings.js'), 'export default md => { md.renderer.rules.text = () => "Updated Markdown"; return md }') + let rendered = '' + for (let attempt = 0; !rendered.includes('Updated Markdown'); attempt++) { + assert.ok(attempt < 200, 'Markdown settings edit was not applied') + await setTimeout(25) + rendered = await readFile(join(dest, 'index.html'), 'utf8') + } + assert.ok(!records.some(record => record.msg.includes('Stop and restart DOMStack'))) + await writeFile(join(src, 'esbuild.settings.js'), `${settings}// edited\n`) + await until(() => records.some(record => record.level === 40 && record.msg.includes('Stop and restart DOMStack'))) + const warning = records.find(record => record.level === 40 && record.msg.includes('Stop and restart DOMStack')) + assert.match(warning?.msg ?? '', /esbuild.settings.js/) + assert.match(warning?.msg ?? '', /restarting esbuild alone does not reload/) +}) + for (const level of ['debug', 'silent']) { test(`watch builds once per context and respects the ${level} logger`, { timeout: 20000 }, async t => { const root = await mkdtemp(join(import.meta.dirname, 'logging-workspace-'))