Skip to content

perf(cli): Code-split the npm package and the SDK like the standalone binary - #1843

Merged
BYK merged 2 commits into
mainfrom
perf/cli-split-npm-bundle
Nov 3, 2026
Merged

BYK merged 2 commits into
mainfrom
perf/cli-split-npm-bundle

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1810. That PR split the standalone binary into a CLI asset and a completion asset with a V8 code cache. The npm package still shipped the CLI and the library as one ~7 MB index.cjs, so every sentry run and every require("sentry") compiled all of it.

Now the npm package, the SDK, and the binary all ship as code-split chunks. One build helper and one runtime loader produce and load them.

Build: script/lib/split-bundle.ts, shared by bundle.ts and build.ts

  • esbuild only splits ESM, so the CommonJS build takes two passes. Pass 1 is ESM with splitting. Pass 2 converts each chunk to CommonJS without bundling. Pass 2 reads pass 1's sourcemaps, so the final maps point at src/ (checked with trace-mapping).
  • Every import() starts a chunk (each route in route-loaders.ts). So does every lazy _require() that require-alias rewrites: telemetry, cli-highlight, the --fields parser, and the WASM SQLite driver. Each of these becomes a lazy-*.cjs entry.
  • import("./chunk-X.cjs") is rewritten to a require-based __load() helper. That way a chunk loads through the same loader as its parent. Other import() calls stay native: builtins, and the Ink sidecar, which uses top-level await.
  • npm: cli.cjs and index.cjs share their chunks. index.mjs is split the same way and keeps the zlib shim. complete.cjs stays one file. package.json#files now uses dist/*.cjs and dist/*.mjs.
  • Binary: the same chunks, each embedded as a SEA asset under dist-build/<chunk>.
  • script/lib/chunk-build.ts holds the shared steps after the build: debug IDs and sourcemap upload for every chunk, the build hash, and bundling the main script. Each chunk registers its own debug ID through the injected snippet. The __SENTRY_DEBUG_ID__ placeholder in constants.ts is now dead, so it is removed.

Runtime: src/chunk-main.ts + src/lib/chunk-loader.ts

These replace sea-main.ts and sea-chunk.ts.

  • The same main script runs as dist/bin.cjs (npm) and as the SEA main script. It runs cli.cjs or complete.cjs.
  • The loader resolves require("./chunk-*.cjs") itself. It reads each chunk from a file or a SEA asset. It keeps a V8 code cache per chunk under <config dir>/cache/code/<build>-<platform>-<v8 tag>/, writes it on exit, keeps the 3 newest builds, and deletes the cache files left by perf(cli): split the SEA binary into a bootstrap and cached chunks #1810.
  • Node 20/21 load the chunks natively, without a code cache. On Node 20, a script compiled from a code cache loses its import() callback (ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING). I hit this with the packed bin on Node 20.20.2. There is now a regression test for import() from a cached chunk.

SDK

  • index.ts no longer imports the CLI runner. _cli is gone, because bin.cjs runs cli.cjs.
  • SDK calls load only the routes they run, through src/sdk-app.ts. It works like app-for-args.ts, except that the loaded routes accumulate so concurrent calls can't drop each other's routes.
  • cli-startup.test.ts now asserts that index.ts, sdk-app.ts, and app-for-args.ts load no command.

Not done: a consumer's bundler still can't drop unused commands at build time. createSentrySDK() returns one object that references every command. The chunks also have real top-level side effects (module init, registrations), so I did not set sideEffects: false. For now, "tree-shakeable" means runtime laziness: an app loads only what it calls.

Measurements (Node 22, this sandbox; medians, noisy)

Warm code cache, SENTRY_CLI_NO_TELEMETRY=1, 21 runs:

command npm main npm split SEA main SEA split
--version 530 ms 289 ms 285 ms 279 ms
issue list --help 426 ms 226 ms 239 ms 237 ms
org list --help 456 ms 234 ms 270 ms 264 ms

