Skip to content

Add bundle roots for independent esbuild graphs - #319

Open
bcomnes wants to merge 4 commits into
masterfrom
issue-261-bundle-roots
Open

bcomnes wants to merge 4 commits into
masterfrom
issue-261-bundle-roots

Conversation

@bcomnes

@bcomnes bcomnes commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Closes #261.

Summary

Allow sites to isolate sections such as admin applications into independent esbuild graphs, preventing their entry points from participating in code splitting with the public site.

Configure the bundleRoots named export in esbuild.settings.js or its supported variants with source-relative directories such as admin and account/internal.

Behavior

  • Use deepest-root-wins matching for nested roots, keep unclaimed entries in the default graph, and skip empty groups.
  • Preserve source-relative entry output paths while placing named-root chunks and file-loader assets beneath their root.
  • Keep the service worker in its existing separate, self-contained build.
  • Preserve combined output maps and metadata, and expose exact per-group results through report.builds.
  • Support independent watch contexts with serialized metadata updates and startup-failure cleanup.
  • Reject invalid roots, glob entry points, and stdin when bundle roots are configured.

The settings transform runs once before partitioning; plugins are set up for each resulting build/context. Dependencies imported across roots are bundled independently by design, trading some duplication for isolation. Bundle roots do not enforce import boundaries or access permissions.

Compatibility and limitations

Sites without configured roots retain single-graph behavior. Explicit entry outputs take precedence over cross-root dynamic-import copies when generating page asset references. Production failures wait for all started builds to finish before returning.

Independent mangle caches remain available in per-group reports rather than being merged. Settings use ordinary module imports, matching the pre-feature behavior. Changes to settings, including bundleRoots, or their imported dependencies require a DOMStack process restart; restarting watch contexts alone does not reload them. Output collision checks are not transactional, so failed builds can leave partial output.

Testing

  • Added unit and integration coverage for root validation, entry assignment, graph isolation, aggregate reports, watch rebuilds, failure cleanup, and settings-module compatibility.
  • Full Node test suite passed.
  • Project ESLint and TypeScript checks passed.
  • Git diff whitespace checks passed.
  • Browser tests were not run.

@coveralls

coveralls commented Sep 13, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34904434328

Coverage increased (+0.1%) to 95.007%

