Skip to content

build(cli): bundle the CLI with esbuild — EXPERIMENTAL prototype, fine to discard - #1549

Draft
hlenke wants to merge 4 commits into
mainfrom
proto/esbuild-bundle
Draft

hlenke wants to merge 4 commits into
mainfrom
proto/esbuild-bundle

Conversation

@hlenke

@hlenke hlenke commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

⚠️ EXPERIMENTAL PROTOTYPE — this is a draft on purpose, and it is completely fine to throw this away. If the gains below are not meaningful enough to justify the added build complexity, close this PR without merging. Nothing depends on it.

Sequencing note: remaining dependencies on #1530 and #1532

#1543 has landed and this branch is rebased on top of it — runtime deps are now 2 (jiti, oxc-parser) and installs no longer pull the ~24 MB typescript peer. Two open PRs still change what this branch should look like when it grows up:

Affected Components

  • CLI
  • Create CLI
  • Test
  • Docs
  • Examples
  • Other

What this does, in plain language

Today, installing the CLI downloads 39 npm packages plus everything those packages depend on — roughly 224 packages, 7,800 files, 95 MB — before the CLI can run. Almost all of that code is ours or is used in exactly one way.

This branch has esbuild (the same bundler the Shopify CLI uses) combine everything into a small number of pre-built files that ship inside the checkly package itself. After that:

  • Installing the CLI downloads 3 packages instead of 39 (95 MB → 42 MB of node_modules, most of which is typescript that npm auto-installs as a peer, same as on main).
  • Every JS dependency is pinned at build time. A transitive dependency can no longer break or surprise us after release; but it also means security patches in bundled dependencies now require a CLI release. That is a posture change, made deliberately — it is the same conservatism as our 2-day new-version holdback, taken to its conclusion.
  • Startup got faster, not slower: the CLI now starts from a small pre-compiled chunk graph instead of resolving hundreds of module files, and bin/run.cjs now enables Node's V8 compile cache so warm runs skip re-parsing the bundle entirely.

One deliberate deviation from the Shopify pattern, with measurements: Shopify uses oclif's "explicit" command strategy — one file importing all commands, which oclif loads eagerly. I built that first; it made every invocation import all 60 command classes, and warm --version regressed 96 ms → 225 ms. So this branch instead gives each command its own bundle entry, with shared code split into chunks by esbuild — running checkly env ls never even parses the mqtt or archiver code. oclif keeps its default lazy command discovery, just pointed at the bundled files. Shopify's bundling without Shopify's startup tax.

Performance numbers