Verification

  • tsc, root pnpm run lint, and ci:policy pass.
  • CLI unit suite: 13,069 pass. 11 fail, and all of them fail the same way on main in this sandbox: 6 chmod/EACCES tests, plus 5 errors/oauth/auto-login-host-guard message tests.
  • The full e2e suite passes against the npm bin (SENTRY_CLI_BINARY=dist/bin.cjs, 207 tests) and against a linux-x64 SEA built with build.ts --single (179 tests).
  • New tests:
    • the loader (chunk graph, stack positions, cache reuse/reject/prune, legacy cleanup, import() with and without a cache, Node 20 gate)
    • sdk-app (partial, accumulated, concurrent, full tree)
    • e2e: __complete reads no CLI chunk, and issue list --help reads under half of the chunks
    • npm pack contains every chunk and no sourcemap
    • the zlib link check now covers every .mjs chunk
  • On Node 20.20.2, I ran the packed bin (fallback path) and both library formats by hand.

via @BYK.

--

View Session in Sentry

… binary

The npm package shipped the CLI and the library as one ~7 MB `index.cjs`,
so every run and every `require("sentry")` compiled all of it. Both now
ship as code-split chunks, built and loaded by the same code as the
standalone binary:

- `script/lib/split-bundle.ts` builds code-split CommonJS (an ESM pass with
  splitting, then each chunk converted to CommonJS). Every `import()` and
  every lazy `_require()` (telemetry, cli-highlight, the --fields parser,
  the WASM SQLite driver) starts a chunk. `script/bundle.ts` uses it for
  `cli.cjs` + `index.cjs` (shared chunks) and the split ESM `index.mjs`;
  `script/build.ts` for the binary, which embeds every chunk as a SEA asset.
- `script/lib/chunk-build.ts` holds the shared post-build steps: debug IDs
  and sourcemap upload for every chunk, and the main script.
- `src/chunk-main.ts` (replaces `sea-main.ts`) is `dist/bin.cjs` and the SEA
  main script. It runs `cli.cjs` or `complete.cjs` through
  `src/lib/chunk-loader.ts` (replaces `sea-chunk.ts`), which reads chunks
  from files or SEA assets and keeps a V8 code cache per chunk. Node < 22
  loads the chunks natively instead: Node 20 drops `import()` from scripts
  compiled from a code cache.
- The library no longer imports the CLI runner (`_cli` is gone, `bin.cjs`
  runs `cli.cjs`). SDK calls load only the routes they run
  (`src/sdk-app.ts`), so `require("sentry")` reads 79 KB instead of 6.8 MB.
- Each chunk registers its own debug ID with the injected snippet, so the
  `__SENTRY_DEBUG_ID__` placeholder in `constants.ts` is gone.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
@vercel

vercel Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
sentry-local Skipped Skipped Nov 3, 2026 9:57am UTC

Request Review

@github-actions github-actions Bot added the risk: medium PR risk score: medium label Nov 3, 2026

@BYK BYK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are we still compatible with Bun btw?

Comment thread packages/cli/src/lib/chunk-loader.ts Resolved
Comment thread packages/cli/src/sdk-app.ts Resolved
@sentry-junior

sentry-junior Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Re: Bun — yes. I tested this branch's npm build on Bun 1.4.4 (it reports Node 26 compat):

  • CLI (bun dist/bin.cjs): --version, help, issue list --help, auth status (gives the expected not-authenticated error) and __complete all behave the same as on Node.
  • Code cache: the chunk loader's vm.Script path runs on Bun. Bun writes its caches to a separate directory, because Bun's cachedDataVersionTag() differs from Node's, so the two never read each other's caches. On a warm run Bun doesn't rewrite them. Timing (issue list --help, warm) is about the same as Bun loading the chunks natively, ~330 ms vs ~400 ms.
  • SDK (require of dist/index.cjs and import of dist/index.mjs): run("--version"), three concurrent typed calls (each gives the expected SentryError), and run("help"), which loads the full tree, all work.

The standalone binary embeds Node, so Bun doesn't apply to it. There's no Bun job in CI, and packages/cli/AGENTS.md says the repo is Node-only, so I didn't add one.

Comment thread packages/cli/src/lib/chunk-loader.ts Resolved
@BYK
BYK merged commit 61b1fd4 into main Nov 3, 2026
27 checks passed
@BYK
BYK deleted the perf/cli-split-npm-bundle branch November 3, 2026 10:22
sentry-junior Bot added a commit that referenced this pull request Nov 3, 2026
Resolve the AGENTS.md conflict with the code-split npm package (#1843),
and point sdk-app.ts at selectApp(), which replaced routesForArgs(). The
SDK loads the full app for the top-level help, whose precomputed copy it
does not register.

Co-Authored-By: BYK <burak.kaya@sentry.io>

This branch was previously deployed

1 inactive deployment
Preview — 44b49d12 Deployed Nov 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant