perf(build): bundle wizard via tsup for faster cold-start#692
Merged
Conversation
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>
Contributor
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
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.
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.
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.
This was referenced May 9, 2026
kelsonpw
added a commit
that referenced
this pull request
May 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Problem
Cold-start cost on
npx @amplitude/wizardandwizard --version/wizard status --jsonis dominated by Node.js resolving + parsing 343individual
.jsfiles underdist/src/. Even withpnpm 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 entrieswith 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
defaults (Node target updates, source-map flag changes) so we don't
have to.
Before / after
Profile-instrumented numbers (Module hook on every
require()atcold-start,
--versionexit path, fast Mac):Wall-clock medians (15 iterations each, no profiling overhead):
wizard --versionwizard status --json(empty dir)wizard manifestThe 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 shippedalongside):
bin.tsand the entiresrc/**/*.tstree.Externalized (resolved at runtime from the consumer's
node_modules):package.jsondependencies— Ink + React, AnthropicSDKs, 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-ptypaths).node:fs,node:path, etc.) — external by defaulton
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.ts—axioslazy insidedetectRegionFromToken.Synchronous URL helpers used everywhere never trigger the import.
src/utils/oauth.ts—axioslazy insideexchangeCodeForTokenand refresh.
src/lib/api.ts— full axios module +apiClientinstancelazy-loaded via
getApiClient()(cached promise).src/utils/environment.ts—fast-globlazy insidedetectEnvVarPrefix(only used during framework detection).Existing lazy paths (already in
bin.ts, untouched) continue towork:
dotenv,update-notifier,@sentry/node(viainitSentry),@anthropic-ai/claude-agent-sdk(vialazyRunWizard).Test coverage
New
src/__tests__/bundle-smoke.test.tsspawns the bundled artifactand asserts:
node dist/bin.js --versionexits 0 with semver shapenode dist/bin.js status --jsonemits a parseable JSON envelopenode dist/bin.js mcp serveinitializes and lists the read-onlytools (
detect_framework,get_project_status,get_auth_status)These run as regular vitest tests but spawn
nodeso they catchissues 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:bundlerunsproduce byte-identical
dist/bin.jsanddist/bin.js.map.Backward-compat
package.jsonbinstill points atdist/bin.js.npx @amplitude/wizardand global installs work identically.filesglob unchanged:dist/bin.*(now matches.js,.js.map,.d.ts) +dist/src(now.d.tsonly — no JS — butIDE consumers who walk
import typepaths still resolve).package.jsonmain/exportsreferencedist/index.js, whichis a pre-existing inconsistency (no
index.tssource); out ofscope for this PR.
--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-mapsruns stay readable.Known limitations
Node.js startup is the dominant cost. The win is much larger on
slower hardware and on first-cache
npxruns.dist/index.jsreference inpackage.jsonmain/exportsis still broken (it was already broken on
main); this PR doesnot 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 builddeterministic across consecutive runspnpm packproduces a valid tarball with the expected files--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, andmcp serveagainst the built artifact.Overview
Switches the build pipeline from plain
tscoutput to a single-file tsup bundle for the CLI (bin.ts+src/**→dist/bin.js+ sourcemap), while emitting declarations separately viatsc --emitDeclarationOnly.To keep cold-start fast and bundle externals stable, several modules now lazy-load heavy dependencies (
axiosinsrc/lib/api.ts,src/utils/urls.ts, and OAuth token exchange/refresh;fast-globinsrc/utils/environment.ts) with cached promises that clear on rejection.Adds
tsup.config.ts, updatespackage.jsonscripts/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.