Skip to content

perf(build): bundle wizard via tsup for faster cold-start#692

Merged
kelsonpw merged 4 commits into
mainfrom
perf/cold-start-bundle
May 9, 2026
Merged

perf(build): bundle wizard via tsup for faster cold-start#692
kelsonpw merged 4 commits into
mainfrom
perf/cold-start-bundle

Conversation

@kelsonpw
Copy link
Copy Markdown
Member

@kelsonpw kelsonpw commented May 9, 2026

Problem

Cold-start cost on npx @amplitude/wizard and wizard --version /
wizard status --json is dominated by Node.js resolving + parsing 343
individual .js files under dist/src/. Even with pnpm dlx, npx,
or a pre-installed binary, the wizard pays 1-3+ seconds of module-load
cost before any UI shows.

Tool choice

tsup — chosen over hand-rolling esbuild because it's a thin
wrapper that respects tsconfig.json, preserves shebangs on entries
with them (no banner config needed), and reads ESM/CJS conventions
out of the box. Single dev-dep, zero impact on the published
dependencies. The full config is 30 lines (tsup.config.ts).

esbuild is the engine in either case. Hand-rolling would mean writing

  • maintaining the build script ourselves; tsup tracks ecosystem
    defaults (Node target updates, source-map flag changes) so we don't
    have to.

Before / after

Profile-instrumented numbers (Module hook on every require() at
cold-start, --version exit path, fast Mac):

Path Baseline Bundled Δ
Cumulative require time 1487 ms / 1034 calls 249 ms / 626 calls -83% time, -39% calls
Profile-mode total runtime 391 ms 203 ms -48%

Wall-clock medians (15 iterations each, no profiling overhead):

Command Baseline Bundled
wizard --version 260 ms 250 ms
wizard status --json (empty dir) 390 ms 380 ms
wizard manifest 270 ms 240 ms

The wall-clock delta on a fast Mac is modest because Node.js startup
itself accounts for ~180 ms of fixed cost. On slower hardware (cloud
CI, npx cold cache, slower disks) where module IO dominates, the
6× faster cumulative load translates into a much bigger absolute
saving. The lazy-load callsites further mean no axios / form-data /
fast-glob parsing on the cold path.

What's bundled vs externalized

Bundled into dist/bin.js (single file, ~2.3 MB, sourcemap shipped
alongside):

  • bin.ts and the entire src/**/*.ts tree.

Externalized (resolved at runtime from the consumer's
node_modules):

  • All entries in package.json dependencies — Ink + React, Anthropic
    SDKs, axios, yargs, zod, ai (Vercel ai-sdk), Sentry, Amplitude
    analytics SDKs, MCP SDK, etc. Bundling them would inflate the
    published bundle to >50 MB and break native modules (xcode,
    transitive node-pty paths).
  • Node built-ins (node:fs, node:path, etc.) — external by default
    on platform: 'node'.

Lazy-load callsites added

Even with bundling, the heaviest externals are gated behind
await import(...) so cold-start paths don't pay for them:

  • src/utils/urls.tsaxios lazy inside detectRegionFromToken.
    Synchronous URL helpers used everywhere never trigger the import.
  • src/utils/oauth.tsaxios lazy inside exchangeCodeForToken
    and refresh.
  • src/lib/api.ts — full axios module + apiClient instance
    lazy-loaded via getApiClient() (cached promise).
  • src/utils/environment.tsfast-glob lazy inside
    detectEnvVarPrefix (only used during framework detection).

Existing lazy paths (already in bin.ts, untouched) continue to
work: dotenv, update-notifier, @sentry/node (via initSentry),
@anthropic-ai/claude-agent-sdk (via lazyRunWizard).

Test coverage

New src/__tests__/bundle-smoke.test.ts spawns the bundled artifact
and asserts:

  • node dist/bin.js --version exits 0 with semver shape
  • node dist/bin.js status --json emits a parseable JSON envelope
  • node dist/bin.js mcp serve initializes and lists the read-only
    tools (detect_framework, get_project_status, get_auth_status)

These run as regular vitest tests but spawn node so they catch
issues that only surface in the bundled artifact (shebang corruption,
externals not resolving, accidental top-level awaits in CJS).

Build determinism verified: two consecutive pnpm build:bundle runs
produce byte-identical dist/bin.js and dist/bin.js.map.