Measured on the same idle macOS machine, from real npm installs of the packed tarballs — both columns on current main (after #1543 landed), p50 of 30 runs:

Metric main This branch
npm install, cold cache 5.3 s 1.1 s
node_modules 71 MB / 7,550 files / 220 pkgs 12 MB / 675 files / 5 pkgs
checkly --version warm 74.2 ms 51.8 ms
checkly --help warm 82.7 ms 60.6 ms
Tarball 1.23 MB gz / 1,374 files 1.32 MB gz / 614 files

The tarball is within 8% of main. The bundles are minified (halving them from 7.4 MB to 3.5 MB) with esbuild's keepNames, so class and function names stay readable in stack traces — only local identifiers are mangled. Sourcemaps are not shipped: the split-bundle maps weigh ~13 MB, and Node only applies sourceMappingURL with --enable-source-maps anyway.

Runtime dependencies: 39 → 2. The two that remain each genuinely cannot be bundled:

  • oxc-parser — native Rust bindings, resolved per platform
  • jiti — lazy-requires its own package files at runtime

(@typescript-eslint/typescript-estree was the third until #1543 landed; it is now a devDependency used only by the parser-parity test, and nothing imports it at runtime.)

@oclif/core, @oclif/plugin-help and @oclif/plugin-warn-if-update-available become devDependencies (bundled in). plugin-help turned out to be fully shadowed: help output is byte-identical without it.

How the build works now

  • prepare:dist = tsc --build (still the source of truth for types and .d.ts files) → scripts/bundle.mjs (esbuild).
  • oclif's command discovery, hooks and help class keep their original configuration shape, just pointing at dist/bundle/... paths.
  • A few files computed paths relative to their own depth in dist/; those now use a small packageRoot() walker (src/services/package-root.ts) so they resolve identically from the bundle, from tsc output, and from source.
  • A new CI step (pnpm --filter checkly run startup:check in the lint job) fails the build if --version p50 exceeds a generous 400 ms budget (currently ~50 ms) — so startup regressions from new dependencies fail a check instead of going unnoticed.

Testing done

  • Full unit + type suite: 223 files, 4,298 tests, green — including the fixture-sandbox tests that install the packed tarball into real pnpm/yarn/bun projects and drive the bundled CLI.
  • 18-case smoke battery byte-identical to the main build: --help/help/topics/-h/-v, unknown-command hook (exit 127), parse-error hook, skills, runtimes, live authenticated API commands (env add/rm, including agent mode), plus ESM and CJS imports of the public API from the installed copy.
  • Clean-slate reproducibility: pnpm run prepare (clean → tsc → bundle → AI-context generation) runs end-to-end; the AI-context step itself executes the bundled CLI.
  • Local validation is macOS-only. The riskiest remaining unknowns — Windows path handling in packageRoot() and the fixture installs — are exactly what CI on this draft would validate for free (unit + fixture suites run on Windows in CI). That is a big part of why this draft exists.

Known trade-offs / open questions

  • Tarball is 8% larger than main (1.32 vs 1.22 MB gz); minified with keepNames, no shipped sourcemaps (see above).
  • Bundled-dependency security-patch posture (above).
  • e2e suite passed in CI on both platforms (the draft's first run caught a real bundling lesson: esbuild renamed construct classes, breaking the deploy preview — fixed with keepNames).
  • src/hooks/check-update.ts (the folded update-check plugin) should be replaced by perf(cli): replace warn-if-update-available with a lightweight hook #1532 when it lands.

@hlenke
hlenke force-pushed the proto/esbuild-bundle branch from 2c03bba to ee69664 Compare October 15, 2026 21:26
EXPERIMENTAL prototype, opened as a discardable draft PR — see the PR
description for measurements, trade-offs and sequencing notes.

- tsc output and 39 runtime dependencies are replaced by esbuild-bundled
  entries under dist/bundle/ (per-command entries + shared chunks, so
  commands stay lazy), reducing runtime deps to oxc-parser,
  @typescript-eslint/typescript-estree and jiti (none of which can be
  bundled: native binding, user-project resolution, runtime self-requires).
- @oclif/core and the two oclif plugins move to devDependencies and are
  folded into the bundle; plugin-help was fully shadowed already.
- bin/run.cjs enables Node's V8 compile cache (best effort, old-syntax
  safe for the version preflight).
- New packageRoot() util anchors package-relative paths (ai-context,
  assets) that used to depend on module depth in dist/.
- New startup-budget CI check (scripts/startup-check.mjs) fails the lint
  job if --version p50 exceeds 400ms (currently ~50ms).
The deploy preview prints construct.constructor.name, and error messages
and write-back checks name construct classes too. Without keepNames,
esbuild renames classes whose name collides with their export binding in
chunk scope (ApiCheck = class _ApiCheck), so the packed CLI rendered
'_ApiCheck' where main renders 'ApiCheck' — caught by the deploy e2e on
CI (unit tests run from source and never saw it).

Also move the exports 'types' conditions first, where esbuild expects
them, silencing build warnings.
Roughly halves the bundle (7.4 MB -> 3.5 MB, tarball 1.69 -> 1.32 MB
gzipped, within 8% of the unbundled main tarball). keepNames keeps class
and function names readable in stack traces; only local identifiers are
mangled. Sourcemaps stay unshipped: split-bundle maps weigh ~13 MB and
Node only applies them with --enable-source-maps anyway.


#1543 moved write-back and preview parsing to oxc-parser and made
@typescript-eslint/typescript-estree a devDependency (reference parser of
ts-parser-parity.spec.ts only). No runtime imports remain, so the bundle
no longer needs it external: runtime dependencies are down to jiti and
oxc-parser, and installs no longer pull the ~24 MB typescript peer —
node_modules drops from 42 MB/18 packages to 12 MB/5 packages.

Also regenerates the lockfile after the rebase onto main.
@hlenke
hlenke force-pushed the proto/esbuild-bundle branch from ee69664 to 98434fb Compare October 16, 2026 18:11

This branch has not been deployed

No deployments
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.

1 participant