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/docs/settings/README.md b/docs/settings/README.md index 0bce521a..63351df5 100644 --- a/docs/settings/README.md +++ b/docs/settings/README.md @@ -119,6 +119,47 @@ 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. +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. + +### 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/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/build-esbuild/bundle-roots.test.js b/lib/build-esbuild/bundle-roots.test.js new file mode 100644 index 00000000..c8a6322f --- /dev/null +++ b/lib/build-esbuild/bundle-roots.test.js @@ -0,0 +1,452 @@ +/** + * @import { TestContext } from 'node:test' + * @import { BuildOptions, PluginBuild } 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' }) +}) + +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_)' }, + 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 {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 {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 {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('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) + 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 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.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() + } + } + 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 shares the ordinary import state') + assert.equal(await import(pathToFileURL(path).href), original) + assert.equal(await readFile(join(settingsDir, 'loads.txt'), 'utf8'), 'load\n') +}) + +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')) + 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, 'admin']) + originalCalls++ + assert.equal(original.default.calls.length, originalCalls) + } + assert.equal(original.default.calls.length, 3) + assert.equal(await import(pathToFileURL(settings).href), original) +}) diff --git a/lib/build-esbuild/index.js b/lib/build-esbuild/index.js index 7dacd392..43d994ab 100644 --- a/lib/build-esbuild/index.js +++ b/lib/build-esbuild/index.js @@ -5,7 +5,8 @@ */ import { writeFile } from 'fs/promises' -import { join, relative, basename, resolve, extname } from 'path' +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' import { resolveVars } from '../build-pages/resolve-vars.js' @@ -18,6 +19,7 @@ import { toPosix } from '../helpers/path.js' import { createDomStackLogger } from '../logger.js' const __dirname = import.meta.dirname + const DOM_STACK_DEFAULTS_PREFIX = 'domstack-defaults' const SERVICE_WORKER_OUTPUT_RELNAME = 'service-worker.js' @@ -28,10 +30,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< @@ -76,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 @@ -168,9 +185,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 +263,17 @@ async function createBrowserBuildOpts (src, dest, siteData, opts, modeOpts = {}) } } - const esbuildSettingsExtends = siteData.esbuildSettings - ? (await import(siteData.esbuildSettings.filepath)).default + const esbuildSettings = siteData.esbuildSettings + ? await import(pathToFileURL(siteData.esbuildSettings.filepath).href) + : 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 +283,166 @@ 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), + } +} + +/** + * 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 +507,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 +540,9 @@ export async function buildEsbuild (src, dest, siteData, opts) { outputs, report: { buildResults, - buildOpts: extendedBuildOpts, + buildOpts: configuration.buildOpts, outputMap, + builds: groupReports, }, } } catch (err) { @@ -365,6 +558,66 @@ 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: {} } + 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)) { + 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 +761,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 +840,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 +926,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') }, 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, { 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-'))