Backward-compat

  • package.json bin still points at dist/bin.js. npx @amplitude/wizard and global installs work identically.
  • Published files glob unchanged: dist/bin.* (now matches .js,
    .js.map, .d.ts) + dist/src (now .d.ts only — no JS — but
    IDE consumers who walk import type paths still resolve).
  • package.json main / exports reference dist/index.js, which
    is a pre-existing inconsistency (no index.ts source); out of
    scope for this PR.
  • All CLI commands verified: --version, --help, status --json,
    auth status --json, manifest, mcp serve.

Tarball size: 1.8 MB → 2.7 MB compressed (sourcemap is the +900 KB
delta). 978 → 641 files in the published archive. Sourcemap kept so
Sentry stack traces and --enable-source-maps runs stay readable.

Known limitations

  • Wall-clock cold-start savings on a fast Mac are modest because
    Node.js startup is the dominant cost. The win is much larger on
    slower hardware and on first-cache npx runs.
  • The dist/index.js reference in package.json main/exports
    is still broken (it was already broken on main); this PR does
    not introduce or fix that.

Test plan

  • pnpm test (3783 vitest tests pass)
  • pnpm test:bdd (100 scenarios pass)
  • pnpm exec tsc --noEmit -p tsconfig.json (clean)
  • pnpm lint (clean — 1 pre-existing warning unrelated to this PR)
  • pnpm build deterministic across consecutive runs
  • pnpm pack produces a valid tarball with the expected files
  • CLI smoke: --version, --help, status --json, auth status --json, manifest, mcp serve (tools/list)

🤖 Generated with Claude Code


Note

Medium Risk
Build/publish pipeline is reworked to ship a bundled dist/bin.js, so regressions could break the CLI at runtime (module resolution, CJS bundling edge cases, missing externals). Risk is mitigated by new spawn-based smoke tests that exercise --version, status --json, and mcp serve against the built artifact.

