feat(compile): --include embeds extra files as lazily loaded modules (#11732)

AH
6d ago
+1190-0

Problem

bun build --compile only embeds an entry point's static import/require graph. A computed import()/require() built from a runtime string — plugin loaders, ESLint/textlint-style plugin-by-name loading, a path built from import.meta.dirname — has nothing to resolve against inside $bunfs, even when the target file sits right next to the entry point on disk. That's the request in #11732 (open since 2024; --minify is a workaround Jarred gave there, since it makes static analysis catch a few more literal cases, but it doesn't help a truly runtime-computed specifier).

--asset doesn't cover this either: it embeds a file or directory as raw bytes (read via fs/Bun.file()), so an asset never becomes an importable module.

Design

New repeatable --include <path|glob> flag, --compile-only:

  • A directory argument is included recursively.
  • An argument containing a glob metacharacter (*, ?, [, {) is matched with bun_glob against every file under the cwd (skipping node_modules/.git) — the same matcher test_command.rs/pm_diff_command.rs already use for CLI-supplied patterns.
  • Anything else is a literal file path.

The matched paths are appended to the bundler's entry_points before bundling starts. This reuses the existing multiple-entry-point machinery end to end rather than adding a new embedding path:

  • Each included file gets an ordinary OutputKind::EntryPoint output — parsed, resolved, bundled, --bytecode applies the same as any entry.
  • The standalone graph writer already embeds every EntryPoint/Chunk output under $bunfs, with its naming-template path (default [dir]/[name].[ext], no hash) preserved.
  • StandaloneModuleGraph::compile picks exactly one output — the first EntryPoint-kind file — as entry_point_id, the module that actually runs when the executable starts. Every other embedded output (additional user-specified entries today, included files with this change) sits in the graph unexecuted until something resolves its path at runtime, e.g. a computed import().

That gives path fidelity and lazy loading "for free," without touching StandaloneModuleGraph, the resolver, or --asset's code path at all.

One deliberate difference from --asset: an asset directory is rooted at its own basename (--asset ./public/static embeds as static/…, discarding public/). --include preserves the argument's full path relative to cwd, because that's what makes import(join(import.meta.dirname, "…")) resolve correctly for code that isn't the entry point itself — the entry-naming template needs the real relative path, not a flattened one.

--include is independent of --asset and requires --compile, same validation shape as --asset. Combining it with --compile --target browser (the all-HTML-entrypoints standalone mode) is rejected the same way --asset already is.

Explicitly out of scope for this PR (see #11732 and the discussion there):

  • The --external/bare-specifier resolution gap (an embedded module resolving a non-embedded bare specifier from the executable's own directory) is #44053, being worked by another contributor. This PR does not touch resolver lookup order.
  • A bunfig.toml config form, context-module expansion for import(./dir/${x}.js), and build-time diagnostics for non-analyzable dynamic imports are follow-ups.

Tests

test/bundler/compile-include.test.ts runs each case through the CLI flag and through Bun.build({ compile: { include } }): a computed import() of an included directory resolves from $bunfs; an included module runs only when imported; directories are recursive and a glob matches only its own files; a glob walks from its literal prefix and needs no leading ./; a file outside the include set still fails to resolve; --bytecode; a missing path and a glob with no matches fail loudly. CLI-only: --include without --compile, and with --compile --target=browser. The API rejects compile.include with a browser target.

Patterns are relative to the cwd, and that rule is now enforced the same way across the literal-file, directory, and glob branches:

  • An absolute pattern is rejected (/abs/*.js; a drive path on Windows), and on Windows backslashes count as separators. A colon in a relative name is valid off Windows.
  • A pattern is normalized (repeated slashes collapsed, a leading ./ stripped) before the absolute check and before a glob's walk prefix is derived, so a pattern that only looks relative before normalization (.//tmp/*.js, ././/tmp/*.js, .///tmp/*.js, .//./tmp/*.js) can't turn into an absolute walk/match afterward — covered with a file planted outside the temp cwd, rejected with the same "must be relative to cwd" message; a same-directory .//plugins/*.js still resolves.
  • A .. component is rejected the moment it would climb above the cwd (../etc/passwd, ../../etc, ../*.js), but allowed when it nets back inside the cwd (./plugins/../plugins/*.js, equivalent to ./plugins/*.js) — .. gets the same "relative to cwd" treatment as a leading /, not a blanket ban.

Windows-only cases are skipped elsewhere.

Docs

docs/bundler/executables.mdx: new "Embed lazily loaded modules" section next to --asset, covering the module-vs-bytes distinction, path preservation, glob semantics, and lazy loading, with CLI and Bun.build examples. packages/bun-types/bun.d.ts documents compile.include; the --help line for --include was already in place.

Verification (what actually ran)

Upstream Buildkite is gated for first-time contributors, so the change was built and tested on a throwaway Linux x64 machine (Google Cloud Build, 32 vCPU): bun scripts/build.ts --profile=debug (ASAN, LLVM 23, pinned nightly Rust), then bun-debug test on the branch head (6f9d4b27). Tests ran on Linux x64 only; the Windows-only cases were skipped, and Windows and macOS were type-checked via bun run rust:check-all (12/12 targets), not executed.

  • test/bundler/compile-include.test.ts: 53 pass, 0 fail, 8 skipped (Windows-only) on the debug build (CLI and Bun.build variants of each case, including the doubled-slash-after-./ cases and the ..-escape/net-neutral cases added after review).
  • The same file under USE_SYSTEM_BUN=1 (Bun 1.4.2, no --include): 4 pass, 41 fail, 8 skipped — the escape/normalization cases (.//tmp/*.js outside cwd, ../etc/passwd, ../../etc, ../*.js) are among the failures, confirming they exercise the fix and not incidental behavior.
  • test/bundler/compile-asset-bunfs.test.ts (control): 9/9 pass.
  • bun test test/integration/bun-types/bun-types.test.ts: 21/21 pass.
  • bun run rust:check-all: 12 ok, 0 failed, 0 skipped (all 12 targets, including both Windows triples).
  • cargo clippy / cargo check --all-targets / miri: https://github.com/AhmedElBanna80/bun/actions/runs/36256853507 (earlier commit)

Every test deletes the source tree before running the executable, so an included module has to resolve from $bunfs, not from disk.

Which hunks are the fix:

  • expand_compile_includes in build_command.rs expands a file, directory or glob into entry points. A glob walks only its literal leading directory: with a 30k-file tree and a 5k-file node_modules beside the entry, --include './plugins/*.js' adds about 8 getdents64 calls and 0.03 s to a 2.0 s compile.
  • The CLI calls it before first_entry_point is captured. Bun.build calls the same function (JSBundler.rs) before it derives the root directory, so both paths share one expansion.
  • Both reject --target=browser standalone HTML, a missing path, and a glob that matches nothing.
  • The pattern is normalized (repeated slashes collapsed, a leading ./ stripped) once, up front, and every branch — literal path, directory, glob prefix — validates and reads that same normalized value, so a pattern that only looked relative before normalization can't become an absolute walk after it. A .. component is rejected only if it would climb above the cwd at that point (depth tracking across the normalized components); one that nets back inside the cwd is left alone.

Embedded paths: --include ./plugins embeds /$bunfs/root/plugins/target.js and /$bunfs/root/plugins/sub/n.js. The default [dir]/[name].[ext] naming (no hash) is unchanged.

Under --bytecode, import.meta.dirname is the build directory, so build the specifier relative to the importing module (import("./plugins/" + name + ".js")). That is existing behaviour and not changed here.

Related: #11732, #44053 (unrelated, separate fix, not touched here).