Details

  • Coverage increased (+0.1%) from the base build.
  • Patch coverage: 4 uncovered changes across 1 file (329 of 333 lines covered, 98.8%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
lib/build-esbuild/index.js 321 317 98.75%
Total (3 files) 333 329 98.8%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 9885
Covered Lines: 9521
Line Coverage: 96.32%
Relevant Branches: 2833
Covered Branches: 2562
Branch Coverage: 90.43%
Branches in Coverage %: Yes
Coverage Strength: 416.05 hits per line

💛 - Coveralls

@bcomnes
bcomnes requested a lite review from Copilot September 13, 2026 17:07
@bcomnes
bcomnes marked this pull request as ready for review September 13, 2026 17:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Three moderate implementation issues and one test-typing nit remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds independent esbuild bundle roots, isolating code-splitting graphs while supporting production builds, watch mode, validation, reporting, and documentation.

Changes:

  • Partitions entries by deepest matching bundle root.
  • Adds grouped build orchestration, metadata aggregation, cleanup, and settings reloads.
  • Adds comprehensive tests and configuration documentation.
File summaries
File Summary Review findings
lib/build-esbuild/index.js Implements bundle-root partitioning and grouped builds. Moderate: detect collisions in outputFiles when metafiles are disabled (2 votes). Moderate: serialize multiple build failures without a TypeError (2 votes). Moderate: prevent unbounded settings-module/cache growth during watch reloads (2 votes).
lib/build-esbuild/bundle-roots.test.js Tests validation, isolation, watch behavior, and reloads. Nit: use the file-level @import block and PluginBuild annotations in plugin tests (2 votes).
docs/settings/README.md Documents bundle-root configuration and limitations. No findings.
Review details

Suppressed comments (2)

lib/build-esbuild/bundle-roots.test.js:313

  • Please use the file-level @import block for PluginBuild and annotate this parameter as PluginBuild instead of using an inline import('esbuild') type, matching the repository's JSDoc type-import convention.
    setup (/** @type {import('esbuild').PluginBuild} */ build) {

lib/build-esbuild/bundle-roots.test.js:351

  • Please use the file-level @import block for PluginBuild and annotate this parameter as PluginBuild instead of using an inline import('esbuild') type, matching the repository's JSDoc type-import convention.
    setup (/** @type {import('esbuild').PluginBuild} */ build) {
  • Files reviewed: 3/3 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/build-esbuild/index.js Outdated
Comment on lines +295 to +311
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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in d100933. Node does not expose ESM module eviction, so removing entries from our map would not reclaim those modules. The extracted settings loader now enforces a process-wide budget of 256 distinct module versions across all settings paths, counting failed imports too. Cached and reverted contents reuse existing identities; new contents beyond the budget fail with an actionable process-restart message. This bounds DOMStack-created identities rather than claiming bounded total memory. Added limit/reuse/failure regression coverage and documented the tradeoff; imported dependencies still need a process restart. Full Node suite, project lint, and TypeScript pass. Leaving this thread unresolved for review.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following discussion with the maintainer, superseding the reload-budget solution: removed the hash-based settings loader, version tracking, and 256-version limit entirely. Settings now use an ordinary file-URL import, preserving the pre-feature module caching behavior. Changes to settings (including bundleRoots) or their dependencies require a DOMStack process restart; restarting esbuild contexts alone is not sufficient. Documented this explicitly and adjusted ESM/CommonJS identity tests. Proper worker-isolated reloads are outside this PR. Full Node suite, project lint, and TypeScript checks pass. Leaving the thread unresolved as requested.

Comment thread lib/build-esbuild/index.js
Comment thread lib/build-esbuild/index.js
Comment thread lib/build-esbuild/bundle-roots.test.js Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Three moderate issues remain in lib/build-esbuild/index.js: incomplete glob rejection, root-escaping name templates, and double error serialization.

Review details

Suppressed comments (3)

lib/build-esbuild/index.js:346

  • The glob guard only looks for *, so other glob patterns such as ?, character classes, or brace expansions are still treated as literal entry paths and partitioned incorrectly instead of being rejected as promised by the bundle-roots contract. Use a complete glob check (or expand globs before partitioning) so every supported glob form is rejected when roots are configured.
  if (entryInputs(entryPoints).some(input => input.includes('*'))) {
    throw new TypeError('bundleRoots does not support glob entryPoints. Use explicit file paths instead.')

lib/build-esbuild/index.js:445

  • Prefixing a user-supplied chunkNames or assetNames template does not keep it beneath the named root when the template contains ..; for example, assetNames: '../assets/[name]' becomes admin/../assets/[name] and escapes admin, allowing collisions with other graphs and violating the documented isolation guarantee. Normalize and reject templates that resolve outside the bundle root before passing them to esbuild.
  return `${bundleRoot}/${template.replaceAll('\\', '/').replace(/^\/+/, '')}`

lib/build-esbuild/index.js:519

  • These rejection reasons are serialized here and then passed through serializeEsbuildError again in the catch block below. When an esbuild diagnostic has an Error in detail, the first pass turns it into a plain { name, message, stack } object and the second pass converts that object to "[object Object]", losing the structured detail metadata. Keep the raw rejection reasons here and let the outer catch serialize the single error or aggregate exactly once.
    const failures = settled.filter(result => result.status === 'rejected').map(result => serializeEsbuildError(result.reason))
  • Files reviewed: 6/6 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@bcomnes
bcomnes force-pushed the issue-261-bundle-roots branch from a374c76 to 4e145c8 Compare September 14, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bundle roots

3 participants