Overview
Switches the build pipeline from plain tsc output to a single-file tsup bundle for the CLI (bin.ts + src/**dist/bin.js + sourcemap), while emitting declarations separately via tsc --emitDeclarationOnly.

To keep cold-start fast and bundle externals stable, several modules now lazy-load heavy dependencies (axios in src/lib/api.ts, src/utils/urls.ts, and OAuth token exchange/refresh; fast-glob in src/utils/environment.ts) with cached promises that clear on rejection.

Adds tsup.config.ts, updates package.json scripts/devDependencies and ESLint ignores, and introduces new end-to-end bundle smoke tests (src/__tests__/bundle-smoke.test.ts) plus docs updates (docs/build.md, releasing notes) describing the new bundling and externalization conventions.

Reviewed by Cursor Bugbot for commit 4d45cb8. Bugbot is set up for automated code reviews on this repo. Configure here.

Replaces the per-file `tsc` JS emit with a single tsup-driven bundle so
cold-start parses one file (`dist/bin.js`) instead of resolving and
loading 343 individual modules from `dist/src/`. Type declarations are
still emitted via a separate `tsc --emitDeclarationOnly` pass so the
package's `.d.ts` surface is unchanged.

Lazy-loads the heaviest externals on the cold-start path:
  - axios in src/utils/urls.ts (only used in detectRegionFromToken)
  - axios in src/utils/oauth.ts (only used in OAuth exchanges)
  - axios + apiClient in src/lib/api.ts (cached promise, first GraphQL
    call pays the import cost)
  - fast-glob in src/utils/environment.ts (only used in
    detectEnvVarPrefix during framework detection)

Profile-instrumented numbers: cumulative require time drops from
~1.5 s / 1034 calls to ~0.25 s / 626 calls. Wall-clock --version
median drops 260ms -> 250ms on a fast Mac (Node startup is the
fixed-cost floor); savings are larger on slower hardware where IO
dominates.

Smoke tests cover --version, status --json, and mcp serve against
the bundled artifact so regressions in the publish path are caught
in vitest.

Build is deterministic — two consecutive `pnpm build:bundle` runs
produce byte-identical bin.js and bin.js.map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kelsonpw kelsonpw requested a review from a team as a code owner May 9, 2026 16:50
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Cached lazy-import promises never clear on rejection
    • Added .catch() handlers to all four lazy-import caches (axiosModulePromise and apiClientPromise in api.ts, axiosPromise in urls.ts, fgPromise in environment.ts) that null out the cached promise before re-throwing, matching the existing pattern in agent-driver.ts.

Create PR

Or push these changes by commenting:

@cursor push 188e21b95e
Preview (188e21b95e)
diff --git a/src/lib/api.ts b/src/lib/api.ts
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -38,13 +38,21 @@
 // subsequent calls reuse the cached promise.
 let axiosModulePromise: Promise<AxiosStatic> | null = null;
 const loadAxios = (): Promise<AxiosStatic> =>
-  (axiosModulePromise ??= import('axios').then((m) => m.default));
+  (axiosModulePromise ??= import('axios')
+    .then((m) => m.default)
+    .catch((e) => {
+      axiosModulePromise = null;
+      throw e;
+    }));
 
 let apiClientPromise: Promise<AxiosInstance> | null = null;
 const getApiClient = (): Promise<AxiosInstance> =>
-  (apiClientPromise ??= loadAxios().then((axios) =>
-    axios.create({ timeout: 15_000, httpsAgent }),
-  ));
+  (apiClientPromise ??= loadAxios()
+    .then((axios) => axios.create({ timeout: 15_000, httpsAgent }))
+    .catch((e) => {
+      apiClientPromise = null;
+      throw e;
+    }));
 
 // Synchronous predicate for catch blocks that just need to narrow `unknown`
 // to `AxiosError` without paying the full module-load cost up front.
@@ -1407,8 +1415,8 @@
           err.response?.data,
         )}`
       : err instanceof Error
-      ? err.message
-      : String(err);
+        ? err.message
+        : String(err);
     logToFile(`[fetchSlackInstallUrl] failed: ${detail}`);
     return null;
   }

diff --git a/src/utils/environment.ts b/src/utils/environment.ts
--- a/src/utils/environment.ts
+++ b/src/utils/environment.ts
@@ -12,9 +12,12 @@
 const loadFg = (): Promise<FgFn> =>
   // The CJS export of fast-glob is the function itself (assigned to
   // `module.exports`), so we read the same ref through both entry shapes.
-  (fgPromise ??= import('fast-glob').then(
-    (m) => (m as { default?: FgFn }).default ?? (m as unknown as FgFn),
-  ));
+  (fgPromise ??= import('fast-glob')
+    .then((m) => (m as { default?: FgFn }).default ?? (m as unknown as FgFn))
+    .catch((e) => {
+      fgPromise = null;
+      throw e;
+    }));
 
 export function isNonInteractiveEnvironment(): boolean {
   if (IS_DEV) {

diff --git a/src/utils/urls.ts b/src/utils/urls.ts
--- a/src/utils/urls.ts
+++ b/src/utils/urls.ts
@@ -12,7 +12,12 @@
 const loadAxios = (): Promise<AxiosStatic> =>
   // axios is exported as `module.exports = axios` (callable + namespaced),
   // so the ESM/CJS interop bridge surfaces the static object as `.default`.
-  (axiosPromise ??= import('axios').then((m) => m.default));
+  (axiosPromise ??= import('axios')
+    .then((m) => m.default)
+    .catch((e) => {
+      axiosPromise = null;
+      throw e;
+    }));
 
 /**
  * Resolve the Amplitude data ingestion host for a given region.

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit a4204d3. Configure here.

Comment thread src/lib/api.ts Outdated
kelsonpw added 3 commits May 9, 2026 10:07
The new lazy-load patterns for `axiosModulePromise`/`apiClientPromise`
(in `lib/api.ts`), `axiosPromise` (in `utils/urls.ts`), and `fgPromise`
(in `utils/environment.ts`) cached the dynamic-import promise via `??=`
but never cleared the cache on rejection. A transient `import()` failure
(broken install, partial filesystem, transient I/O) would poison every
subsequent caller in the process with the same stale rejection forever.

Switch to the same null-on-catch pattern already used by
`loadDefaultDriver` in `agent-driver.ts`: store the cached promise, wire
a `.catch()` that nulls the cache and re-throws so callers still see the
original error, and let the next call retry the import cleanly.
…gionFromToken

Add a regression test that mocks axios's `.default` getter to throw on
the first call and asserts `detectRegionFromToken` re-attempts the
import after a working axios is doMock'd in. Without the
rejection-clearing branch in `loadAxios`, the second call would replay
the cached rejection instead of returning a region.
@kelsonpw kelsonpw merged commit 06506f7 into main May 9, 2026
13 checks passed
kelsonpw added a commit that referenced this pull request May 9, 2026
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