Skip to content

feat(bundle): bundle the bit cli with esbuild - #10590

Draft
GiladShoham wants to merge 471 commits into
masterfrom
bit-bundle3
Draft

GiladShoham wants to merge 471 commits into
masterfrom
bit-bundle3

Conversation

@GiladShoham

@GiladShoham GiladShoham commented Aug 9, 2026

Copy link
Copy Markdown
Member

Bundles the CLI into a single 59 MB CJS file plus 10 externals that cannot be inlined, with a generated shim package per core aspect re-exporting its slice of the bundle. npm run bundle builds it; --sea also builds a node single executable.

1.2 GB / 141k files → 159 MB / ~2.8k files. bit --help ~0.53s warm.

The shims emit the same dist/*.aspect.js and dist/*.main.runtime.js filenames the aspect loader already discovers, so the runtime needed no changes. The one source fix is real and independent of bundling: hook-require patched module.constructor.prototype.require, which under any bundler installs an enumerable require on Object.prototype.

bit start now works too: it serves the pre-built UI/preview bundles instead of running a bundler at all (shouldServeBundleUi/writePreviewEntry hash-match and serve from the shipped artifacts/, no public/ written). Verified end to end from a fresh bit init + bit create workspace: UI shell, workspace/scope roots, and the component's own preview all served from the pre-bundle. @rspack/dev-server and @rspack/core itself (42 MB — was the single biggest external) are both fully excluded from the default build's package.json/node_modules, not just externalized — neither is reachable once bit start serves the pre-bundle instead of rebuilding.

Also adds a "UI vendor DLL" artifact, built alongside the existing UI/preview pre-bundle: BundleUiTask runs an additional rspack DllPlugin pass producing vendor.js + a portable vendor-manifest.json, covering React and every core-aspect package that ships browser (.ui.runtime/.preview.runtime) code. A new createUiVendorDllReference() lets a separate consuming project turn that manifest into real DllReferencePlugin options, so it can intercept already-compiled core UI code instead of recompiling it from source — fixing the gap where a bundled bit can't build a third-party UI root (e.g. an app like community-cloud). Design doc: bundle-plan/26-ui-vendor-dll-design.md; plan + decision log: bundle-plan/27-ui-vendor-dll-plan.md.

Also adds a CircleCI guard (scripts/bundle-size-guard.mjs + scripts/bundle-size-baseline.json) that fails the build if any part of the bundle grows more than 10% past its baseline — a direct response to an SSR bundle regression (6 MB → 53 MB) caught and fixed on this branch by externalizing @rspack/core in rspack.ssr.config.ts. Runs in two phases so the cheaper check fails fast: pre (no UI/preview build needed) in build_esbuild_bundle, right after the bundle is built and before the e2e fan-out starts; post in e2e_test_ui_prebundle, right after inject_ui_prebundle. Every check also has a whole-folder catch-all alongside the twelve named sub-paths. --update-baseline re-measures and rewrites the baseline file, the way to accept an intentional size increase later. Baseline is calibrated against a real CircleCI (Linux) run, not a local machine (native binaries for packages like esbuild/@swc/core differ by platform).

Current size breakdown (measured on a real CircleCI Linux run):

piece size
bit.app.js (the CLI bundle) 59.42 MB
shims (@teambit/* + vendored harmony deps), before UI/preview injected 10.11 MB — regular dist/ barrels 0.12 MB + browser barrels (browser/, real compiled dist per shim, for third-party bundlers) 7.32 MB + vendored harmony runtime deps/locators/types the rest
UI/preview pre-bundle (artifacts/), injected after 21.06 MB — app shell (workspace+scope, single combined build) 6.65 MB + SSR 6.35 MB (was ~53 MB before the @rspack/core externalize fix) + UI vendor DLL (vendor.js + manifest) 7.38 MB + preview 0.68 MB
combined dist/core-aspects (bundle + all shims, before → after injection) 81.54 MB → 102.57 MB
externals installed (node_modules) 68.31 MB
total shipped distribution (whole-folder check, before → after injection) ~149.9 MB → ~171 MB

The UI pre-bundle shrink from the original ~82.7 MB two-build layout down to the single shared compilation comes from upstream #10628 (SSR fix + minifier) and #10629 (single rspack compilation shared by both UI roots instead of two separate builds), both merged into this branch, plus #10631's bit start sanity e2e. Note the UI artifact no longer splits into separate ui-bundle/workspace/ui-bundle/scope directories — it's one shared ui-bundle/public/bit/ tree (with ssr/ and static/ under it) plus the sibling ui-vendor-dll/.

esbuild's own metafile.json (8.9 MB of build-analysis JSON, never read at runtime) is no longer written into the published package — still produced for local npm run bundle iteration and CI's diagnostic capture.

Producing the UI/preview pre-bundle locally needs a real bit build --tasks BundleUI,PreBundlePreview; it's now cached under a gitignored .bundle-cache/ (with a commit-hash + date meta.json) so node_modules wipes don't force re-deriving it every time.

Verified from an isolated dir: 40+ commands including create, status, tag, export, import, watch, server, start, and build --unmodified (all 9 tasks, rspack included). UI vendor DLL additionally verified against a real rebuilt CLI bundle and against a genuinely separate pnpm install with different peer dependencies, confirming real DllReferencePlugin interception (not just a clean build).

npm run e2e-test:bundle / :sea run the suite against the artifact; CircleCI builds it once in build_esbuild_bundle and shares it across the e2e nodes (gated to ^bit-bundle.* branches).

Full architecture, measurements, externals breakdown, script-vs-SEA analysis, the publishable package layout and open questions are in bundle-plan.md.

Draft: based on remove-core-envs-from-manifest, so the diff includes that branch. Opened to get CircleCI running. ui-vendor-dll (#10690) has been fast-forward merged into this branch and closed.

covers the design, the four problems that mattered, verification (40 commands +
the full build pipeline), what is installed next to the bundle and why, script
vs sea timings, the publishable package layout, and the open questions.
the bundler spawns child processes with other cwds (npm install inside the
bundle dir, the bundle introspection for the esm bridges), so a relative out-dir
resolved differently per step.
setup_esbuild_bundle compiles the workspace and builds the bundle once, then
persists it; e2e_test_esbuild_bundle symlinks its launcher onto PATH and reuses
the existing e2e_test_cmd (same file splitting, timings and junit output) with
--bit_bin=bit-bundled. Gated to ^bit-bundle.* branches until it is green.
bit install compiles by default, which is why setup_harmony has bbit compile
commented out - the attached workspace already has every component's dist/.
Building the bundle needs only node and npm, so bvm is not needed either.
it is declared nowhere in the repo and imported by nothing - the reference came
from bit-bundle2, which predates the split of legacy into per-concern packages
that bundle like any other component. it resolved locally only because a stale
copy sat in node_modules, so the bundle built here and failed on CI.

the extras list is now filtered to what is actually installed, so a missing
optional extra warns instead of failing the build.
23/2876 baseline failures vs 41/2837 bundled; all baseline failures reproduce in
the bundle and none are unique to it, so the delta is 18 real regressions.
12 of them are the already-documented UI-bundling and require.resolve surface;
the zlib inflate failure and the startup-budget miss are new.
…i-bundler

the logic had to leave @teambit/bit before an env for @teambit/bit could use it:
an env that imports the component it builds is a cycle. the component now takes
the core aspect ids and the packages root as inputs instead of importing
manifests and resolving from the running process, so both callers - npm run
bundle and the build task - drive the same code.

adds teambit.harmony/envs/bit-cli-app-env, extending core-aspect-env with a
BundleCliApp task that reads the ids from the capsule's compiled manifests and
bundles the capsule rather than the running installation.
…s shims

it is a locator that linkCoreAspect dereferences, not a package, and node picks
package.json main over index.js for a directory require - so the two roles
cannot share one directory. lists the three publishable layouts.
shims move to dist/core-aspects/node_modules/@teambit/<name> and the bundle to
dist/core-aspects/bundle. node's upward node_modules walk from the bundle file
finds them, so resolution needs no runtime change, and npm strips only the root
node_modules so a nested one publishes (verified with npm pack --dry-run).

the bundler now also emits the per-aspect locators at dist/<name>/index.js that
DependencyLinker.linkCoreAspect dereferences, which is what lets a bundled build
drop @teambit/aspect's CoreExporterTask. externals become ordinary dependencies
of the distribution's package.json instead of a second package.json inside the
bundle dir. config.ts documents the layout and the rejected alternatives.

verified: bit install in a workspace links @teambit/workspace to the shim, and
--version/status/list/show/compile all pass.
…ession

entry point: current state, the immediate next step (env set -> install ->
build) with the failure modes to expect from the never-yet-executed build task,
the 18 bundle-only e2e failures in priority order, the commands, the CI setup,
and the repo gotchas.
…indow

both call sites treat an expected, temporary condition as fatal. an install re-injects the
workspace packages, deleting every component's dist, and rebuilds them in the compile step that
runs last. code in between that reaches a lazily-required module dies on a dist that existed when
the process started - aborting the install *before* the compile that would have restored it, which
leaves the workspace unable to run bit at all.

- syncCoreAspectLinksForEnvs guarded its trailing createLinks but not the body that throws (a lazy
  require of @teambit/aspect-loader for getCoreAspectName). its own comment already documents the
  bridge as best-effort.
- resolveAspects resolved core-aspect defs with an unguarded Promise.all(map(getAspectDef)),
  ignoring the throwOnError: false that InstallMain.reloadMovedEnvs explicitly passes.
…the env to @teambit/bit

generatePackageJson read workspace.jsonc and package.json from packagesRoot unconditionally. both
are repo-root files that do not exist in a capsule, so the build task threw before writing anything.
they are only fallbacks - in a capsule every external is a real dependency, so the installed copy
answers first - hence degrade to undefined instead of throwing.

also assigns bit-cli-app-env to teambit.harmony/bit in .bitmap.
…tall loop

the task runs; the capsule premise does not hold - its node_modules carries published
@teambit/*@1.0.1097 rather than the freshly compiled workspace components, and is missing 35 core
aspects outright. also documents the install/compile deadlock between the release binary and bd,
and the recovery sequence out of it.
…indow

both call sites treat an expected, temporary condition as fatal. an install re-injects the
workspace packages, deleting every component's dist, and rebuilds them in the compile step that
runs last. code in between that reaches a lazily-required module dies on a dist that existed when
the process started - aborting the install *before* the compile that would have restored it, which
leaves the workspace unable to run bit at all.

- syncCoreAspectLinksForEnvs guarded its trailing createLinks but not the body that throws (a lazy
  require of @teambit/aspect-loader for getCoreAspectName). its own comment already documents the
  bridge as best-effort.
- resolveAspects resolved core-aspect defs with an unguarded Promise.all(map(getAspectDef)),
  ignoring the throwOnError: false that InstallMain.reloadMovedEnvs explicitly passes.
they are not missing from the capsule. capsules share a hoisted root whose node_modules/@teambit has
299 entries, including all 35 the bundler reported as 'not installed'. the bug is that
getCoreAspectsInfo path-joins instead of resolving, so it never looks one level up. same bug in
teambit-dist-resolver-plugin.
the bundler moved to teambit.harmony/modules/cli-bundler but three references still pointed at
@teambit/bit/dist/bundle/ensure-bundle.js, which no longer exists - CI failed with MODULE_NOT_FOUND
on 'npm run bundle:ensure'.

also fingerprint the cli-bundler dist for the staleness stamp. it only covered the thin arg parser
left in @teambit/bit, so edits to the actual bundling logic left the stamp unchanged and
ensure-bundle would reuse a stale artefact.
…/<pkg>

the bundler located every package by path-joining onto packagesRoot. that holds for this repo and
breaks in a capsule, where most dependencies hoist to the shared capsule root - so a real build
found 71 of 106 core aspects, 70 of them 'without a main runtime', copied 0 of 4 runtime assets and
resolved 3 of 11 external versions. all of it silently.

- resolvePackageDir walks the node_modules chain the way node does, and returns the realpath so a
  pnpm-linked package's own dependencies stay on the resolution path.
- findRuntimeAndAspectFiles looks in dist/ as well as the top level; published packages and capsules
  keep the compiled runtime files there.
- specifiers keep the extension under dist/ and drop it at the top level - the two take different
  branches of the exports map and neither extension-probes.
- the dist resolver keys off componentId rather than _bit_local, which a capsule's copies don't
  carry, and falls back to esbuild for non-workspace components instead of failing the build.
- unresolved externals now warn: they are excluded from the bundle, so dropping one silently yields
  a runtime 'Cannot find module'.

the task now reports 106 aspects (9 genuinely UI-only), copies 105 assets and builds a 69MB bundle
with 107 shims and 107 locators.
…rect

53 capsules against a bit status of 2 new + 51 modified confirms the rule: new/modified components
are built into sibling capsules and linked fresh, unmodified ones install from the registry - where
the published package is by definition the current code. the earlier concern that the bundle would
ship stale aspects was unfounded.
bit-cli-app-env is itself an env, so teambit.envs/env; cli-bundler is a plain node module rather
than an aspect, so node-babel-mocha.
a user's workspace resolves @teambit/<aspect> to a shim whose body is one dynamic require, so
without declarations every import degraded to any - no type checking, no autocomplete.

the .d.ts tree is copied verbatim from the package being shimmed rather than regenerated. that
preserves type identity: declarations re-export their siblings and other @teambit/* packages, and
those references resolve through the sibling shims, so every aspect sees the same Component and
Workspace. rolling them up per package would break exactly that. the whole tree is copied, not just
index.d.ts, because the re-exports are relative paths into it.

the shim's exports map points 'types' at the copied declarations - inheriting the original, which
points at .ts sources a shim does not have, would leave everything untyped.

capsules always carry declarations; this repo needs 'bit compile --generate-types'. both verified,
107/107 shims with 1722 files. an external workspace type-checks against them under noImplicitAny,
and a negative control confirms the types are enforced rather than silently any: ws.path resolves as
string and cm.toArray() as [Component, string][], the Component coming from a sibling shim.
…k status

docs.mdx explains each module and the traps: resolution is not path-joining, exports maps don't
extension-probe, a missing main runtime is legitimate so a resolution bug looks like a normal build.

plan gains 9e - the task runs green, what the first runs exposed, freshness confirmed correct, types
verified in an external workspace, and what is still open.
the task wrote to <capsule>/app-bundle, so the build produced a prototype dir that something would
later have had to lift into the package. it now builds into the capsule itself, which *is* what gets
published, giving the 9b layout directly: package.json + bin/bit + dist/<aspect>/index.js locators +
dist/core-aspects/{bundle,node_modules}.

inPlace changes three things: never clean (the out dir holds the component's own sources and dist -
cleanOutDir would delete them), merge into the real package.json instead of writing the
@teambit/bit-bundle-externals stand-in, and skip .npmrc, which only exists for the prototype's local
npm install.

the merge prunes the dependency surface to the externals alone - 168 declared dependencies replaced
by 7. leaving them would make a consumer's install re-download the very tree the bundle replaces,
and would resolve a second copy of every core aspect next to the shims, so @teambit/workspace could
resolve to a published package rather than the bundle slice. dev/peer/optional deps go too;
identity fields (name, version, componentId, engines) are untouched.

also excludes dist/core-aspects from the .d.ts copy: in place, @teambit/bit's source dir is the
capsule, whose dist now contains the generated shims, so an unfiltered glob copied all 106 shims'
declarations into the bit shim (1158 files instead of 18).

verified: the capsule's sources and dist survive, and the built package runs --version, init, status
and list from a fresh workspace.
MochaMain.createTester() has no callers anywhere in the repo or in any
resolved env - real consumers (node-babel-mocha, node-typescript-mocha)
already import MochaTester directly from @teambit/defender.mocha-tester,
which has no dependency on this aspect. Being an eager BitMain dependency,
it forced mocha's require chain onto every bit invocation for no reason.
mocha is no longer needed - the @teambit/mocha core aspect that pulled it
in unconditionally is gone (merged from remove-core-envs-from-manifest).
Rebuild verified: externalsInstalled 11 -> 10, coreAspects 106 -> 105, zero
require sites for mocha in bit.app.js.

bundle-plan.md records the webpack/mocha externals research and the mocha
removal end to end.
GiladShoham and others added 30 commits September 6, 2026 19:53
…VendorDllPaths

Wires resolveUiVendorDllPackages/buildUiVendorDll into BundleUiTask.execute()
and adds UiMain.getUiVendorDllPaths() so external consumers (e.g. bit-cloud)
can discover the shipped vendor DLL artifact.

Fixes found and corrected while wiring against a real build:
- resolveUiVendorDllPackages derived package names with the non-core naming
  convention instead of the core-aspect one; used getCoreAspectPackageName
  from @teambit/aspect-loader instead (not @teambit/bit, which would balloon
  teambit.ui-foundation/ui's own dependency graph and break the isolator).
- buildUiVendorDll required each package's whole main entry instead of its
  specific .ui.runtime.js/.preview.runtime.js file, pulling in unrelated
  non-browser-safe code (e.g. a native binary loader via @teambit/pnpm).
- @teambit/ui's barrel re-exports BundleUiTask as a real value, making the
  new ui-vendor-dll.ts module (and its rspack/webpack loader dependencies)
  require()-reachable from client-rendered code; externalized these in both
  rspack.browser.config.ts and the DLL's own compiler config.
- buildUiVendorDll's own rspack config lacked the CSS/SCSS loader, JSX/TSX
  transform, and resolve fallback/alias table the existing pre-bundle already
  has, so real core-aspect ui.runtime files (which pull in the actual UI
  component library) failed to bundle; now reuses rspack.common's pieces.

Verified via a real `bd build teambit.ui-foundation/ui --tasks BundleUI`:
existing pre-bundle unaffected, ui-vendor-dll/ produced with real content
(vendor.js ~6.4MB covering 3393 real modules, correctly keyed by exact
runtime-file path).
rspack keys a dll manifest by each module's path relative to the build's context, and
DllReferencePlugin matches by recomputing that path on the consuming side. 2629 of the shipped
manifest's 3393 keys ran through pnpm's .pnpm/<name>@<version>_<peer-hash> store directories, whose
hash comes from the building install's own dependency graph - no separate project reproduces it, so
every entry silently missed and recompiled from source.

Re-key the shipped manifest by package name + subpath, and add createUiVendorDllReference() to turn
it back into DllReferencePlugin options for whatever install is consuming it. Also expose vendor.css
through getUiVendorDllPaths().
Corrects 26's "intercepts transparently" claim, adds D17 and a Task 6 findings entry with the
measured before/after from a real separate-install repro, and amends gap 1's mitigation note.
…pm-hoisting workaround, remaining coverage gap
The browser config already externalizes @rspack/core to keep the vendor-dll
import chain out of the client bundle; the ssr config had no externals at
all, so the same chain (via @teambit/ui's barrel re-exporting BundleUiTask)
dragged @rspack/core's ~40 MB native binding into the shipped ssr bundle.
Verified safe against a real default build where @rspack/core isn't
installed: 16/16 e2e passing, including real SSR render.
It ran unguarded inside e2e_test_esbuild_bundle's sweep and hit the
missing --ui-bundling toolchain ("Cannot find module 'assert/'"),
unlike its ui-start.e2e.ts/ui-ssr.e2e.ts siblings which already skip
unless BIT_E2E_UI_MODE is set.
Fails CI if any part of the bundle grows more than 10% past baseline -
direct response to the SSR bundle's 6 MB -> 53 MB regression. Two
phases (pre/post UI-preview injection) so the cheaper check fails fast
before the 40-way e2e fan-out starts.

Also fixes lint-staged's oxlint glob to skip scripts/, matching
.oxlintrc.json's own ignorePatterns - it was erroring on any staged
scripts/*.mjs file ("no files found to lint") since oxlint treats an
explicitly-passed but ignore-listed path as zero matched files.
…er check

Pre-phase baseline was captured on macOS and undercounted externals by
~8.7% vs. real CircleCI (Linux) numbers, eating most of the margin on
the first run. Recalibrated from the actual CI job's report.

Also adds total-pre/total-post checks measuring the entire out-dir, so
anything landing anywhere in the shipped folder trips the guard, not
just the named sub-paths.
Move the post-phase check + inject_ui_prebundle out of
e2e_test_ui_prebundle into a new check_ui_prebundle_size job - build
validation, not a test, so it shouldn't live in the e2e job. Persists
the already-injected bundle so e2e_test_ui_prebundle attaches it
directly instead of redoing the injection.
It builds the bundle, it doesn't set anything up. Updates the job key,
every requires: reference, and current-state docs; historical findings
log entries keep the old name since they describe what was true then.
React version compatibility check, process.cwd()-independent resolve
root, correct nested node_modules handling, per-package version-
collision handling, dependency-aware router-context safety, and no
longer leaking the generated entry file's build paths into the
shipped artifact - all in ui-vendor-dll.ts.

Also: fail (not warn) on an unresolvable harmony runtime dep and vendor
a missing browser-dist dependency in generate-shim-packages.ts; fail
the size guard on a silently-missing artifact instead of reading it as
0 bytes; wire the size guard into both bvm publish CI jobs; fix a
lint-staged glob that silently excluded root-level JS files.
## Proposed Changes

The component-peer dependency e2e test gives `comp1` a custom TypeScript
env but leaves `comp2` on the empty default env. With root components
enabled, `comp1` references `comp2` as a TypeScript project even though
the peer has no compiler to generate its tsconfig.

Assign the same custom env to `comp2` so both projects have a compiler.
This fixes the test fixture while preserving its peer-dependency and
hidden-peer assertions; it does not change the external compiler's
handling of non-TypeScript dependencies.

Validation: `npm run lint` and `git diff --check` pass. Focused e2e runs
were attempted under Node 22 with the local compiler reference-pruning
patch disabled, but both original and corrected setups hit 18 unrelated
local type errors mixing checkout types with an installed Bit version. A
clean CI e2e result is still needed.
The previous commit's import { tmpdir } from 'os' broke the real
browser UI/preview pre-bundle build: ui-vendor-dll.ts is reachable
from a browser compilation via the @teambit/ui barrel, and os has no
browser resolve fallback the way fs/path do. Write the scratch entry
file under dirname(outputPath) instead - no new Node builtin needed,
and it sits outside the artifact glob rather than merely being
cleaned up in time.

Also: buildUiVendorDll's new sourceRoot default broke two existing
unit tests under real capsule execution - they used lodash.compact/
lodash.flatten as fixtures, which aren't actual dependencies of
@teambit/ui and only resolved by cwd happenstance. Switched to
p-map-series/chalk, real declared dependencies guaranteed present
wherever the component's own install is.

Also updates bundle-size-baseline.json's shims-dist check for the
expected size growth from vendoring @teambit/base-react.navigation.link.
p-map-series/chalk still failed to resolve via buildUiVendorDll's new
sourceRoot default inside bit_pr's real build capsule, even though
p-map-series is a genuine dependency of @teambit/ui. Revert to the
original lodash.compact/lodash.flatten fixtures, explicitly resolved
off process.cwd() (confirmed via CI history that this exact pairing
passed before any of this sourceRoot work) - real BundleUiTask usage
never passes a sourceRoot, so it's unaffected and still gets the
correct default.

Also replaces the resolveContextProviderMismatchUnsafePackages
negative-case test's use of the live @teambit/preview component (whose
dependency graph disagreed between CI and local) with a fully
controlled fake package.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v3 prs to merge for bit v3

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants