feat(compile): --include embeds extra files as lazily loaded modules (#11732)
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 withbun_globagainst every file under the cwd (skippingnode_modules/.git) — the same matchertest_command.rs/pm_diff_command.rsalready 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::EntryPointoutput — parsed, resolved, bundled,--bytecodeapplies the same as any entry. - The standalone graph writer already embeds every
EntryPoint/Chunkoutput under$bunfs, with its naming-template path (default[dir]/[name].[ext], no hash) preserved. StandaloneModuleGraph::compilepicks exactly one output — the firstEntryPoint-kind file — asentry_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 computedimport().
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.tomlconfig form, context-module expansion forimport(./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/*.jsstill 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 andBun.buildvariants 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/*.jsoutside 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_includesinbuild_command.rsexpands a file, directory or glob into entry points. A glob walks only its literal leading directory: with a 30k-file tree and a 5k-filenode_modulesbeside the entry,--include './plugins/*.js'adds about 8getdents64calls and 0.03 s to a 2.0 s compile.- The CLI calls it before
first_entry_pointis captured.Bun.buildcalls the same function (JSBundler.rs) before it derives the root directory, so both paths share one expansion. - Both reject
--target=browserstandalone 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).