From b73847dba102777ce19f03ae107a9ba28f28e863 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:34:22 +0000 Subject: [PATCH 1/2] perf(cli): Code-split the npm package and the SDK like the standalone 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> --- packages/cli/.gitignore | 2 + packages/cli/AGENTS.md | 32 +- packages/cli/package.json | 6 +- packages/cli/script/build.ts | 318 +++++--------- packages/cli/script/bundle.ts | 327 ++++++--------- packages/cli/script/debug-id.ts | 10 - packages/cli/script/lib/chunk-build.ts | 158 +++++++ .../cli/script/lib/require-alias-plugin.ts | 2 +- packages/cli/script/lib/split-bundle.ts | 394 ++++++++++++++++++ packages/cli/src/chunk-main.ts | 99 +++++ packages/cli/src/complete-bin.ts | 6 +- packages/cli/src/index.ts | 10 - packages/cli/src/lib/chunk-loader.ts | 324 ++++++++++++++ packages/cli/src/lib/complete.ts | 4 +- packages/cli/src/lib/config-dir.ts | 4 +- packages/cli/src/lib/constants.ts | 40 -- packages/cli/src/lib/db/index.ts | 6 +- packages/cli/src/lib/sdk-invoke.ts | 9 +- packages/cli/src/lib/sea-chunk.ts | 236 ----------- packages/cli/src/sdk-app.ts | 63 +++ packages/cli/src/sea-main.ts | 59 --- packages/cli/src/sentry-cli.ts | 6 +- packages/cli/test/e2e/bundle.test.ts | 57 ++- packages/cli/test/lib/chunk-loader.test.ts | 295 +++++++++++++ packages/cli/test/lib/sdk-app.test.ts | 57 +++ packages/cli/test/lib/sea-chunk.test.ts | 191 --------- packages/cli/test/script/cli-startup.test.ts | 37 +- .../cli/test/script/package-exports.test.ts | 43 +- 28 files changed, 1779 insertions(+), 1016 deletions(-) create mode 100644 packages/cli/script/lib/chunk-build.ts create mode 100644 packages/cli/script/lib/split-bundle.ts create mode 100644 packages/cli/src/chunk-main.ts create mode 100644 packages/cli/src/lib/chunk-loader.ts delete mode 100644 packages/cli/src/lib/sea-chunk.ts create mode 100644 packages/cli/src/sdk-app.ts delete mode 100644 packages/cli/src/sea-main.ts create mode 100644 packages/cli/test/lib/chunk-loader.test.ts create mode 100644 packages/cli/test/lib/sdk-app.test.ts delete mode 100644 packages/cli/test/lib/sea-chunk.test.ts diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore index 3922b3676..6c3e2be57 100644 --- a/packages/cli/.gitignore +++ b/packages/cli/.gitignore @@ -11,6 +11,8 @@ out dist dist-bin dist-build +# Scratch output of script/lib/split-bundle.ts +.split-* *.tgz # e2e bundle build lock (see test/e2e/bundle-setup.ts) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index e3c8dbe47..f22b6978e 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -121,12 +121,22 @@ Top-level layout: the whole tree with `getRoutes()`, loads `app.ts`. Add commands to `app.ts` only; a new top-level command that reads the whole tree goes in `FULL_TREE_ROUTES`. Root route map settings live in `src/app-root.ts`. -- **`src/sea-main.ts`** — main script of the standalone binary. `script/build.ts` - embeds the CLI (`src/bin.ts`) and the completion engine (`src/complete-bin.ts`) - as separate SEA assets, and this bootstrap runs one of them through - `src/lib/sea-chunk.ts`, which keeps a V8 code cache for each under - `/cache/code/`. It must import only `node:` builtins and pure - helpers (`test/script/cli-startup.test.ts` lists its modules). +- **`src/chunk-main.ts`** — main script of the npm package (`dist/bin.cjs`) + and the standalone binary. `script/lib/split-bundle.ts` builds the CLI + (`src/bin.ts`) as code-split CommonJS chunks for both: every `import()` and + every lazy `_require()` (telemetry, syntax highlighting, the field-list + parser) starts a chunk, so a command loads only its own code. The + completion engine (`src/complete-bin.ts`) is one file of its own. The main + script runs one of them through `src/lib/chunk-loader.ts`, which reads the + chunks from files next to it (npm) or from SEA assets (binary) and keeps a V8 + code cache for each under `/cache/code/`. It must import only + `node:` builtins and pure helpers (`test/script/cli-startup.test.ts` lists + its modules). `script/lib/chunk-build.ts` holds the build steps both + packages share: debug IDs, sourcemap upload, and the main script. +- **`src/index.ts`** — the library (`createSentrySDK`). It imports no command + and no CLI runner: each SDK call loads only the top-level routes it runs + (`src/sdk-app.ts`, the SDK's `app-for-args.ts`), from the same split chunks + in `dist/index.cjs` and `dist/index.mjs`. - **`src/mcp-server.ts`** — entry point for the standalone `@sentry/mcp-server` bundle (built by `packages/mcp-server/script/bundle.ts`). It must not import anything that only the CLI needs; the bundle script fails on commands, markdown rendering, @@ -506,11 +516,11 @@ search grammar) import it when called, so the fast path only loads it for the arguments that need it. The fast path runs on every TAB press, so it loads only `src/lib/complete.ts` -and what that module imports. `src/bin.ts` and the npm `dist/bin.cjs` check -for `__complete` first and call `runCompletion` without loading `cli.ts`; -the npm package ships it as the separate `dist/complete.cjs` bundle, and the -standalone binary as a separate SEA asset (`src/complete-bin.ts`), so a TAB -press never compiles the CLI bundle. That +and what that module imports. `src/bin.ts` and the main script of both +packages (`src/chunk-main.ts`) check for `__complete` first and run +`runCompletion` without loading `cli.ts`: both ship it as a separate bundle +(`src/complete-bin.ts`, `complete.cjs`) that shares no chunk with the CLI, so a +TAB press never loads a CLI chunk. That graph must stay free of `@sentry/node`, telemetry, the API client, and the command tree: the database modules get spans and cache metrics through `withDbSpan` and `recordCacheHit` from `src/lib/db/index.ts`, which require diff --git a/packages/cli/package.json b/packages/cli/package.json index 98f31ecc3..e28253a69 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,10 +25,8 @@ "sentry": "./dist/bin.cjs" }, "files": [ - "dist/bin.cjs", - "dist/complete.cjs", - "dist/index.cjs", - "dist/index.mjs", + "dist/*.cjs", + "dist/*.mjs", "dist/index.d.cts", "dist/index.d.mts", "dist/ink-app.js", diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index cb433cd62..314762578 100644 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -8,10 +8,12 @@ * Binaries are uploaded to GitHub Releases. * * Uses a two-step build to produce external sourcemaps for Sentry: - * 1. Bundle TS → minified JS (esbuild): the CLI with an external .map, the - * shell-completion engine, and the SEA bootstrap that runs one of them - * 2. Compile JS → native SEA binary per platform (fossilize) - * 3. Upload .map to Sentry for server-side stack trace resolution + * 1. Bundle TS → minified JS (esbuild): the CLI as code-split chunks, the + * shell-completion engine, and the main script that runs them + * (`src/chunk-main.ts`), the same as the npm package's `dist/bin.cjs` + * 2. Compile JS → native SEA binary per platform (fossilize), with every + * chunk embedded as a SEA asset + * 3. Upload the .map files to Sentry for server-side stack trace resolution * * Usage: * pnpm run script/build.ts # Build for all platforms @@ -25,20 +27,30 @@ * sentry-linux-arm64 * sentry-linux-x64 * sentry-windows-x64.exe - * bin.js.map (sourcemap, uploaded to Sentry then deleted) + * dist-build/ (chunks and sourcemaps, embedded and uploaded) */ import { execSync } from "node:child_process"; -import { createHash } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, renameSync } from "node:fs"; import { readFile, rm, stat, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; +import { basename } from "node:path"; import { buildCarExtract } from "./build-car-extract.js"; import { promisify } from "node:util"; import { gzip } from "node:zlib"; import { type BuildOptions, build as esbuild } from "esbuild"; -import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js"; -import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js"; +import { + buildMainScript, + chunkBuild, + type InjectedFile, + injectDebugIds, + uploadChunkSourcemaps, +} from "./lib/chunk-build.js"; +import { + REQUIRE_ALIAS_FILTER, + requireAliasPlugin, +} from "./lib/require-alias-plugin.js"; +import { buildSplitBundle } from "./lib/split-bundle.js"; import { zodEnglishLocalesPlugin } from "./lib/zod-locales-plugin.js"; import { zodV3StubPlugin } from "./lib/zod-v3-stub-plugin.js"; import { textImportPlugin } from "./text-import-plugin.js"; @@ -52,11 +64,6 @@ const VERSION: string = pkg.version; /** Node version for SEA binaries. "lts" resolves to the latest LTS via fossilize. */ const NODE_VERSION = "lts"; -/** Files whose lazy relative imports must be included in the native bundle. */ -const REQUIRE_ALIAS_FILTER = - /(?:db[\\/](?:index|schema)|formatters[\\/](?:highlight|json)|telemetry)\.ts$/; -const REQUIRE_ALIAS_RE = /\b_require\(/g; - /** * Optional OAuth client ID override baked into the binary. When unset, the * binary uses the committed sentry.io default in src/lib/oauth.ts. @@ -96,20 +103,15 @@ function getFossilizePlatform(target: BuildTarget): string { const BUILD_DIR = "dist-build"; /** - * The CLI bundle. Embedded as a SEA asset, so its key is also the filename in - * stack traces and the URL of its uploaded sourcemap. + * The `sentry __complete` bundle. Like every chunk, it is embedded as a SEA + * asset under its path, which is also its filename in stack traces and the + * URL of its uploaded sourcemap. */ -const BUNDLE_JS = `${BUILD_DIR}/bin.js`; - -/** The `sentry __complete` bundle, embedded as a second SEA asset. */ -const COMPLETE_JS = `${BUILD_DIR}/complete.js`; +const COMPLETE_JS = `${BUILD_DIR}/complete.cjs`; -/** The SEA main script, which runs one of the two bundles above. */ +/** The SEA main script, which runs the chunks (`src/chunk-main.ts`). */ const SEA_MAIN_JS = `${BUILD_DIR}/sea-main.js`; -/** Path to the sourcemap produced by Step 1 (bundle). */ -const SOURCEMAP_FILE = `${BUILD_DIR}/bin.js.map`; - /** * esbuild options shared by the CLI and completion bundles. * @@ -120,12 +122,10 @@ const SOURCEMAP_FILE = `${BUILD_DIR}/bin.js.map`; * empty names array). */ const BUNDLE_OPTIONS = { - bundle: true, platform: "node", // Target Node 24 LTS. Downlevels `using` declarations (not // supported in CJS). Node SEA runs embedded JS as CJS. target: "node24", - format: "cjs", treeShaking: true, // Externalize the Ink + React stack from the esbuild bundling // step. The main bundle never calls `import("ink")` at runtime — @@ -149,7 +149,7 @@ const BUNDLE_OPTIONS = { "node-sqlite3-wasm", ], minify: true, - metafile: true, + sourcemap: true, // CJS format needs import.meta.url shimmed via inject + define. inject: ["./script/import-meta-url.js"], define: { @@ -159,7 +159,6 @@ const BUNDLE_OPTIONS = { SENTRY_CLIENT_ID_BUILD: JSON.stringify(SENTRY_CLIENT_ID), }), "process.env.NODE_ENV": JSON.stringify("production"), - __SENTRY_DEBUG_ID__: JSON.stringify(PLACEHOLDER_DEBUG_ID), // Tree-shakes the SDK's runtime module hooks, which can't instrument a // bundle and would hook the host app in library mode. __SENTRY_CHANNEL_INJECTION__: "false", @@ -168,118 +167,92 @@ const BUNDLE_OPTIONS = { textImportPlugin, zodEnglishLocalesPlugin, zodV3StubPlugin, - // Transform _require() → require() so esbuild resolves lazy relative - // requires at bundle time. In tsx dev mode, _require is a file-local - // createRequire(import.meta.url) that resolves relative to the file. - // esbuild only statically resolves bare require() calls. - // Only targets the specific files that use _require with relative paths. - { - name: "require-alias", - setup(b) { - b.onLoad({ filter: REQUIRE_ALIAS_FILTER }, async (args) => { - const source = await readFile(args.path, "utf-8"); - return { - contents: source.replace(REQUIRE_ALIAS_RE, "require("), - loader: args.path.endsWith(".tsx") ? "tsx" : "ts", - }; - }); - }, - }, + // `_require()` → `require()`, so esbuild bundles the lazy requires + requireAliasPlugin, ], } satisfies BuildOptions; -/** Size of a bundle in esbuild's metafile, in MB. */ -async function bundleSizeMb( - outputs: Record | undefined, - file: string, -): Promise { - return ( - (outputs?.[file]?.bytes ?? (await stat(file)).size) / - 1024 / - 1024 - ).toFixed(2); +/** Total size of `files`, in MB. */ +async function sizeMb(files: readonly string[]): Promise { + const sizes = await Promise.all( + files.map(async (file) => (await stat(file)).size), + ); + return (sizes.reduce((a, b) => a + b, 0) / 1024 / 1024).toFixed(2); } +/** The JavaScript files that step 1 writes, all embedded as SEA assets. */ +type Bundle = { + /** Every chunk, including {@link COMPLETE_JS}. */ + readonly files: readonly string[]; + /** The CLI's entry chunk (`src/bin.ts`). */ + readonly cli: string; +}; + /** * Step 1: Bundle TypeScript sources into minified JS files with esbuild: - * the CLI (`src/bin.ts`) with an external sourcemap, and the - * shell-completion engine (`src/complete-bin.ts`). The SEA bootstrap that - * picks one of them is bundled after debug ID injection, because it embeds - * a hash of each (see {@link bundleSeaMain}). + * the CLI (`src/bin.ts`) as code-split chunks, the same way as the npm + * package (`script/lib/split-bundle.ts`), and the shell-completion engine + * (`src/complete-bin.ts`) as one file. Each has a sourcemap, which is + * uploaded to Sentry and never shipped. The main script that runs them is + * bundled after debug ID injection, because it embeds a hash of every chunk + * (see {@link bundleSeaMain}). * - * This runs once and is shared by all compile targets. The sourcemap - * is uploaded to Sentry (never shipped to users) for server-side - * stack trace resolution. + * This runs once and is shared by all compile targets. */ -async function bundleJs(): Promise { - console.log(" Step 1: Bundling TypeScript → JS + sourcemap..."); +async function bundleJs(): Promise { + console.log(" Step 1: Bundling TypeScript → JS + sourcemaps..."); try { - const result = await esbuild({ - ...BUNDLE_OPTIONS, - entryPoints: ["./src/bin.ts"], - outfile: BUNDLE_JS, - sourcemap: "linked", + const cli = await buildSplitBundle({ + entryPoints: { cli: "./src/bin.ts" }, + outdir: BUILD_DIR, + format: "cjs", + lazyRequireImporters: REQUIRE_ALIAS_FILTER, + options: BUNDLE_OPTIONS, }); - // Completion runs with telemetry off and is never symbolicated, so it - // gets no sourcemap. `SENTRY_CLI_COMPLETION_BUILD` drops the lazy - // telemetry `require` (and `@sentry/node`) from `src/lib/db/index.ts`. - const completeResult = await esbuild({ + // Completion runs with telemetry off: `SENTRY_CLI_COMPLETION_BUILD` + // drops the lazy telemetry `require` (and `@sentry/node`) from + // `src/lib/db/index.ts`. + await esbuild({ ...BUNDLE_OPTIONS, entryPoints: ["./src/complete-bin.ts"], + bundle: true, + format: "cjs", outfile: COMPLETE_JS, define: { ...BUNDLE_OPTIONS.define, SENTRY_CLI_COMPLETION_BUILD: "true" }, }); - const mapSize = ((await stat(SOURCEMAP_FILE)).size / 1024 / 1024).toFixed( - 2, - ); - console.log( - ` -> ${BUNDLE_JS} (${await bundleSizeMb(result.metafile?.outputs, BUNDLE_JS)} MB)`, - ); - console.log(` -> ${SOURCEMAP_FILE} (${mapSize} MB, for Sentry upload)`); console.log( - ` -> ${COMPLETE_JS} (${await bundleSizeMb(completeResult.metafile?.outputs, COMPLETE_JS)} MB)`, + ` -> ${BUILD_DIR}/${basename(cli.entries.cli)}: ${cli.files.length} chunks (${await sizeMb(cli.files)} MB)`, ); - return true; + console.log(` -> ${COMPLETE_JS} (${await sizeMb([COMPLETE_JS])} MB)`); + return { files: [...cli.files, COMPLETE_JS], cli: cli.entries.cli }; } catch (error) { console.error(" Failed to bundle JS:"); console.error( ` ${error instanceof Error ? error.message : String(error)}`, ); - return false; + return; } } -/** First 16 hex digits of the SHA-256 of `file`. */ -async function fileHash(file: string): Promise { - return createHash("sha256") - .update(await readFile(file)) - .digest("hex") - .slice(0, 16); -} - /** - * Bundle the SEA main script (`src/sea-main.ts`). It runs after debug ID - * injection so the hash it embeds for the CLI bundle covers its final bytes: - * the hash names each bundle's code cache file, and V8 would otherwise accept - * a cache from another build whose source has the same length. + * Bundle the SEA main script (`src/chunk-main.ts`), which reads the chunks + * back with `node:sea.getAsset()`. It runs after debug ID injection, so the + * build ID it embeds hashes the chunks' final bytes: the ID names the + * directory of their code caches, and V8 would otherwise accept a cache from + * another build whose source has the same length. */ -async function bundleSeaMain(): Promise { +async function bundleSeaMain(bundle: Bundle): Promise { try { - const chunks = { - cli: { asset: BUNDLE_JS, buildId: await fileHash(BUNDLE_JS) }, - complete: { asset: COMPLETE_JS, buildId: await fileHash(COMPLETE_JS) }, - }; - await esbuild({ - entryPoints: ["./src/sea-main.ts"], - bundle: true, + await buildMainScript({ outfile: SEA_MAIN_JS, - platform: "node", + chunks: await chunkBuild(bundle.files, { + cli: basename(bundle.cli), + complete: basename(COMPLETE_JS), + }), + assetDir: BUILD_DIR, target: BUNDLE_OPTIONS.target, - format: "cjs", - minify: true, - define: { SEA_CHUNKS: JSON.stringify(chunks) }, }); console.log(` -> ${SEA_MAIN_JS} (SEA main script)`); return true; @@ -293,103 +266,23 @@ async function bundleSeaMain(): Promise { } /** - * Inject debug IDs and upload sourcemap to Sentry. - * - * Both injection and upload are done natively — no external binary needed. - * Uses the chunk-upload + assemble protocol for reliable artifact delivery. - * - * Requires SENTRY_AUTH_TOKEN environment variable for upload. Debug ID - * injection always runs (even without auth token) so local builds get - * debug IDs for development/testing. + * Upload the sourcemaps to Sentry. Runs after compilation. Each chunk's + * URL is its SEA asset key, the filename of its stack frames. */ -/** Module-level debug ID set by {@link injectDebugIds} for use in {@link uploadSourcemapToSentry}. */ -let currentDebugId: string | undefined; - -/** - * Inject debug IDs into the JS and sourcemap. Runs before compilation. - * The upload happens separately after compilation (see {@link uploadSourcemapToSentry}). - */ -async function injectDebugIds(): Promise { - // skipSnippet: true — the IIFE snippet breaks ESM (placed before import - // declarations). The debug ID is instead registered in constants.ts via - // a build-time __SENTRY_DEBUG_ID__ constant. - console.log(" Injecting debug IDs..."); - try { - const { debugId } = await injectDebugId(BUNDLE_JS, SOURCEMAP_FILE, { - skipSnippet: true, - }); - currentDebugId = debugId; - console.log(` -> Debug ID: ${debugId}`); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - console.warn(` Warning: Debug ID injection failed: ${msg}`); +async function uploadSourcemapsToSentry( + injected: readonly InjectedFile[], +): Promise { + if (injected.length === 0) { return; } - - // Replace the placeholder UUID with the real debug ID in the JS bundle. - // Both are 36-char UUIDs so sourcemap character positions stay valid. - try { - const jsContent = await readFile(BUNDLE_JS, "utf-8"); - await writeFile( - BUNDLE_JS, - jsContent.split(PLACEHOLDER_DEBUG_ID).join(currentDebugId), - ); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - console.warn( - ` Warning: Debug ID placeholder replacement failed: ${msg}`, - ); - } -} - -/** - * Upload the sourcemap to Sentry. Runs after compilation. - */ -async function uploadSourcemapToSentry(): Promise { - const debugId = currentDebugId; - if (!debugId) { - return; - } - if (!process.env.SENTRY_AUTH_TOKEN) { console.log(" No SENTRY_AUTH_TOKEN, skipping sourcemap upload"); return; } - - console.log(` Uploading sourcemap to Sentry (release: ${VERSION})...`); - - try { - const dir = BUNDLE_JS.slice(0, BUNDLE_JS.lastIndexOf("/") + 1); - const urlPrefix = `~/${dir}`; - const jsBasename = BUNDLE_JS.split("/").pop() ?? "bin.js"; - const mapBasename = SOURCEMAP_FILE.split("/").pop() ?? "bin.js.map"; - - await uploadSourcemaps({ - org: "sentry", - project: "cli", - release: VERSION, - files: [ - { - path: BUNDLE_JS, - debugId, - type: "minified_source", - url: `${urlPrefix}${jsBasename}`, - sourcemapFilename: mapBasename, - }, - { - path: SOURCEMAP_FILE, - debugId, - type: "source_map", - url: `${urlPrefix}${mapBasename}`, - }, - ], - }); - console.log(" -> Sourcemap uploaded to Sentry"); - } catch (error) { - // Non-fatal: don't fail the build if upload fails - const msg = error instanceof Error ? error.message : String(error); - console.warn(` Warning: Sourcemap upload failed: ${msg}`); - } + await uploadChunkSourcemaps(injected, { + release: VERSION, + urlPrefix: `~/${BUILD_DIR}/`, + }); } /** @@ -399,12 +292,13 @@ async function uploadSourcemapToSentry(): Promise { */ async function compileAllTargets( targets: BuildTarget[], + bundle: Bundle, ): Promise<{ successes: number; failures: number }> { const platforms = targets.map((t) => getFossilizePlatform(t)); - // The SEA main script (SEA_MAIN_JS) runs one of these two bundles, read - // back with node:sea.getAsset() under the same keys. - const assetArgs: string[] = ["--assets", BUNDLE_JS, "--assets", COMPLETE_JS]; + // The SEA main script (SEA_MAIN_JS) reads the chunks back with + // node:sea.getAsset() under the same keys. + const assetArgs = bundle.files.flatMap((file) => ["--assets", file]); // Add ink sidecar as asset if it exists (pre-bundled by text-import-plugin) // The text-import-plugin pre-bundles the Ink sidecar into BUILD_DIR. @@ -482,7 +376,7 @@ async function compileAllTargets( // Make the binary ignore NODE_OPTIONS so user V8 flags (e.g. // `NODE_OPTIONS=--max-old-space-size=8192`) don't change V8's // flag-hash and reject the code caches ("Code cache data - // rejected"), the embedded one and the ones `src/lib/sea-chunk.ts` + // rejected"), the embedded one and the ones `src/lib/chunk-loader.ts` // writes. process.env is untouched, so child processes still // inherit the user's NODE_OPTIONS. "--ignore-node-options", @@ -621,22 +515,26 @@ async function build(): Promise { console.log(`\nBuilding for ${targets.length} targets`); } - // Clean and recreate output directory (esbuild requires it to exist) + // Clean and recreate output directory (esbuild requires it to exist). + // Chunk names hash their content, so the last build's would stay around. await rm("dist-bin", { recursive: true, force: true }); + await rm(BUILD_DIR, { recursive: true, force: true }); mkdirSync("dist-bin", { recursive: true }); console.log(""); - // Step 1: Bundle TS → JS + sourcemap (shared by all targets) - const bundled = await bundleJs(); - if (!bundled) { + // Step 1: Bundle TS → JS + sourcemaps (shared by all targets) + const bundle = await bundleJs(); + if (!bundle) { process.exit(1); } - // Inject debug IDs into the JS and sourcemap (non-fatal on failure). - await injectDebugIds(); + // Inject debug IDs into the chunks and sourcemaps (non-fatal on failure). + console.log(" Injecting debug IDs..."); + const injected = await injectDebugIds(bundle.files); + console.log(` -> Debug IDs injected into ${injected.length} files`); - if (!(await bundleSeaMain())) { + if (!(await bundleSeaMain(bundle))) { process.exit(1); } @@ -644,10 +542,10 @@ async function build(): Promise { // Step 2: Compile JS → native SEA binary for all targets at once const { successes: successCount, failures: failCount } = - await compileAllTargets(targets); + await compileAllTargets(targets, bundle); - // Step 3: Upload the sourcemap to Sentry (after compilation) - await uploadSourcemapToSentry(); + // Step 3: Upload the sourcemaps to Sentry (after compilation) + await uploadSourcemapsToSentry(injected); // Clean up intermediate build directory (only the binaries are artifacts). // await rm(BUILD_DIR, { recursive: true, force: true }); diff --git a/packages/cli/script/bundle.ts b/packages/cli/script/bundle.ts index 1b7d930f6..97f7b9cc4 100644 --- a/packages/cli/script/bundle.ts +++ b/packages/cli/script/bundle.ts @@ -1,15 +1,32 @@ #!/usr/bin/env tsx import { existsSync } from "node:fs"; -import { copyFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { + chmod, + copyFile, + mkdir, + readFile, + stat, + writeFile, +} from "node:fs/promises"; import { createRequire } from "node:module"; -import { build, type Plugin } from "esbuild"; +import { basename, join } from "node:path"; +import { type BuildOptions, build, type Plugin } from "esbuild"; import pkg from "../package.json"; -import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js"; -import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js"; +import { + buildMainScript, + chunkBuild, + deleteSourcemaps, + injectDebugIds, + uploadChunkSourcemaps, +} from "./lib/chunk-build.js"; +import { + REQUIRE_ALIAS_FILTER, + requireAliasPlugin, +} from "./lib/require-alias-plugin.js"; +import { buildSplitBundle, removeBundleOutputs } from "./lib/split-bundle.js"; import { zodEnglishLocalesPlugin } from "./lib/zod-locales-plugin.js"; import { zodV3StubPlugin } from "./lib/zod-v3-stub-plugin.js"; import { buildCoreDeclarations, SDK_TYPES_PATH } from "./sdk-declarations.js"; -import { requireAliasPlugin } from "./lib/require-alias-plugin.js"; import { textImportPlugin } from "./text-import-plugin.js"; const VERSION = pkg.version; @@ -19,142 +36,6 @@ const SENTRY_CLIENT_ID = process.env.SENTRY_CLIENT_ID; console.log(`\nBundling sentry v${VERSION} for npm`); console.log("=".repeat(40)); -type InjectedFile = { jsPath: string; mapPath: string; debugId: string }; - -/** Delete .map files after a successful upload — they shouldn't ship to users. */ -async function deleteMapFiles(injected: InjectedFile[]): Promise { - for (const { mapPath } of injected) { - try { - await unlink(mapPath); - } catch { - // Ignore — file might already be gone - } - } -} - -/** Inject debug IDs into JS outputs and their companion sourcemaps. */ -async function injectDebugIdsForOutputs( - jsFiles: string[], -): Promise { - const injected: InjectedFile[] = []; - for (const jsPath of jsFiles) { - const mapPath = `${jsPath}.map`; - try { - const { debugId } = await injectDebugId(jsPath, mapPath, { - skipSnippet: true, - }); - injected.push({ jsPath, mapPath, debugId }); - console.log(` Debug ID injected: ${debugId}`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.warn( - ` Warning: Debug ID injection failed for ${jsPath}: ${msg}`, - ); - } - } - return injected; -} - -/** - * Upload injected sourcemaps to Sentry via the chunk-upload protocol. - * - * @returns `true` if upload succeeded, `false` if it failed (non-fatal). - */ -async function uploadInjectedSourcemaps( - injected: InjectedFile[], -): Promise { - try { - console.log(" Uploading sourcemaps to Sentry..."); - await uploadSourcemaps({ - org: "sentry", - project: "cli", - release: VERSION, - files: injected.flatMap(({ jsPath, mapPath, debugId }) => { - const jsName = jsPath.split("/").pop() ?? "bin.cjs"; - const mapName = mapPath.split("/").pop() ?? "bin.cjs.map"; - return [ - { - path: jsPath, - debugId, - type: "minified_source" as const, - url: `~/${jsName}`, - sourcemapFilename: mapName, - }, - { - path: mapPath, - debugId, - type: "source_map" as const, - url: `~/${mapName}`, - }, - ]; - }), - }); - console.log(" Sourcemaps uploaded to Sentry"); - return true; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(` Warning: Sourcemap upload failed: ${msg}`); - return false; - } -} - -/** - * esbuild plugin that injects debug IDs and uploads sourcemaps to Sentry. - * - * Runs after esbuild finishes bundling (onEnd hook): - * 1. Injects debug IDs into each JS output + its companion .map - * 2. Uploads all artifacts to Sentry via the chunk-upload protocol - * 3. Deletes .map files after upload (they shouldn't ship to users) - * - * Replaces `@sentry/esbuild-plugin` with zero external dependencies. - */ -const sentrySourcemapPlugin: Plugin = { - name: "sentry-sourcemap", - setup(pluginBuild) { - pluginBuild.onEnd(async (buildResult) => { - const outputs = Object.keys(buildResult.metafile?.outputs ?? {}); - const jsFiles = outputs.filter( - (p) => - p.endsWith(".cjs") || - p.endsWith(".mjs") || - (p.endsWith(".js") && !p.endsWith(".map")), - ); - - if (jsFiles.length === 0) { - return; - } - - const injected = await injectDebugIdsForOutputs(jsFiles); - if (injected.length === 0) { - return; - } - - // Replace the placeholder UUID with the real debug ID in each JS output. - // Both are 36-char UUIDs so sourcemap character positions stay valid. - for (const { jsPath, debugId } of injected) { - const content = await readFile(jsPath, "utf-8"); - await writeFile( - jsPath, - content.split(PLACEHOLDER_DEBUG_ID).join(debugId), - ); - } - - if (!process.env.SENTRY_AUTH_TOKEN) { - return; - } - - const uploaded = await uploadInjectedSourcemaps(injected); - - // Only delete .map files after a successful upload — preserving - // them on failure allows retrying without a full rebuild. - if (uploaded) { - await deleteMapFiles(injected); - } - }); - }, -}; - -// Always inject debug IDs (even without auth token); upload is gated inside the plugin const IDENTIFIER_FILTER_RE = /^[A-Za-z_$][\w$]*$/; // Match both the prefixed and bare specifiers — bundled dependencies (e.g. // binpatch) import from "zlib", which esbuild also treats as external builtin. @@ -222,7 +103,6 @@ const zlibNamespaceShimPlugin: Plugin = { }; const plugins: Plugin[] = [ - sentrySourcemapPlugin, zodEnglishLocalesPlugin, zodV3StubPlugin, textImportPlugin, @@ -237,15 +117,15 @@ if (process.env.SENTRY_AUTH_TOKEN) { ); } -// Options shared by both the CJS and ESM library builds. Only the module -// format, output path, and format-specific interop shims differ between them. +// Options shared by the CJS and ESM builds. Only the module format, the +// entry points, and format-specific interop shims differ between them. const commonBuildOptions = { - entryPoints: ["./src/index.ts"], bundle: true, minify: true, treeShaking: true, - // No banner (beyond format shims) — warning suppression moved to dist/bin.cjs - // (CLI-only). The library bundle must not suppress the host app's warnings. + // No banner (beyond format shims) — warning suppression lives in + // dist/bin.cjs (CLI-only). The library must not suppress the host app's + // warnings. sourcemap: true, platform: "node", // Target Node.js 18 — the published package's floor (engines.node). @@ -273,9 +153,8 @@ const commonBuildOptions = { "react-devtools-core", "yoga-layout", ], - metafile: true, plugins, -} satisfies Parameters[0]; +} satisfies BuildOptions; // Defines shared by both formats. The CJS build additionally rewrites // `import.meta.url` to a shim; the ESM build uses it natively. @@ -285,7 +164,6 @@ const commonDefine: Record = { SENTRY_CLIENT_ID_BUILD: JSON.stringify(SENTRY_CLIENT_ID), }), "process.env.NODE_ENV": JSON.stringify("production"), - __SENTRY_DEBUG_ID__: JSON.stringify(PLACEHOLDER_DEBUG_ID), // Tree-shakes the SDK's runtime module hooks, which can't instrument a // bundle and would hook the host app in library mode. __SENTRY_CHANNEL_INJECTION__: "false", @@ -294,6 +172,13 @@ const commonDefine: Record = { SENTRY_CLI_COMPLETION_BUILD: "false", }; +/** Options of the CommonJS builds, which shim `import.meta.url`. */ +const cjsBuildOptions = { + ...commonBuildOptions, + inject: ["./script/import-meta-url.js"], + define: { ...commonDefine, "import.meta.url": "import_meta_url" }, +} satisfies BuildOptions; + // In an ESM bundle, `require`, `__dirname`, and `__filename` don't exist, but // the inlined `node-sqlite3-wasm` Emscripten glue (and other lazily-required // modules) still reference them. Recreate them from `import.meta.url` so the @@ -307,54 +192,85 @@ const ESM_INTEROP_BANNER = [ "const __dirname = __sentryDirname(__filename);", ].join("\n"); -const result = await build({ - ...commonBuildOptions, +const DIST = "./dist"; + +await removeBundleOutputs(DIST); + +// The CLI (`dist/cli.cjs`, which `dist/bin.cjs` runs) and the CommonJS +// library (`dist/index.cjs`) share their chunks, and every command is a +// chunk of its own, so a run loads only the code it uses. +const cjs = await buildSplitBundle({ + entryPoints: { cli: "./src/bin.ts", index: "./src/index.ts" }, + outdir: DIST, format: "cjs", - outfile: "./dist/index.cjs", - // Inject the import.meta.url shim for CJS compatibility. - inject: ["./script/import-meta-url.js"], - define: { - ...commonDefine, - // Replace import.meta.url with the injected shim variable for CJS - "import.meta.url": "import_meta_url", - }, + lazyRequireImporters: REQUIRE_ALIAS_FILTER, + options: cjsBuildOptions, }); -// `sentry __complete` runs on every TAB press, so `dist/bin.cjs` loads this -// small bundle of `runCompletion` instead of the whole CLI in `index.cjs`. -const completeResult = await build({ - ...commonBuildOptions, - entryPoints: ["./src/lib/complete.ts"], +// `sentry __complete` runs on every TAB press, so it gets a single small +// file that shares nothing with the CLI's chunks. +const COMPLETE_CJS = join(DIST, "complete.cjs"); +await build({ + ...cjsBuildOptions, + entryPoints: ["./src/complete-bin.ts"], format: "cjs", - outfile: "./dist/complete.cjs", - inject: ["./script/import-meta-url.js"], - define: { - ...commonDefine, - SENTRY_CLI_COMPLETION_BUILD: "true", - "import.meta.url": "import_meta_url", - }, + outfile: COMPLETE_CJS, + define: { ...cjsBuildOptions.define, SENTRY_CLI_COMPLETION_BUILD: "true" }, }); -const esmResult = await build({ - ...commonBuildOptions, +// The ES module library (`dist/index.mjs`): split the same way, so an app +// that imports the SDK loads only the commands it calls. +const esm = await buildSplitBundle({ + entryPoints: { index: "./src/index.ts" }, + outdir: DIST, format: "esm", - outfile: "./dist/index.mjs", - // ESM has native import.meta.url and needs no injected shims. - define: { ...commonDefine }, - banner: { js: ESM_INTEROP_BANNER }, - // ESM-only: keep node:zlib access dynamic so version-gated zstd exports don't - // become link-time-fatal named imports on Node < 22.15 (see plugin comment). - plugins: [...commonBuildOptions.plugins, zlibNamespaceShimPlugin], + options: { + ...commonBuildOptions, + // ESM has native import.meta.url and needs no injected shims. + define: commonDefine, + banner: { js: ESM_INTEROP_BANNER }, + // ESM-only: keep node:zlib access dynamic so version-gated zstd exports + // don't become link-time-fatal named imports on Node < 22.15 (see the + // plugin comment). + plugins: [...plugins, zlibNamespaceShimPlugin], + }, }); -// Write the CLI bin wrapper (tiny — shebang + version check + dispatch). -// Version floor must track `engines.node` in package.json. -const BIN_WRAPPER = `#!/usr/bin/env node +const cjsFiles = [...cjs.files, COMPLETE_CJS]; +const jsFiles = [...cjsFiles, ...esm.files]; + +// Always inject debug IDs (even without auth token); upload needs a token. +const injected = await injectDebugIds(jsFiles); +console.log(` Debug IDs injected into ${injected.length} files`); +if (process.env.SENTRY_AUTH_TOKEN && injected.length > 0) { + // Only delete .map files after a successful upload — preserving them on + // failure allows retrying without a full rebuild. + if ( + await uploadChunkSourcemaps(injected, { release: VERSION, urlPrefix: "~/" }) + ) { + await deleteSourcemaps(injected); + } +} + +// The CLI bin: shebang, version check, warning suppression, then +// `src/chunk-main.ts`, which runs `cli.cjs` or `complete.cjs` with a code +// cache. Built after debug ID injection, because the build ID it embeds +// hashes the chunks' final content. Version floor must track `engines.node` +// in package.json. +const BIN_BANNER = `#!/usr/bin/env node {let v=process.versions.node.split(".").map(Number);if(v[0]<18){console.error("Error: sentry requires Node.js 18 or later (found "+process.version+").\\n\\nEither upgrade Node.js, or install the standalone binary instead:\\n curl -fsSL https://cli.sentry.dev/install | bash\\n");process.exit(1)}} -{let e=process.emit;process.emit=function(n,...a){return n==="warning"?!1:e.apply(this,[n,...a])}} -process.argv[2]==="__complete"?require('./complete.cjs').runCompletion(process.argv.slice(3)).catch(()=>{process.exitCode=0}):require('./index.cjs')._cli().catch(()=>{process.exitCode=1}); -`; -await writeFile("./dist/bin.cjs", BIN_WRAPPER); +{let e=process.emit;process.emit=function(n,...a){return n==="warning"?!1:e.apply(this,[n,...a])}}`; +const BIN_CJS = join(DIST, "bin.cjs"); +await buildMainScript({ + outfile: BIN_CJS, + chunks: await chunkBuild(cjsFiles, { + cli: basename(cjs.entries.cli), + complete: basename(COMPLETE_CJS), + }), + target: commonBuildOptions.target, + banner: BIN_BANNER, +}); +await chmod(BIN_CJS, 0o755); // Write TypeScript declarations for the library API. // The SentrySDK type is read from sdk.generated.d.cts (produced by generate-sdk.ts), and @@ -373,7 +289,7 @@ const TYPE_DECLARATIONS = `${CORE_DECLARATIONS}\n${sdkTypes}\n`; await writeFile("./dist/index.d.cts", TYPE_DECLARATIONS); await writeFile("./dist/index.d.mts", TYPE_DECLARATIONS); -console.log(" -> dist/bin.cjs (CLI wrapper)"); +console.log(" -> dist/bin.cjs (CLI main script)"); console.log(" -> dist/index.d.cts + dist/index.d.mts (type declarations)"); // The `ink-app.js` sidecar (pre-bundled by text-import-plugin) ships @@ -384,7 +300,7 @@ console.log(" -> dist/index.d.cts + dist/index.d.mts (type declarations)"); // Ship the DIF parser WASM as a sibling of the bundle. The runtime loads it // (non-SEA path) via new URL("./vendor/symbolic_bg.wasm", import.meta.url), -// which resolves relative to dist/index.cjs (see src/lib/dif/index.ts). +// which resolves relative to the chunk in dist/ (see src/lib/dif/index.ts). await mkdir("./dist/vendor", { recursive: true }); await copyFile( // Resolve via module resolution rather than a fixed node_modules path: @@ -396,7 +312,7 @@ console.log(" -> dist/vendor/symbolic_bg.wasm (DIF parser)"); // Ship the Spleen glyph subset next to the npm bundle. The formatter resolves // it via new URL("./assets/spleen-8x16.bin", import.meta.url), relative to -// dist/index.cjs (see src/lib/formatters/spleen-font.ts). +// the chunk in dist/ (see src/lib/formatters/spleen-font.ts). await mkdir("./dist/assets", { recursive: true }); await copyFile( "./src/lib/formatters/assets/spleen-8x16.bin", @@ -405,7 +321,7 @@ await copyFile( console.log(" -> dist/assets/spleen-8x16.bin (dashboard font)"); // Ship the WASM SQLite driver's .wasm next to the bundle. The driver JS -// (node-sqlite3-wasm) is inlined into dist/index.cjs by esbuild, and its +// (node-sqlite3-wasm) is a chunk in dist/ (lazy-node-sqlite3-wasm.cjs), and its // Emscripten glue locates the .wasm via `__dirname + "/node-sqlite3-wasm.wasm"`. // Once bundled, `__dirname` is `dist/`, so the file must sit at // dist/node-sqlite3-wasm.wasm. Only loaded on Node.js < 22.15 (see @@ -428,19 +344,20 @@ if (!existsSync(sqliteWasmSrc)) { await copyFile(sqliteWasmSrc, "./dist/node-sqlite3-wasm.wasm"); console.log(" -> dist/node-sqlite3-wasm.wasm (WASM SQLite fallback)"); -// Calculate bundle size (only the main bundle, not source maps) -const bundleSizeKB = ( - (result.metafile?.outputs["dist/index.cjs"]?.bytes ?? 0) / 1024 -).toFixed(1); -const completeBundleSizeKB = ( - (completeResult.metafile?.outputs["dist/complete.cjs"]?.bytes ?? 0) / 1024 -).toFixed(1); -const esmBundleSizeKB = ( - (esmResult.metafile?.outputs["dist/index.mjs"]?.bytes ?? 0) / 1024 -).toFixed(1); +/** Total size of `files` in KB. */ +async function sizeKb(files: readonly string[]): Promise { + const sizes = await Promise.all( + files.map(async (file) => (await stat(file)).size), + ); + return (sizes.reduce((a, b) => a + b, 0) / 1024).toFixed(1); +} -console.log(`\n -> dist/index.cjs (${bundleSizeKB} KB)`); -console.log(` -> dist/index.mjs (${esmBundleSizeKB} KB)`); -console.log(` -> dist/complete.cjs (${completeBundleSizeKB} KB)`); +console.log( + `\n -> dist/cli.cjs + dist/index.cjs: ${cjs.files.length} files (${await sizeKb(cjs.files)} KB)`, +); +console.log( + ` -> dist/index.mjs: ${esm.files.length} files (${await sizeKb(esm.files)} KB)`, +); +console.log(` -> dist/complete.cjs (${await sizeKb([COMPLETE_CJS])} KB)`); console.log(`\n${"=".repeat(40)}`); console.log("Bundle complete!"); diff --git a/packages/cli/script/debug-id.ts b/packages/cli/script/debug-id.ts index 47b39e2d3..612b4b439 100644 --- a/packages/cli/script/debug-id.ts +++ b/packages/cli/script/debug-id.ts @@ -10,13 +10,3 @@ export { getDebugIdSnippet, injectDebugId, } from "../src/lib/sourcemap/debug-id.js"; - -/** - * Placeholder UUID used by esbuild's `define` for `__SENTRY_DEBUG_ID__`. - * - * After esbuild finishes and the real debug ID is computed from the - * minified JS + sourcemap content hash, this placeholder is replaced in the - * JS output via `String.replaceAll`. The placeholder is exactly 36 characters - * (standard UUID length) so character positions in the sourcemap stay valid. - */ -export const PLACEHOLDER_DEBUG_ID = "deb00000-de60-4d00-a000-000000000000"; diff --git a/packages/cli/script/lib/chunk-build.ts b/packages/cli/script/lib/chunk-build.ts new file mode 100644 index 000000000..aa695aa56 --- /dev/null +++ b/packages/cli/script/lib/chunk-build.ts @@ -0,0 +1,158 @@ +/** + * Steps after `buildSplitBundle()` (`./split-bundle.ts`) that the npm package + * (`script/bundle.ts`) and the standalone binary (`script/build.ts`) share: + * debug IDs and sourcemap upload for every chunk, and the CLI's main script + * (`src/chunk-main.ts`), which runs the chunks with a code cache. + */ + +import { createHash } from "node:crypto"; +import { readFile, unlink } from "node:fs/promises"; +import { basename } from "node:path"; +import { build } from "esbuild"; +import { uploadSourcemaps } from "../../src/lib/api/sourcemaps.js"; +import type { MainChunkBuild } from "../../src/chunk-main.js"; +import { injectDebugId } from "../debug-id.js"; + +/** A JavaScript file with a debug ID, and its sourcemap. */ +export type InjectedFile = { + readonly jsPath: string; + readonly mapPath: string; + readonly debugId: string; +}; + +/** + * Inject a debug ID into each file of `jsFiles` and its `.map`. Each file + * registers its own ID at runtime with the snippet `injectDebugId` adds, so + * stack frames of every chunk resolve. A failure only warns: the build still + * works, its stack traces just don't resolve. + */ +export async function injectDebugIds( + jsFiles: readonly string[], +): Promise { + const injected = await Promise.all( + jsFiles.map(async (jsPath): Promise => { + const mapPath = `${jsPath}.map`; + try { + const { debugId } = await injectDebugId(jsPath, mapPath); + return { jsPath, mapPath, debugId }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn( + ` Warning: Debug ID injection failed for ${jsPath}: ${msg}`, + ); + return; + } + }), + ); + return injected.filter((file) => file !== undefined); +} + +/** + * Upload the sourcemaps of `injected` to the `sentry/cli` project. A chunk's + * URL is `urlPrefix` and its file name: the filename its stack frames have. + * + * @returns Whether the upload succeeded. A failure only warns. + */ +export async function uploadChunkSourcemaps( + injected: readonly InjectedFile[], + options: { readonly release: string; readonly urlPrefix: string }, +): Promise { + try { + console.log(` Uploading ${injected.length} sourcemaps to Sentry...`); + await uploadSourcemaps({ + org: "sentry", + project: "cli", + release: options.release, + files: injected.flatMap(({ jsPath, mapPath, debugId }) => [ + { + path: jsPath, + debugId, + type: "minified_source" as const, + url: `${options.urlPrefix}${basename(jsPath)}`, + sourcemapFilename: basename(mapPath), + }, + { + path: mapPath, + debugId, + type: "source_map" as const, + url: `${options.urlPrefix}${basename(mapPath)}`, + }, + ]), + }); + console.log(" Sourcemaps uploaded to Sentry"); + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(` Warning: Sourcemap upload failed: ${msg}`); + return false; + } +} + +/** Delete the sourcemaps of `injected`, which don't ship to users. */ +export async function deleteSourcemaps( + injected: readonly InjectedFile[], +): Promise { + await Promise.all( + injected.map(({ mapPath }) => unlink(mapPath).catch(() => undefined)), + ); +} + +/** + * Describe the chunks in `files`. Their ID hashes their final content, so + * call it after debug ID injection. + */ +export async function chunkBuild( + files: readonly string[], + entries: MainChunkBuild["entries"], +): Promise { + const sorted = [...files].sort(); + const hash = createHash("sha256"); + for (const file of sorted) { + hash + .update(basename(file)) + .update("\0") + .update(await readFile(file)); + } + return { + id: hash.digest("hex").slice(0, 16), + chunks: sorted.map((file) => basename(file)), + entries, + }; +} + +/** What {@link buildMainScript} builds. */ +export type MainScriptOptions = { + readonly outfile: string; + /** The chunks the main script runs, from {@link chunkBuild}. */ + readonly chunks: MainChunkBuild; + /** + * Key prefix of the chunks' SEA assets (`dist-build`). Without it, the + * chunks are files next to the main script. + */ + readonly assetDir?: string; + readonly target: string; + readonly banner?: string; +}; + +/** Bundle the CLI's main script, `src/chunk-main.ts`. */ +export async function buildMainScript( + options: MainScriptOptions, +): Promise { + await build({ + entryPoints: ["./src/chunk-main.ts"], + bundle: true, + minify: true, + platform: "node", + target: options.target, + format: "cjs", + outfile: options.outfile, + banner: options.banner ? { js: options.banner } : undefined, + define: { + CHUNK_BUILD: JSON.stringify(options.chunks), + CHUNK_ASSET_DIR: + options.assetDir === undefined + ? "undefined" + : JSON.stringify(options.assetDir), + }, + }); +} diff --git a/packages/cli/script/lib/require-alias-plugin.ts b/packages/cli/script/lib/require-alias-plugin.ts index f444b5884..d97e2d1c2 100644 --- a/packages/cli/script/lib/require-alias-plugin.ts +++ b/packages/cli/script/lib/require-alias-plugin.ts @@ -12,7 +12,7 @@ import type { Plugin } from "esbuild"; * `formatters/json` loads its generated parser only when --fields is parsed. * `formatters/highlight` loads cli-highlight only on the first code block. */ -const REQUIRE_ALIAS_FILTER = +export const REQUIRE_ALIAS_FILTER = /(?:db[\\/](?:index|schema|sqlite)|formatters[\\/](?:highlight|json)|telemetry)\.ts$/; const REQUIRE_ALIAS_RE = /\b_require\(/g; diff --git a/packages/cli/script/lib/split-bundle.ts b/packages/cli/script/lib/split-bundle.ts new file mode 100644 index 000000000..50446e045 --- /dev/null +++ b/packages/cli/script/lib/split-bundle.ts @@ -0,0 +1,394 @@ +/** + * Code-split builds of the CLI, shared by the npm package (`script/bundle.ts`) + * and the standalone binary (`script/build.ts`). + * + * esbuild only splits ESM, so a CommonJS build takes two passes: + * + * 1. Bundle the entry points as ESM with splitting, and write the result to + * a scratch directory. Every `import()` becomes its own chunk, and so does + * every module that a lazy `require()` loads (see {@link lazyRequirePlugin}): + * the CLI loads telemetry, syntax highlighting, the `--fields` parser, and + * the WASM SQLite driver with `require()` on first use, so they stay out of + * the chunks a command loads at startup. + * 2. Convert each chunk to CommonJS without bundling. esbuild reads the + * sourcemaps of pass 1, so the final sourcemaps point at the sources. + * + * An ESM build (the library's `index.mjs`) is a single pass with splitting. + * + * Chunks require each other by file name (`require("./chunk-ABC.cjs")`), so + * Node loads them from the package directory, and `src/lib/chunk-loader.ts` + * resolves them from files or SEA assets. + */ + +import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; +import { isBuiltin } from "node:module"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { + type BuildOptions, + type BuildResult, + build, + type Metafile, + type Plugin, +} from "esbuild"; + +/** What {@link buildSplitBundle} builds. */ +export type SplitBundleOptions = { + /** Entry modules by output name: `{ cli: "./src/bin.ts" }` writes `cli.cjs`. */ + readonly entryPoints: Readonly>; + readonly outdir: string; + /** `cjs` writes `.cjs` files, `esm` writes `.mjs` files. */ + readonly format: "cjs" | "esm"; + /** + * CommonJS only: files whose `require()` calls of bundled modules are lazy, + * the ones the `require-alias` plugin rewrites. Each module they require + * becomes an entry point of its own. + */ + readonly lazyRequireImporters?: RegExp; + /** esbuild options for every pass: target, define, external, plugins, ... */ + readonly options: BuildOptions; +}; + +/** The files {@link buildSplitBundle} wrote. */ +export type SplitBundle = { + /** The output file of each entry point, by name. */ + readonly entries: Readonly>; + /** Every JavaScript file written: entry points, lazy modules, and chunks. */ + readonly files: readonly string[]; +}; + +/** Namespace of the generated entry points of lazily required modules. */ +const LAZY_NAMESPACE = "sentry-lazy-require"; +/** Prefix of their entry point specifiers, before the module's path. */ +const LAZY_ENTRY_PREFIX = `${LAZY_NAMESPACE}:`; +const LAZY_ENTRY_RE = new RegExp(`^${LAZY_ENTRY_PREFIX}`); + +const ANY_RE = /.*/; + +/** Characters that may not appear in an entry point name. */ +const UNSAFE_NAME_RE = /[^\w-]+/g; +const EXTENSION_RE = /\.[cm]?[jt]sx?$/; + +/** + * Prefix of the scratch directory of pass 1, next to the output directory + * (`.split-dist` for `dist`). Ignored by git. + */ +const SCRATCH_PREFIX = ".split-"; + +/** Helper that a lowered `import()` of a chunk calls, see {@link lowerChunkImports}. */ +const LOAD_HELPER = "__load"; + +/** + * Defines {@link LOAD_HELPER} in each CommonJS chunk. Same result as + * `import()` of a chunk: a promise of its exports. A function declaration + * is hoisted, so it can go after the code, where it leaves the chunk's + * `"use strict"` directive and line numbers alone. + */ +const LOAD_HELPER_FOOTER = `function ${LOAD_HELPER}(s){return Promise.resolve().then(()=>require(s))}`; + +/** JavaScript files of an earlier build, and their sourcemaps. */ +const BUNDLE_OUTPUT_RE = /\.[cm]js(?:\.map)?$/; + +/** + * Lazily required modules become entry points named after the specifier: + * `require("cli-highlight")` loads `lazy-cli-highlight.cjs`. + */ +function lazyEntryName(specifier: string, taken: ReadonlySet): string { + const base = `lazy-${basename(specifier).replace(EXTENSION_RE, "").replace(UNSAFE_NAME_RE, "-")}`; + let name = base; + for (let index = 2; taken.has(name); index += 1) { + name = `${base}-${index}`; + } + return name; +} + +/** + * Turn lazy `require()` calls into split points. + * + * A `require()` from a file matching `importers` that resolves to a bundled + * module becomes `require("./lazy-.cjs")`: the module is an entry + * point of its own, which re-exports it as its default export (pass 2 turns + * that back into `module.exports`). Modules not yet in `lazyEntries` are + * added to `discovered`, and the caller builds again with them. + */ +function lazyRequirePlugin( + importers: RegExp, + lazyEntries: ReadonlyMap, + discovered: Map, +): Plugin { + return { + name: "lazy-require", + setup(pluginBuild) { + pluginBuild.onResolve({ filter: ANY_RE }, async (args) => { + if ( + args.kind !== "require-call" || + args.namespace === LAZY_NAMESPACE || + args.pluginData === LAZY_NAMESPACE || + isBuiltin(args.path) || + !importers.test(args.importer) + ) { + return; + } + const resolved = await pluginBuild.resolve(args.path, { + kind: args.kind, + importer: args.importer, + resolveDir: args.resolveDir, + pluginData: LAZY_NAMESPACE, + }); + if (resolved.errors.length > 0 || resolved.external) { + return; + } + const name = lazyEntries.get(resolved.path); + if (name === undefined) { + discovered.set(resolved.path, args.path); + return; + } + return { path: `./${name}.cjs`, external: true }; + }); + + // The entry point of a lazily required module, by absolute path + pluginBuild.onResolve({ filter: LAZY_ENTRY_RE }, (args) => + args.kind === "entry-point" + ? { + path: args.path.slice(LAZY_ENTRY_PREFIX.length), + namespace: LAZY_NAMESPACE, + } + : undefined, + ); + pluginBuild.onLoad( + { filter: ANY_RE, namespace: LAZY_NAMESPACE }, + (args) => ({ + contents: `export default require(${JSON.stringify(args.path)});`, + resolveDir: dirname(args.path), + loader: "js", + }), + ); + }, + }; +} + +/** + * Pass 1 of a CommonJS build: ESM chunks with `.cjs` names, in memory. + * + * Builds for `options.outdir`, so plugins that write next to the output + * (the Ink sidecar) write there, and builds again until every lazily + * required module is an entry point. + */ +async function buildEsmChunks(options: SplitBundleOptions): Promise<{ + result: BuildResult<{ metafile: true; write: false }>; + lazyNames: ReadonlySet; +}> { + const importers = options.lazyRequireImporters; + const lazyEntries = new Map(); + for (;;) { + const discovered = new Map(); + const lazyPoints = Object.fromEntries( + [...lazyEntries].map(([path, name]) => [ + name, + `${LAZY_ENTRY_PREFIX}${path}`, + ]), + ); + const result = await build({ + ...options.options, + entryPoints: { ...options.entryPoints, ...lazyPoints }, + bundle: true, + format: "esm", + splitting: true, + outdir: options.outdir, + entryNames: "[name]", + chunkNames: "chunk-[hash]", + outExtension: { ".js": ".cjs" }, + metafile: true, + write: false, + plugins: [ + ...(importers + ? [lazyRequirePlugin(importers, lazyEntries, discovered)] + : []), + ...(options.options.plugins ?? []), + ], + }); + if (discovered.size === 0) { + return { result, lazyNames: new Set(lazyEntries.values()) }; + } + for (const [path, specifier] of discovered) { + lazyEntries.set( + path, + lazyEntryName(specifier, new Set(lazyEntries.values())), + ); + } + } +} + +/** + * Replace each `import("./chunk-ABC.cjs")` in a pass 1 output with + * `__load("./chunk-ABC.cjs")`, which `require`s the chunk once it is + * CommonJS. Other `import()` calls, like the one that loads the Ink UI (an + * ES module with top-level await), stay as they are. + * + * `__load` is as long as `import`, so the sourcemap of pass 1 stays valid. + * Every chunk the metafile says `file` imports dynamically must be replaced. + */ +function lowerChunkImports( + file: string, + content: string, + output: Metafile["outputs"][string] | undefined, +): string { + // The metafile lists a chunk once per `import()` of it + const targets = new Set( + (output?.imports ?? []) + .filter((entry) => entry.kind === "dynamic-import" && !entry.external) + .map((entry) => basename(entry.path)), + ); + let lowered = content; + for (const target of targets) { + const call = `import("./${target}")`; + if (!lowered.includes(call)) { + throw new Error(`Could not find ${call} in ${file}`); + } + lowered = lowered.replaceAll( + call, + `${LOAD_HELPER}${call.slice("import".length)}`, + ); + } + return lowered; +} + +/** + * Write the pass 1 output to `scratch`, a sibling of `outdir`: at the same + * depth, its sourcemaps' relative `sources` still resolve. + */ +async function writeEsmChunks( + options: SplitBundleOptions, + result: BuildResult<{ metafile: true; write: false }>, + scratch: string, +): Promise { + const outdir = resolve(options.outdir); + const outputs = new Map( + Object.entries(result.metafile.outputs).map(([file, output]) => [ + resolve(file), + output, + ]), + ); + await mkdir(scratch, { recursive: true }); + const files: string[] = []; + for (const file of result.outputFiles) { + const target = join(scratch, relative(outdir, file.path)); + if (file.path.endsWith(".map")) { + await writeFile(target, file.contents); + continue; + } + await writeFile( + target, + lowerChunkImports(file.path, file.text, outputs.get(file.path)), + ); + files.push(target); + } + return files; +} + +/** Pass 2 of a CommonJS build: convert the ESM chunks in `files` to CommonJS. */ +async function convertToCommonJs( + options: SplitBundleOptions, + files: string[], + outbase: string, + footer?: string, +): Promise { + if (files.length === 0) { + return []; + } + const { target, minify, sourcemap, logLevel } = options.options; + const result = await build({ + entryPoints: files, + outbase, + outdir: options.outdir, + bundle: false, + platform: "node", + format: "cjs", + target, + minify, + sourcemap, + logLevel, + loader: { ".cjs": "js" }, + outExtension: { ".js": ".cjs" }, + footer: { + js: footer ? `${LOAD_HELPER_FOOTER}\n${footer}` : LOAD_HELPER_FOOTER, + }, + metafile: true, + }); + return Object.keys(result.metafile.outputs).filter((file) => + file.endsWith(".cjs"), + ); +} + +/** Files written by a build, without sourcemaps. */ +function jsOutputs(metafile: Metafile): string[] { + return Object.keys(metafile.outputs).filter((file) => !file.endsWith(".map")); +} + +/** + * Delete the JavaScript files and sourcemaps in `dir`. Chunk names hash + * their content, so a build does not overwrite the chunks of the last one. + */ +export async function removeBundleOutputs(dir: string): Promise { + for (const entry of await readdir(dir).catch(() => [])) { + if (BUNDLE_OUTPUT_RE.test(entry)) { + await rm(join(dir, entry), { force: true }); + } + } +} + +/** Build `options.entryPoints` into split chunks, see the module comment. */ +export async function buildSplitBundle( + options: SplitBundleOptions, +): Promise> { + const extension = options.format === "cjs" ? ".cjs" : ".mjs"; + const entries = Object.fromEntries( + Object.keys(options.entryPoints).map((name) => [ + name, + join(options.outdir, `${name}${extension}`), + ]), + ) as Record; + + if (options.format === "esm") { + const result = await build({ + ...options.options, + entryPoints: options.entryPoints, + bundle: true, + format: "esm", + splitting: true, + outdir: options.outdir, + entryNames: "[name]", + chunkNames: "chunk-[hash]", + outExtension: { ".js": ".mjs" }, + metafile: true, + }); + return { entries, files: jsOutputs(result.metafile) }; + } + + const scratch = join( + dirname(options.outdir), + `${SCRATCH_PREFIX}${basename(options.outdir)}`, + ); + await rm(scratch, { recursive: true, force: true }); + try { + const { result, lazyNames } = await buildEsmChunks(options); + const esmFiles = await writeEsmChunks(options, result, scratch); + const isLazy = (file: string) => lazyNames.has(basename(file, ".cjs")); + const files = [ + ...(await convertToCommonJs( + options, + esmFiles.filter((file) => !isLazy(file)), + scratch, + )), + // A lazily required module's entry point exports it as `default` + ...(await convertToCommonJs( + options, + esmFiles.filter(isLazy), + scratch, + "module.exports=module.exports.default;", + )), + ]; + return { entries, files }; + } finally { + await rm(scratch, { recursive: true, force: true }); + } +} diff --git a/packages/cli/src/chunk-main.ts b/packages/cli/src/chunk-main.ts new file mode 100644 index 000000000..a18eee779 --- /dev/null +++ b/packages/cli/src/chunk-main.ts @@ -0,0 +1,99 @@ +/** + * Main script of the CLI: `dist/bin.cjs` in the npm package, and the SEA main + * script of the standalone binary. + * + * Both ship the CLI as the same code-split CommonJS chunks (see + * `script/lib/split-bundle.ts`). This picks the entry chunk, the CLI + * (`bin.ts`) or the shell-completion engine (`complete-bin.ts`), and runs it + * through `lib/chunk-loader.ts`, which loads the chunks it requires from the + * files next to this script or from SEA assets, with a V8 code cache under + * the config directory. + * + * Built by `script/bundle.ts` and `script/build.ts`, which define + * `CHUNK_BUILD` and, for the binary, `CHUNK_ASSET_DIR`. Imports only `node:` + * builtins and pure helpers: it runs before every command. + */ + +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + type ChunkBuild, + type ChunkSource, + canLoadChunks, + createChunkLoader, + reportCodeCacheError, +} from "./lib/chunk-loader.js"; +import { resolveConfigDir } from "./lib/config-dir.js"; + +/** The chunks of a build, and the entry chunk of each program. */ +export type MainChunkBuild = ChunkBuild & { + readonly entries: { readonly cli: string; readonly complete: string }; +}; + +declare const CHUNK_BUILD: MainChunkBuild; + +/** + * Key prefix of the chunks' SEA assets in the standalone binary. Undefined + * in the npm package, whose chunks are files next to this script. + */ +declare const CHUNK_ASSET_DIR: string | undefined; + +/** Where chunks keep their code cache, or `undefined` without a home directory. */ +function codeCacheDir(): string | undefined { + try { + return join(resolveConfigDir(process.env, homedir()), "cache", "code"); + } catch (error) { + reportCodeCacheError("No config directory", error); + return; + } +} + +/** + * Chunks embedded as SEA assets. They run with the main script's + * `__filename` and `__dirname`, and stack traces name the asset key, which + * the uploaded sourcemap's URL matches. + */ +function assetSource(dir: string): ChunkSource { + const { getAsset } = require("node:sea") as typeof import("node:sea"); + const scope = { filename: __filename, dirname: __dirname }; + return { + read: (name) => getAsset(`${dir}/${name}`, "utf8"), + filename: (name) => `${dir}/${name}`, + scope: () => scope, + require, + }; +} + +/** Chunks in files next to this script. */ +function fileSource(): ChunkSource { + return { + read: (name) => readFileSync(join(__dirname, name), "utf8"), + filename: (name) => join(__dirname, name), + scope: (name) => ({ filename: join(__dirname, name), dirname: __dirname }), + require, + }; +} + +// `bin.ts` runs the React Native Xcode wrapper (`__SENTRY_RN_WRAP_XCODE_CALL`) +// before it looks at the arguments, so only the CLI chunk may handle it. +const entry = + CHUNK_BUILD.entries[ + process.argv[2] === "__complete" && + process.env.__SENTRY_RN_WRAP_XCODE_CALL !== "1" + ? "complete" + : "cli" + ]; + +if (CHUNK_ASSET_DIR !== undefined) { + createChunkLoader( + CHUNK_BUILD, + assetSource(CHUNK_ASSET_DIR), + codeCacheDir(), + )(entry); +} else if (canLoadChunks()) { + createChunkLoader(CHUNK_BUILD, fileSource(), codeCacheDir())(entry); +} else { + // Node < 22: let Node load the chunks, without a code cache + require(join(__dirname, entry)); +} diff --git a/packages/cli/src/complete-bin.ts b/packages/cli/src/complete-bin.ts index f257442cf..64ae565d9 100644 --- a/packages/cli/src/complete-bin.ts +++ b/packages/cli/src/complete-bin.ts @@ -2,9 +2,9 @@ * Entry point of `sentry __complete`, which shell completion runs on every * TAB press. * - * `bin.ts` imports it for `__complete`. The standalone binary embeds it as a - * separate chunk that `sea-main.ts` runs, so a TAB press never compiles the - * rest of the CLI. Keep its imports to `lib/complete.ts` and what that loads + * `bin.ts` imports it for `__complete`. The npm package and the standalone + * binary ship it as a separate bundle that `chunk-main.ts` runs, so a TAB + * press never loads the rest of the CLI. Keep its imports to `lib/complete.ts` and what that loads * (see `test/script/cli-startup.test.ts`). */ diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ad6dcc6ef..646ce5ef2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,8 +5,6 @@ * with named methods for every CLI command, plus a `run()` escape hatch for * arbitrary command strings. * - * CLI runner is re-exported as `_cli` for the npm bin wrapper (`dist/bin.cjs`). - * * @example * ```typescript * import createSentrySDK from "sentry"; @@ -22,10 +20,8 @@ * @module */ -import { startCli } from "./cli.js"; import { buildInvoker, buildRunner } from "./lib/sdk-invoke.js"; import { createSDKMethods } from "./sdk.generated.js"; -import { SENTRY_CLI } from "./sentry-cli.js"; export type { AsyncChannel } from "./lib/async-channel.js"; // Re-export public types and error class from the shared module. @@ -64,9 +60,3 @@ export function createSentrySDK( } export default createSentrySDK; - -// CLI runner — internal, used by dist/bin.cjs wrapper -/** @internal Run the full `sentry` CLI on `process.argv`. */ -export function _cli(): Promise { - return startCli(SENTRY_CLI); -} diff --git a/packages/cli/src/lib/chunk-loader.ts b/packages/cli/src/lib/chunk-loader.ts new file mode 100644 index 000000000..71bc33bc3 --- /dev/null +++ b/packages/cli/src/lib/chunk-loader.ts @@ -0,0 +1,324 @@ +/** + * Load the CLI's code-split bundle, with a V8 code cache on disk. + * + * The npm package and the standalone binary ship the CLI as the same + * CommonJS chunks (see `script/lib/split-bundle.ts`), which require each + * other by file name. Their main script (`src/chunk-main.ts`) runs the entry + * chunk through {@link createChunkLoader}, which resolves those requires + * itself and reads each chunk from a file (npm) or a SEA asset (binary). A + * command compiles only the chunks it loads. + * + * Each chunk keeps a code cache: the first run compiles it from source and, + * on exit, writes the V8 code cache for every function that ran. Later runs + * read it back. Node's SEA code cache covers only the main script, and its + * compile cache only the files Node loads itself, so the loader keeps its + * own. Because the program that reads a cache also writes it, it works on + * every platform, including the binaries cross-compiled in CI. + * + * Imports only `node:` builtins: it runs before every command. + */ + +import { + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { cachedDataVersionTag } from "node:v8"; +import { constants, Script } from "node:vm"; + +/** The chunks of one build. */ +export type ChunkBuild = { + /** + * Hash of every chunk, part of the code cache directory name. V8 checks + * only the length of the source a cache was made for, so without it a + * cache from another build would run that build's code. + */ + readonly id: string; + /** File names of the chunks, which they require each other by (`./`). */ + readonly chunks: readonly string[]; +}; + +/** Where the chunks of a build come from. */ +export type ChunkSource = { + /** The CommonJS source of chunk `name`, as esbuild emits it. */ + readonly read: (name: string) => string; + /** + * Filename of chunk `name` in stack traces. It matches the URL of the + * sourcemap uploaded for the chunk. + */ + readonly filename: (name: string) => string; + /** The `__filename` and `__dirname` chunk `name` runs with. */ + readonly scope: (name: string) => { filename: string; dirname: string }; + /** Loads everything that is not a chunk, like `node:` builtins. */ + readonly require: NodeJS.Require; +}; + +/** Runs `callback` once when the process exits. */ +export type OnExit = (callback: () => void) => void; + +/** + * `vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER` lets the chunks' `import()` + * calls use Node's default loader, as they would in a file Node loaded. Node + * marks it experimental and warns once; the warning is noise for CLI users. + */ +const LOADER_WARNING = "USE_MAIN_CONTEXT_DEFAULT_LOADER"; + +/** + * The CommonJS wrapper starts on its own line, and `lineOffset: -1` hides that + * line, so line and column numbers in stack traces match the sourcemap. + */ +const WRAPPER_HEAD = + "(function (exports, require, module, __filename, __dirname) {\n"; +const WRAPPER_TAIL = "\n})"; + +/** + * Builds whose code caches are kept. More than one, so two installed versions + * used in turn (a stable binary and a nightly) don't keep replacing each + * other's. + */ +const MAX_CACHED_BUILDS = 3; + +/** This platform and architecture, which a code cache only works on. */ +const PLATFORM = `${process.platform}-${process.arch}`; + +/** A code cache directory of this platform: `--`. */ +const PLATFORM_CACHE_DIR_RE = new RegExp(`-${PLATFORM}-\\d+$`); + +/** Code cache files of earlier releases, one per chunk and build. */ +const LEGACY_CACHE_FILE_SUFFIX = `-${PLATFORM}.bin`; + +/** The oldest Node major version {@link canLoadChunks} accepts. */ +const MIN_NODE_MAJOR = 22; + +/** + * Whether this Node can run chunks with {@link createChunkLoader}. Without + * `USE_MAIN_CONTEXT_DEFAULT_LOADER` (Node < 20.12), a chunk could not + * `import()` anything, and Node 20 loses it for a script compiled from a + * code cache (`ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`). The standalone + * binary embeds a newer Node, so only the npm package runs without it. + */ +export function canLoadChunks( + version: string = process.versions.node, +): boolean { + return ( + Number.parseInt(version, 10) >= MIN_NODE_MAJOR && + constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER !== undefined + ); +} + +let loaderWarningSuppressed = false; + +function suppressLoaderWarning(): void { + if (loaderWarningSuppressed) { + return; + } + loaderWarningSuppressed = true; + const originalEmit = process.emit.bind(process) as typeof process.emit; + process.emit = ((event: string, ...args: unknown[]) => { + const [warning] = args; + if ( + event === "warning" && + warning instanceof Error && + warning.name === "ExperimentalWarning" && + warning.message.includes(LOADER_WARNING) + ) { + return false; + } + // @ts-expect-error: forwarding args to original emit + return originalEmit(event, ...args); + }) as typeof process.emit; +} + +/** + * Report a code cache failure under `SENTRY_LOG_LEVEL=debug`. The logger is + * not loaded yet, and a cache failure only costs time, so it never fails the + * command. + */ +export function reportCodeCacheError(message: string, error: unknown): void { + const level = process.env.SENTRY_LOG_LEVEL; + if (level === "debug" || level === "trace") { + process.stderr.write(`[code-cache] ${message}: ${String(error)}\n`); + } +} + +function readCodeCache(path: string): Buffer | undefined { + try { + return readFileSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + reportCodeCacheError(`Could not read ${path}`, error); + } + return; + } +} + +/** + * Delete all but the {@link MAX_CACHED_BUILDS} newest code cache directories + * of this platform, and the cache files of earlier releases. Caches for + * other architectures belong to other binaries sharing the directory, so + * they are left alone. + */ +function pruneCodeCaches(cacheDir: string): void { + const builds: { path: string; mtime: number }[] = []; + for (const entry of readdirSync(cacheDir)) { + const path = join(cacheDir, entry); + if (entry.endsWith(LEGACY_CACHE_FILE_SUFFIX)) { + rmSync(path, { force: true }); + } else if (PLATFORM_CACHE_DIR_RE.test(entry)) { + builds.push({ path, mtime: statSync(path).mtimeMs }); + } + } + builds.sort((a, b) => b.mtime - a.mtime); + for (const { path } of builds.slice(MAX_CACHED_BUILDS)) { + rmSync(path, { recursive: true, force: true }); + } +} + +/** + * Write the code cache of each script in `scripts` to `buildDir`, then prune + * old builds. Each rename is atomic, so a concurrent run never reads a + * partial file. + */ +function writeCodeCaches( + cacheDir: string, + buildDir: string, + scripts: ReadonlyMap, +): void { + try { + mkdirSync(buildDir, { recursive: true, mode: 0o700 }); + for (const [name, script] of scripts) { + const path = join(buildDir, `${name}.bin`); + const temporary = `${path}.${process.pid}.tmp`; + writeFileSync(temporary, script.createCachedData(), { mode: 0o600 }); + renameSync(temporary, path); + } + pruneCodeCaches(cacheDir); + } catch (error) { + reportCodeCacheError( + `Could not write the code cache to ${buildDir}`, + error, + ); + } +} + +const onProcessExit: OnExit = (callback) => { + process.once("exit", callback); +}; + +/** The `require` chunks run with: chunks by name, the rest from `source`. */ +function chunkRequire( + source: ChunkSource, + chunks: ReadonlySet, + load: (name: string) => unknown, +): NodeJS.Require { + const require = (specifier: string): unknown => { + const name = specifier.startsWith("./") ? specifier.slice(2) : undefined; + return name !== undefined && chunks.has(name) + ? load(name) + : source.require(specifier); + }; + const { resolve, cache, main, extensions } = source.require; + return Object.assign(require, { resolve, cache, main, extensions }); +} + +/** + * Create a function that runs a chunk of `build` once and returns its + * exports, loading the chunks it requires the same way. + * + * `cacheDir` holds the code caches, in a directory per build and platform. + * When it is `undefined`, chunks compile from source every time. When a + * chunk has no accepted cache, `onExit` gets the callback that writes the + * caches of all such chunks. + */ +export function createChunkLoader( + build: ChunkBuild, + source: ChunkSource, + cacheDir: string | undefined, + onExit: OnExit = onProcessExit, +): (name: string) => unknown { + // A shared config directory can serve binaries for several platforms (an + // x64 binary under Rosetta next to an arm64 one), and V8 rejects a cache + // made for another CPU, V8 version, or set of V8 flags. + const buildDir = + cacheDir === undefined + ? undefined + : join(cacheDir, `${build.id}-${PLATFORM}-${cachedDataVersionTag()}`); + const modules = new Map(); + const uncached = new Map(); + suppressLoaderWarning(); + + const load = (name: string): unknown => { + const loaded = modules.get(name); + if (loaded) { + return loaded.exports; + } + const { filename, dirname } = source.scope(name); + const module = { + exports: {}, + id: filename, + filename, + path: dirname, + loaded: false, + children: [], + paths: [], + } as unknown as NodeJS.Module; + modules.set(name, module); + + const cachedData = + buildDir === undefined + ? undefined + : readCodeCache(join(buildDir, `${name}.bin`)); + const script = new Script( + `${WRAPPER_HEAD}${source.read(name)}${WRAPPER_TAIL}`, + { + filename: source.filename(name), + lineOffset: -1, + cachedData, + importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER, + }, + ); + if ( + buildDir !== undefined && + cacheDir !== undefined && + (cachedData === undefined || script.cachedDataRejected) + ) { + // On exit the cache also holds the functions compiled while the + // command ran, not only the top level. + if (uncached.size === 0) { + onExit(() => writeCodeCaches(cacheDir, buildDir, uncached)); + } + uncached.set(name, script); + } + + const run = script.runInThisContext() as ( + exports: unknown, + require: NodeJS.Require, + module: NodeJS.Module, + filename: string, + dirname: string, + ) => void; + try { + run.call( + module.exports, + module.exports, + requireChunk, + module, + filename, + dirname, + ); + } catch (error) { + // Like Node's loader: the next require runs the chunk again + modules.delete(name); + throw error; + } + module.loaded = true; + return module.exports; + }; + const requireChunk = chunkRequire(source, new Set(build.chunks), load); + return load; +} diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 481847772..4457b14a0 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -74,8 +74,8 @@ type Completion = { /** * Run `sentry __complete`. * - * `src/bin.ts` and the npm package's `dist/bin.cjs` (`dist/complete.cjs`) - * call this before anything else of the CLI loads, so a TAB press loads only + * `src/bin.ts` and the packages' main script (`src/chunk-main.ts`, which + * runs `complete.cjs`) call this before anything else of the CLI loads, so a TAB press loads only * this module's imports. `test/script/cli-startup.test.ts` keeps them free of * `@sentry/node`, the API client, and the command tree. * diff --git a/packages/cli/src/lib/config-dir.ts b/packages/cli/src/lib/config-dir.ts index 9c5d204d7..d042026a2 100644 --- a/packages/cli/src/lib/config-dir.ts +++ b/packages/cli/src/lib/config-dir.ts @@ -1,8 +1,8 @@ /** * Resolve the CLI's config directory. * - * Kept free of the logger and the database so the SEA bootstrap - * (`src/sea-main.ts`) can find the code cache without loading either. + * Kept free of the logger and the database so the CLI's main script + * (`src/chunk-main.ts`) can find the code cache without loading either. */ import { existsSync } from "node:fs"; diff --git a/packages/cli/src/lib/constants.ts b/packages/cli/src/lib/constants.ts index 64d69790f..18895cf51 100644 --- a/packages/cli/src/lib/constants.ts +++ b/packages/cli/src/lib/constants.ts @@ -8,17 +8,6 @@ import { ConfigError } from "./errors.js"; /** Build-time constant injected by esbuild/bun */ declare const SENTRY_CLI_VERSION: string | undefined; -/** - * Build-time debug ID for sourcemap resolution, injected by esbuild. - * - * During the build, esbuild's `define` replaces this identifier with a - * placeholder UUID string literal. After esbuild finishes, the build - * script replaces the placeholder with the real debug ID (derived from - * the minified JS + sourcemap content hash). The same-length swap keeps - * sourcemap character positions valid. - */ -declare const __SENTRY_DEBUG_ID__: string | undefined; - /** Default Sentry SaaS hostname */ export const DEFAULT_SENTRY_HOST = "sentry.io"; @@ -184,32 +173,3 @@ export function getUserAgent(): string { */ export const SENTRY_CLI_DSN = "https://1188a86f3f8168f089450587b00bca66@o1.ingest.us.sentry.io/4510776311808000"; - -/** - * Register the build-time debug ID with the Sentry SDK's native discovery. - * - * The SDK reads `globalThis._sentryDebugIds` (a map of Error.stack → debugId) - * during event processing to populate `debug_meta.images`, which the server - * uses to match uploaded sourcemaps. - * - * Previously this was done by a runtime IIFE snippet prepended to the bundle - * output by `injectDebugId()`. That broke ESM because the snippet appeared - * before `import` declarations. Placing the same logic here — inside the - * module, after all imports — is valid ESM and feeds the SDK's existing - * mechanism directly. - */ -if (typeof __SENTRY_DEBUG_ID__ !== "undefined") { - // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. - try { - // stack trace capture only - const stack = new Error().stack; - if (stack) { - // oxlint-disable-next-line typescript/no-explicit-any -- SDK reads this untyped global - const g = globalThis as any; - g._sentryDebugIds = g._sentryDebugIds || {}; - g._sentryDebugIds[stack] = __SENTRY_DEBUG_ID__; - } - } catch (_) { - // Non-critical — sourcemap resolution degrades gracefully - } -} diff --git a/packages/cli/src/lib/db/index.ts b/packages/cli/src/lib/db/index.ts index 8374cda52..7aca23c08 100644 --- a/packages/cli/src/lib/db/index.ts +++ b/packages/cli/src/lib/db/index.ts @@ -182,9 +182,9 @@ export function getDatabase(): Database { } /** - * `true` in the npm package's `dist/complete.cjs` (defined in - * `script/bundle.ts`), so esbuild drops the telemetry `require` below from - * that bundle. Undefined when running from source. + * `true` in the `complete.cjs` bundle of the npm package and the standalone + * binary (defined in `script/bundle.ts` and `script/build.ts`), so esbuild + * drops the telemetry `require` below from that bundle. Undefined when running from source. */ declare const SENTRY_CLI_COMPLETION_BUILD: boolean | undefined; diff --git a/packages/cli/src/lib/sdk-invoke.ts b/packages/cli/src/lib/sdk-invoke.ts index 27354f065..4f88c9982 100644 --- a/packages/cli/src/lib/sdk-invoke.ts +++ b/packages/cli/src/lib/sdk-invoke.ts @@ -99,7 +99,8 @@ async function resolveCommand(path: string[]): Promise { const key = path.join("."); let cached = commandCache.get(key); if (!cached) { - const { routes } = await import("../app.js"); + const { loadSdkApp } = await import("../sdk-app.js"); + const { routes } = await loadSdkApp(path); // Walk route tree: routes → sub-route → command // oxlint-disable-next-line typescript/no-explicit-any -- Stricli's RoutingTarget union requires runtime duck-typing let target: any = routes; @@ -619,7 +620,8 @@ export function buildRunner(options?: SentryOptions) { if (hasStreamingFlag(args)) { return executeWithStream(options, async (ctx, span) => { const { run: stricliRun } = await import("@stricli/core"); - const { app } = await import("../app.js"); + const { loadSdkApp } = await import("../sdk-app.js"); + const { app } = await loadSdkApp(args); const { buildContext } = await import("../context.js"); // oxlint-disable-next-line typescript/no-explicit-any -- fakeProcess duck-types the process interface const process_ = ctx.context.process as any; @@ -629,7 +631,8 @@ export function buildRunner(options?: SentryOptions) { return executeWithCapture(options, async (ctx, span) => { const { run: stricliRun } = await import("@stricli/core"); - const { app } = await import("../app.js"); + const { loadSdkApp } = await import("../sdk-app.js"); + const { app } = await loadSdkApp(args); const { buildContext } = await import("../context.js"); // oxlint-disable-next-line typescript/no-explicit-any -- fakeProcess duck-types the process interface const process_ = ctx.context.process as any; diff --git a/packages/cli/src/lib/sea-chunk.ts b/packages/cli/src/lib/sea-chunk.ts deleted file mode 100644 index 090863814..000000000 --- a/packages/cli/src/lib/sea-chunk.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Run a script embedded in the standalone binary, with a V8 code cache on disk. - * - * The binary's SEA main script is a small bootstrap (`src/sea-main.ts`). The - * CLI and the shell-completion engine are separate SEA assets, so a TAB press - * compiles only the completion engine, not the whole CLI. - * - * Node's SEA code cache covers only the main script, so this module keeps its - * own: the first run compiles a chunk from source and, on exit, writes the V8 - * code cache for every function that ran. Later runs read it back. Because the - * cache is written by the binary that reads it, it works on every platform, - * including the ones cross-compiled in CI, where fossilize can't build one. - * - * Imports only `node:` builtins: it runs before every command. - */ - -import { - mkdirSync, - readdirSync, - readFileSync, - renameSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; -import { join } from "node:path"; -import { constants, Script } from "node:vm"; - -/** CommonJS scope a chunk runs in, the same one the SEA main script gets. */ -export type ChunkScope = { - exports: unknown; - require: NodeJS.Require; - module: NodeJS.Module; - filename: string; - dirname: string; -}; - -export type SeaChunk = { - /** The chunk's CommonJS source, as esbuild emits it. */ - source: string; - /** - * Filename in stack traces. It matches the URL of the sourcemap uploaded - * for the chunk, and the path the SEA main script reported before the split. - */ - filename: string; - /** Names the chunk's code cache files, such as `cli`. */ - name: string; - /** - * Hash of `source`, part of the code cache file name. V8 checks only the - * length of the source a cache was made for, so without it a cache from - * another build of the same length would run that build's code. - */ - buildId: string; -}; - -/** - * `vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER` lets the chunk's `import()` - * calls use Node's default loader, as they did in the main script. Node marks - * it experimental and warns once; the warning is noise for CLI users. - */ -const LOADER_WARNING = "USE_MAIN_CONTEXT_DEFAULT_LOADER"; - -/** - * The CommonJS wrapper starts on its own line, and `lineOffset: -1` hides that - * line, so line and column numbers in stack traces match the sourcemap. - */ -const WRAPPER_HEAD = - "(function (exports, require, module, __filename, __dirname) {\n"; -const WRAPPER_TAIL = "\n})"; - -/** - * Code caches kept per chunk. More than one, so two installed versions used in - * turn (a stable binary and a nightly) don't keep replacing each other's. - */ -const MAX_CACHED_BUILDS = 3; - -let loaderWarningSuppressed = false; - -function suppressLoaderWarning(): void { - if (loaderWarningSuppressed) { - return; - } - loaderWarningSuppressed = true; - const originalEmit = process.emit.bind(process) as typeof process.emit; - process.emit = ((event: string, ...args: unknown[]) => { - const [warning] = args; - if ( - event === "warning" && - warning instanceof Error && - warning.name === "ExperimentalWarning" && - warning.message.includes(LOADER_WARNING) - ) { - return false; - } - // @ts-expect-error: forwarding args to original emit - return originalEmit(event, ...args); - }) as typeof process.emit; -} - -/** - * Report a code cache failure under `SENTRY_LOG_LEVEL=debug`. The logger is - * not loaded yet, and a cache failure only costs time, so it never fails the - * command. - */ -export function reportCodeCacheError(message: string, error: unknown): void { - const level = process.env.SENTRY_LOG_LEVEL; - if (level === "debug" || level === "trace") { - process.stderr.write(`[code-cache] ${message}: ${String(error)}\n`); - } -} - -function readCodeCache(path: string): Buffer | undefined { - try { - return readFileSync(path); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - reportCodeCacheError(`Could not read ${path}`, error); - } - return; - } -} - -/** Suffix of the code cache files for this platform and architecture. */ -const PLATFORM_SUFFIX = `-${process.platform}-${process.arch}.bin`; - -/** - * Delete all but the {@link MAX_CACHED_BUILDS} newest caches of a chunk on - * this platform. Caches for other architectures belong to other binaries - * sharing the directory, so they are left alone. - */ -function pruneCodeCaches(cacheDir: string, chunkName: string): void { - const caches = readdirSync(cacheDir) - .filter( - (entry) => - entry.startsWith(`${chunkName}-`) && entry.endsWith(PLATFORM_SUFFIX), - ) - .map((entry) => { - const path = join(cacheDir, entry); - return { path, mtime: statSync(path).mtimeMs }; - }) - .sort((a, b) => b.mtime - a.mtime); - for (const { path } of caches.slice(MAX_CACHED_BUILDS)) { - rmSync(path, { force: true }); - } -} - -/** - * Write `data` to `cacheDir/fileName` and prune the chunk's old caches. The - * rename is atomic, so a concurrent run never reads a partial file. - */ -function writeCodeCache( - cacheDir: string, - fileName: string, - chunkName: string, - data: Buffer, -): void { - try { - mkdirSync(cacheDir, { recursive: true, mode: 0o700 }); - const path = join(cacheDir, fileName); - const temporary = `${path}.${process.pid}.tmp`; - writeFileSync(temporary, data, { mode: 0o600 }); - renameSync(temporary, path); - pruneCodeCaches(cacheDir, chunkName); - } catch (error) { - reportCodeCacheError( - `Could not write the code cache to ${cacheDir}`, - error, - ); - } -} - -/** Runs `callback` once when the process exits. */ -type OnExit = (callback: () => void) => void; - -const onProcessExit: OnExit = (callback) => { - process.once("exit", callback); -}; - -/** - * Compile and run `chunk` in `scope`. - * - * `cacheDir` holds the code cache, one file per chunk and platform. When it - * is `undefined`, the chunk compiles from source every time. Without an - * accepted cache, `onExit` gets the callback that writes one. - * - * @returns Whether a code cache was read and accepted - */ -export function runSeaChunk( - chunk: SeaChunk, - scope: ChunkScope, - cacheDir: string | undefined, - onExit: OnExit = onProcessExit, -): boolean { - // A shared config directory can serve binaries for several platforms (an - // x64 binary under Rosetta next to an arm64 one), and V8 rejects a cache - // made on another CPU architecture. - const fileName = `${chunk.name}-${chunk.buildId}${PLATFORM_SUFFIX}`; - const cachedData = - cacheDir === undefined - ? undefined - : readCodeCache(join(cacheDir, fileName)); - - suppressLoaderWarning(); - const script = new Script(`${WRAPPER_HEAD}${chunk.source}${WRAPPER_TAIL}`, { - filename: chunk.filename, - lineOffset: -1, - cachedData, - importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER, - }); - const cacheAccepted = cachedData !== undefined && !script.cachedDataRejected; - - if (cacheDir !== undefined && !cacheAccepted) { - // On exit the cache also holds the functions compiled while the command - // ran, not only the top level. - onExit(() => { - writeCodeCache(cacheDir, fileName, chunk.name, script.createCachedData()); - }); - } - - const run = script.runInThisContext() as ( - exports: unknown, - require: NodeJS.Require, - module: NodeJS.Module, - filename: string, - dirname: string, - ) => void; - run.call( - scope.exports, - scope.exports, - scope.require, - scope.module, - scope.filename, - scope.dirname, - ); - return cacheAccepted; -} diff --git a/packages/cli/src/sdk-app.ts b/packages/cli/src/sdk-app.ts new file mode 100644 index 000000000..5e8d2af73 --- /dev/null +++ b/packages/cli/src/sdk-app.ts @@ -0,0 +1,63 @@ +/** + * The application the SDK (`lib/sdk-invoke.ts`) runs commands in. + * + * Like the CLI (`app-for-args.ts`), it loads only the top-level routes a call + * runs, so an app that uses the SDK loads only the commands it calls. Calls + * can run at the same time, so the routes the SDK registers only grow: it + * registers every route loaded so far. A call that needs the whole command + * tree (`help`, `schema`, `cli`, or an unknown name) loads `app.ts`, which + * the SDK uses from then on. + */ + +import { routesForArgs } from "./app-for-args.js"; +import { + buildSentryApp, + buildSentryRoutes, + type SentryRoutes, +} from "./app-root.js"; +import type { CliApplication } from "./lib/application.js"; +import type { CliRoutes } from "./lib/route-registry.js"; +import { ROUTE_LOADERS } from "./generated/route-loaders.js"; + +type SdkApp = { app: CliApplication; routes: CliRoutes }; + +/** The top-level routes loaded so far, by name. */ +const loaded: Record = {}; + +/** + * The application over {@link loaded}, and how many routes it has. A call + * rebuilds it when another call added a route since. + */ +let partialApp: { app: SdkApp; size: number } | undefined; + +/** The full application, once a call needed it. */ +let fullApp: Promise | undefined; + +/** Load the application that runs `args` (`["issue", "list", ...]`). */ +export async function loadSdkApp(args: readonly string[]): Promise { + const names = fullApp ? undefined : routesForArgs(args); + if (!names) { + fullApp ??= import("./app.js"); + return await fullApp; + } + const missing = names.filter((name) => !Object.hasOwn(loaded, name)); + await Promise.all( + missing.map(async (name) => { + const load = ROUTE_LOADERS[name]; + if (!load) { + throw new Error(`No loader for the top-level route "${name}"`); + } + loaded[name] = await load(); + }), + ); + // Another call may have registered the full tree in the meantime + if (fullApp) { + return await fullApp; + } + const size = Object.keys(loaded).length; + if (partialApp?.size !== size) { + const routes = buildSentryRoutes({ ...loaded }); + partialApp = { app: { app: buildSentryApp(routes), routes }, size }; + } + return partialApp.app; +} diff --git a/packages/cli/src/sea-main.ts b/packages/cli/src/sea-main.ts deleted file mode 100644 index 8c5af40f4..000000000 --- a/packages/cli/src/sea-main.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Main script of the standalone binary. - * - * The CLI (`bin.ts`) and the shell-completion engine (`complete-bin.ts`) are - * embedded as separate SEA assets. This bootstrap picks one and runs it, so - * `sentry __complete` never compiles the 7 MB CLI bundle. Each chunk keeps a - * V8 code cache under the config directory (see `lib/sea-chunk.ts`). - * - * Built by `script/build.ts`, which defines `SEA_CHUNKS`. Imports only - * `node:` builtins and pure helpers: it runs before every command. - */ - -import { homedir } from "node:os"; -import { join } from "node:path"; -import { getAsset } from "node:sea"; -import { resolveConfigDir } from "./lib/config-dir.js"; -import { - reportCodeCacheError, - runSeaChunk, - type SeaChunk, -} from "./lib/sea-chunk.js"; - -/** An embedded chunk: its SEA asset key and the hash of its source. */ -type ChunkAsset = { asset: string; buildId: string }; - -declare const SEA_CHUNKS: { cli: ChunkAsset; complete: ChunkAsset }; - -/** Where chunks keep their code cache, or `undefined` without a home directory. */ -function codeCacheDir(): string | undefined { - try { - return join(resolveConfigDir(process.env, homedir()), "cache", "code"); - } catch (error) { - reportCodeCacheError("No config directory", error); - return; - } -} - -// `bin.ts` runs the React Native Xcode wrapper (`__SENTRY_RN_WRAP_XCODE_CALL`) -// before it looks at the arguments, so only the CLI chunk may handle it. -const name = - process.argv[2] === "__complete" && - process.env.__SENTRY_RN_WRAP_XCODE_CALL !== "1" - ? "complete" - : "cli"; -const { asset, buildId } = SEA_CHUNKS[name]; -const chunk: SeaChunk = { - name, - buildId, - source: getAsset(asset, "utf8"), - // The path the main script had before the split, which the uploaded - // sourcemap's URL matches. - filename: asset, -}; - -runSeaChunk( - chunk, - { exports, require, module, filename: __filename, dirname: __dirname }, - codeCacheDir(), -); diff --git a/packages/cli/src/sentry-cli.ts b/packages/cli/src/sentry-cli.ts index ba7ba2f95..d56b4eb8d 100644 --- a/packages/cli/src/sentry-cli.ts +++ b/packages/cli/src/sentry-cli.ts @@ -2,9 +2,9 @@ * The full `sentry` CLI: every command. Each run loads only the command it * runs where it can (see `app-for-args.ts`). * - * Run by `bin.ts` (standalone binaries) and by `index.ts`'s `_cli` (the npm - * package's `dist/bin.cjs`). Shell completion never gets here: both bins run - * `lib/complete.ts` directly. The `@sentry/mcp-server` bundle runs a smaller + * Run by `bin.ts`, the CLI entry point of the npm package and the standalone + * binary. Shell completion never gets here: both run `complete-bin.ts` + * instead. The `@sentry/mcp-server` bundle runs a smaller * application instead (`mcp-server.ts`), so nothing there imports this file. */ diff --git a/packages/cli/test/e2e/bundle.test.ts b/packages/cli/test/e2e/bundle.test.ts index 1c7442d6f..d3740a31b 100644 --- a/packages/cli/test/e2e/bundle.test.ts +++ b/packages/cli/test/e2e/bundle.test.ts @@ -6,7 +6,7 @@ */ import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { basename, join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -189,12 +189,20 @@ describe("npm bundle", () => { }, 15_000); test("__complete runs from dist/complete.cjs without loading the CLI", async () => { - // Print the modules the run loaded, so the test sees what a TAB press pays for + // Print the JavaScript files the run read, so the test sees what a TAB + // press pays for. `dist/bin.cjs` reads its chunks with readFileSync. const { stdout, stderr, exitCode } = await spawnCollect( "node", [ "-e", - `process.on("exit", () => process.stderr.write(JSON.stringify(Object.keys(require.cache)))); + `const fs = require("node:fs"); + const readFileSync = fs.readFileSync; + const read = []; + fs.readFileSync = function (path, ...rest) { + if (typeof path === "string" && /\\.[cm]?js$/.test(path)) read.push(path); + return readFileSync.call(this, path, ...rest); + }; + process.on("exit", () => process.stderr.write(JSON.stringify(read))); process.argv.splice(1, 0, ${JSON.stringify(BUNDLE_BIN_PATH)}); require(process.argv[1]);`, "__complete", @@ -212,7 +220,48 @@ describe("npm bundle", () => { basename(path), ); expect(loaded).toContain("complete.cjs"); - expect(loaded).not.toContain("index.cjs"); + expect(loaded).not.toContain("cli.cjs"); + expect(loaded.filter((name) => name.startsWith("chunk-"))).toEqual([]); + }, 15_000); + + test("a command loads only its own chunks", async () => { + // `dist/bin.cjs` runs `cli.cjs`, whose commands are chunks of their own + const { stdout, stderr, exitCode } = await spawnCollect( + "node", + [ + "-e", + `const fs = require("node:fs"); + const readFileSync = fs.readFileSync; + const read = new Set(); + fs.readFileSync = function (path, ...rest) { + if (typeof path === "string" && /\\.[cm]?js$/.test(path)) read.add(path); + return readFileSync.call(this, path, ...rest); + }; + process.on("exit", () => process.stderr.write("\\n" + JSON.stringify([...read]))); + process.argv.splice(1, 0, ${JSON.stringify(BUNDLE_BIN_PATH)}); + require(process.argv[1]);`, + "issue", + "list", + "--help", + ], + { cwd: ROOT_DIR, env: { ...process.env, SENTRY_CLI_NO_TELEMETRY: "1" } }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("sentry issue list"); + const read = JSON.parse( + stderr.slice(stderr.lastIndexOf("\n") + 1), + ) as string[]; + const chunks = readdirSync(join(ROOT_DIR, "dist")).filter( + (name) => name.startsWith("chunk-") && name.endsWith(".cjs"), + ); + const readChunks = read.filter((path) => + basename(path).startsWith("chunk-"), + ); + expect(read.map((path) => basename(path))).toContain("cli.cjs"); + expect(readChunks.length).toBeGreaterThan(0); + // Not every command: well under half of the CLI's chunks + expect(readChunks.length).toBeLessThan(chunks.length / 2); }, 15_000); test("Ink sidecar can be imported and exports mountApp", async () => { diff --git a/packages/cli/test/lib/chunk-loader.test.ts b/packages/cli/test/lib/chunk-loader.test.ts new file mode 100644 index 000000000..edcad9777 --- /dev/null +++ b/packages/cli/test/lib/chunk-loader.test.ts @@ -0,0 +1,295 @@ +import { + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cachedDataVersionTag, setFlagsFromString } from "node:v8"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "vitest"; +import { + type ChunkBuild, + type ChunkSource, + canLoadChunks, + createChunkLoader, +} from "../../src/lib/chunk-loader.js"; + +const require = createRequire(import.meta.url); + +const PLATFORM = `${process.platform}-${process.arch}`; + +/** The code cache directory of build `id` on this platform. */ +function buildDirName(id: string): string { + return `${id}-${PLATFORM}-${cachedDataVersionTag()}`; +} + +/** Chunks as CommonJS sources by file name, like a SEA build's assets. */ +function source(chunks: Record): ChunkSource { + return { + read: (name) => { + const content = chunks[name]; + if (content === undefined) { + throw new Error(`No chunk ${name}`); + } + return content; + }, + filename: (name) => `dist-build/${name}`, + scope: (name) => ({ + filename: `/usr/local/lib/sentry/${name}`, + dirname: "/usr/local/lib/sentry", + }), + require, + }; +} + +function build(chunks: Record, id = "build1"): ChunkBuild { + return { id, chunks: Object.keys(chunks) }; +} + +/** + * Load `entry` of `chunks` and run the exit callbacks it registers, like a + * full process run. + */ +function runToExit( + chunks: Record, + cacheDir: string | undefined, + id = "build1", +): { exports: unknown; wroteCache: boolean } { + const exitCallbacks: (() => void)[] = []; + const load = createChunkLoader( + build(chunks, id), + source(chunks), + cacheDir, + (callback) => { + exitCallbacks.push(callback); + }, + ); + const exports = load("cli.cjs"); + for (const callback of exitCallbacks) { + callback(); + } + return { exports, wroteCache: exitCallbacks.length > 0 }; +} + +let cacheDir: string; + +beforeAll(() => { + // Each run stands for a new process. Without this, V8 reuses the script it + // compiled earlier in this process and ignores the code cache entirely. + setFlagsFromString("--no-compilation-cache"); +}); + +afterAll(() => { + // The worker process runs other test files next + setFlagsFromString("--compilation-cache"); +}); + +beforeEach(() => { + cacheDir = join( + mkdtempSync(join(tmpdir(), "chunk-loader-")), + "cache", + "code", + ); +}); + +afterEach(() => { + rmSync(join(cacheDir, "..", ".."), { recursive: true, force: true }); +}); + +describe("createChunkLoader", () => { + test("needs Node 22 or later", () => { + // `engines.node` allows Node 20, but CI and development use newer + expect(canLoadChunks()).toBe(true); + // Node 20 drops `import()` from scripts compiled from a code cache + expect(canLoadChunks("20.20.2")).toBe(false); + }); + + test("runs a chunk with the CommonJS scope its source gives it", () => { + const { exports } = runToExit( + { + "cli.cjs": + "exports.seen = [__filename, __dirname, typeof require('node:os').tmpdir];", + }, + undefined, + ); + expect(exports).toEqual({ + seen: [ + "/usr/local/lib/sentry/cli.cjs", + "/usr/local/lib/sentry", + "function", + ], + }); + }); + + test("loads the chunks a chunk requires once, by file name", () => { + const { exports } = runToExit( + { + "cli.cjs": + "const a = require('./chunk-A.cjs'); const b = require('./chunk-B.cjs'); exports.same = a.shared === b.shared; exports.runs = a.runs();", + "chunk-A.cjs": + "exports.shared = require('./chunk-shared.cjs'); exports.runs = () => exports.shared.runs;", + "chunk-B.cjs": "exports.shared = require('./chunk-shared.cjs');", + "chunk-shared.cjs": + "globalThis.__chunkLoaderRuns = (globalThis.__chunkLoaderRuns ?? 0) + 1; exports.runs = globalThis.__chunkLoaderRuns;", + }, + undefined, + ); + expect(exports).toEqual({ same: true, runs: 1 }); + delete (globalThis as { __chunkLoaderRuns?: number }).__chunkLoaderRuns; + }); + + test("reports stack positions of the chunk itself, not of the wrapper", () => { + const { exports } = runToExit( + { "cli.cjs": "exports.stack = new Error('x').stack;" }, + undefined, + ); + // Where `new Error` starts in the chunk (1-based), which the sourcemap maps + expect((exports as { stack: string }).stack).toContain( + "dist-build/cli.cjs:1:17", + ); + }); + + test("runs a chunk that threw again on the next require", () => { + const chunks = { + "cli.cjs": "exports.v = 1;", + "chunk-A.cjs": + "globalThis.__chunkLoaderThrow = (globalThis.__chunkLoaderThrow ?? 0) + 1; if (globalThis.__chunkLoaderThrow === 1) throw new Error('first');", + }; + const load = createChunkLoader(build(chunks), source(chunks), undefined); + expect(() => load("chunk-A.cjs")).toThrow("first"); + expect(() => load("chunk-A.cjs")).not.toThrow(); + delete (globalThis as { __chunkLoaderThrow?: number }).__chunkLoaderThrow; + }); + + test("writes a code cache on the first run and uses it on the next", () => { + const chunks = { + "cli.cjs": "exports.answer = require('./chunk-A.cjs').answer;", + "chunk-A.cjs": "exports.answer = (() => 42)();", + }; + + expect(runToExit(chunks, cacheDir)).toEqual({ + exports: { answer: 42 }, + wroteCache: true, + }); + expect(readdirSync(cacheDir)).toEqual([buildDirName("build1")]); + expect(readdirSync(join(cacheDir, buildDirName("build1"))).sort()).toEqual([ + "chunk-A.cjs.bin", + "cli.cjs.bin", + ]); + + expect(runToExit(chunks, cacheDir).wroteCache).toBe(false); + }); + + test("writes the caches of the chunks a later run loads first", () => { + const chunks = { + "cli.cjs": + "exports.load = () => require('./chunk-A.cjs'); if (process.env.__CHUNK_LOADER_A === '1') exports.load();", + "chunk-A.cjs": "exports.v = 1;", + }; + runToExit(chunks, cacheDir); + process.env.__CHUNK_LOADER_A = "1"; + try { + expect(runToExit(chunks, cacheDir).wroteCache).toBe(true); + expect(runToExit(chunks, cacheDir).wroteCache).toBe(false); + } finally { + delete process.env.__CHUNK_LOADER_A; + } + }); + + test("replaces a cache V8 rejects", () => { + const chunks = { "cli.cjs": "exports.answer = 42;" }; + runToExit(chunks, cacheDir); + writeFileSync( + join(cacheDir, buildDirName("build1"), "cli.cjs.bin"), + "not a code cache", + ); + + expect(runToExit(chunks, cacheDir).wroteCache).toBe(true); + expect(runToExit(chunks, cacheDir).wroteCache).toBe(false); + }); + + test("keeps the caches of the three newest builds", () => { + for (const [index, id] of ["b1", "b2", "b3", "b4"].entries()) { + runToExit({ "cli.cjs": `exports.v = ${index};` }, cacheDir, id); + // Distinct write times, oldest first + const time = new Date(Date.now() - (4 - index) * 1000); + utimesSync(join(cacheDir, buildDirName(id)), time, time); + } + runToExit({ "cli.cjs": "exports.v = 4;" }, cacheDir, "b5"); + + expect(readdirSync(cacheDir).sort()).toEqual( + ["b3", "b4", "b5"].map(buildDirName), + ); + }); + + test("leaves the caches of other architectures alone", () => { + const otherArch = `b0-${process.platform}-other-arch-1`; + mkdirSync(join(cacheDir, otherArch), { recursive: true }); + const time = new Date(Date.now() - 10_000); + utimesSync(join(cacheDir, otherArch), time, time); + for (const id of ["b1", "b2", "b3", "b4"]) { + runToExit({ "cli.cjs": "exports.v = 1;" }, cacheDir, id); + } + + expect(readdirSync(cacheDir)).toContain(otherArch); + expect( + readdirSync(cacheDir).filter((entry) => entry.includes(PLATFORM)), + ).toHaveLength(3); + }); + + test("deletes the cache files of earlier releases", () => { + const legacy = `cli-0123456789abcdef-${PLATFORM}.bin`; + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(join(cacheDir, legacy), ""); + + runToExit({ "cli.cjs": "exports.v = 1;" }, cacheDir); + + expect(readdirSync(cacheDir)).toEqual([buildDirName("build1")]); + }); + + test("runs without a cache when the directory cannot be written", () => { + const file = join(cacheDir, "..", "not-a-directory"); + mkdirSync(join(cacheDir, ".."), { recursive: true }); + writeFileSync(file, ""); + + expect( + runToExit({ "cli.cjs": "exports.v = 1;" }, join(file, "code")).exports, + ).toEqual({ v: 1 }); + }); + + test("resolves import() with Node's default loader", async () => { + const { exports } = runToExit( + { "cli.cjs": "exports.os = import('node:os');" }, + undefined, + ); + const os = (await (exports as { os: Promise }) + .os) as typeof import("node:os"); + expect(typeof os.tmpdir).toBe("function"); + }); + + test("resolves import() in a chunk compiled from its code cache", async () => { + const chunks = { + "cli.cjs": "exports.os = () => import('node:os');", + }; + runToExit(chunks, cacheDir); + const { exports, wroteCache } = runToExit(chunks, cacheDir); + + expect(wroteCache).toBe(false); + const os = await ( + exports as { os: () => Promise } + ).os(); + expect(typeof os.tmpdir).toBe("function"); + }); +}); diff --git a/packages/cli/test/lib/sdk-app.test.ts b/packages/cli/test/lib/sdk-app.test.ts new file mode 100644 index 000000000..01b9973de --- /dev/null +++ b/packages/cli/test/lib/sdk-app.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +/** A fresh `sdk-app.ts`: it keeps the routes it loaded in module state. */ +async function importSdkApp() { + return await import("../../src/sdk-app.js"); +} + +/** The top-level route names of an application's routes. */ +function routeNames(routes: { + getAllEntries(): readonly { name: { original: string } }[]; +}): string[] { + return routes.getAllEntries().map((entry) => entry.name.original); +} + +beforeEach(() => { + vi.resetModules(); +}); + +describe("loadSdkApp", () => { + test("loads only the routes a call runs", async () => { + const { loadSdkApp } = await importSdkApp(); + const { routes } = await loadSdkApp(["org", "list"]); + expect(routeNames(routes)).toEqual(["help", "org"]); + }); + + test("keeps the routes of earlier calls", async () => { + const { loadSdkApp } = await importSdkApp(); + const first = await loadSdkApp(["org", "list"]); + const second = await loadSdkApp(["issue", "list"]); + + expect(routeNames(second.routes).sort()).toEqual(["help", "issue", "org"]); + // An earlier call's command still resolves in the newer application + expect(second.routes.getRoutingTargetForInput("org")).toBe( + first.routes.getRoutingTargetForInput("org"), + ); + // A call whose routes are loaded reuses the application + expect(await loadSdkApp(["org", "view"])).toBe(second); + }); + + test("loads the routes of concurrent calls into one application", async () => { + const { loadSdkApp } = await importSdkApp(); + await Promise.all([ + loadSdkApp(["org", "list"]), + loadSdkApp(["project", "list"]), + ]); + const { routes } = await loadSdkApp(["org", "list"]); + expect(routeNames(routes).sort()).toEqual(["help", "org", "project"]); + }); + + test("loads app.ts for a call that needs the whole tree, from then on", async () => { + const { loadSdkApp } = await importSdkApp(); + const app = await import("../../src/app.js"); + + expect((await loadSdkApp(["help"])).routes).toBe(app.routes); + expect((await loadSdkApp(["org", "list"])).routes).toBe(app.routes); + }); +}); diff --git a/packages/cli/test/lib/sea-chunk.test.ts b/packages/cli/test/lib/sea-chunk.test.ts deleted file mode 100644 index ccf301a2a..000000000 --- a/packages/cli/test/lib/sea-chunk.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { - mkdirSync, - mkdtempSync, - readdirSync, - rmSync, - utimesSync, - writeFileSync, -} from "node:fs"; -import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { setFlagsFromString } from "node:v8"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "vitest"; -import { - type ChunkScope, - runSeaChunk, - type SeaChunk, -} from "../../src/lib/sea-chunk.js"; - -const require = createRequire(import.meta.url); - -const SUFFIX = `-${process.platform}-${process.arch}.bin`; - -/** A chunk that reports through `module.exports`, as an esbuild CJS bundle would. */ -function chunk(source: string, buildId = "build1"): SeaChunk { - return { name: "cli", buildId, source, filename: "dist-build/bin.js" }; -} - -/** A CommonJS scope, plus the exports object the chunk writes to. */ -function scope(): ChunkScope & { result: Record } { - const result: Record = {}; - return { - exports: result, - require, - module: { exports: result } as ChunkScope["module"], - filename: "/usr/local/bin/sentry", - dirname: "/usr/local/bin", - result, - }; -} - -/** Run `chunk` and the exit callback it registers, like a full process run. */ -function runToExit( - value: SeaChunk, - cacheDir: string | undefined, -): { accepted: boolean; wroteCache: boolean } { - const exitCallbacks: (() => void)[] = []; - const accepted = runSeaChunk(value, scope(), cacheDir, (callback) => { - exitCallbacks.push(callback); - }); - for (const callback of exitCallbacks) { - callback(); - } - return { accepted, wroteCache: exitCallbacks.length > 0 }; -} - -let cacheDir: string; - -beforeAll(() => { - // Each run stands for a new process. Without this, V8 reuses the script it - // compiled earlier in this process and ignores the code cache entirely. - setFlagsFromString("--no-compilation-cache"); -}); - -afterAll(() => { - // The worker process runs other test files next - setFlagsFromString("--compilation-cache"); -}); - -beforeEach(() => { - cacheDir = join(mkdtempSync(join(tmpdir(), "sea-chunk-")), "cache", "code"); -}); - -afterEach(() => { - rmSync(join(cacheDir, "..", ".."), { recursive: true, force: true }); -}); - -describe("runSeaChunk", () => { - test("runs the chunk with the CommonJS scope it is given", () => { - const target = scope(); - runSeaChunk( - chunk("module.exports.seen = [__filename, __dirname, typeof require];"), - target, - undefined, - ); - expect(target.result.seen).toEqual([ - "/usr/local/bin/sentry", - "/usr/local/bin", - "function", - ]); - }); - - test("reports stack positions of the bundle itself, not of the wrapper", () => { - const target = scope(); - runSeaChunk( - chunk("module.exports.stack = new Error('x').stack;"), - target, - undefined, - ); - // Where `new Error` starts in the bundle, which is what the sourcemap maps - expect(target.result.stack).toContain("dist-build/bin.js:1:24"); - }); - - test("writes a code cache on the first run and uses it on the next", () => { - const source = "module.exports.answer = (() => 42)();"; - - expect(runToExit(chunk(source), cacheDir)).toEqual({ - accepted: false, - wroteCache: true, - }); - expect(readdirSync(cacheDir)).toEqual([`cli-build1${SUFFIX}`]); - - expect(runToExit(chunk(source), cacheDir)).toEqual({ - accepted: true, - wroteCache: false, - }); - }); - - test("replaces a cache V8 rejects", () => { - const source = "module.exports.answer = 42;"; - runToExit(chunk(source), cacheDir); - writeFileSync(join(cacheDir, `cli-build1${SUFFIX}`), "not a code cache"); - - expect(runToExit(chunk(source), cacheDir)).toEqual({ - accepted: false, - wroteCache: true, - }); - expect(runToExit(chunk(source), cacheDir).accepted).toBe(true); - }); - - test("keeps the caches of the three newest builds", () => { - for (const [index, buildId] of ["b1", "b2", "b3", "b4"].entries()) { - runToExit(chunk(`module.exports.v = ${index};`, buildId), cacheDir); - // Distinct write times, oldest first - const time = new Date(Date.now() - (4 - index) * 1000); - utimesSync(join(cacheDir, `cli-${buildId}${SUFFIX}`), time, time); - } - runToExit(chunk("module.exports.v = 4;", "b5"), cacheDir); - - expect(readdirSync(cacheDir).sort()).toEqual([ - `cli-b3${SUFFIX}`, - `cli-b4${SUFFIX}`, - `cli-b5${SUFFIX}`, - ]); - }); - - test("leaves the caches of other architectures alone", () => { - const otherArch = `cli-b0-${process.platform}-other-arch.bin`; - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(join(cacheDir, otherArch), ""); - const time = new Date(Date.now() - 10_000); - utimesSync(join(cacheDir, otherArch), time, time); - for (const buildId of ["b1", "b2", "b3", "b4"]) { - runToExit(chunk("module.exports.v = 1;", buildId), cacheDir); - } - - expect(readdirSync(cacheDir)).toContain(otherArch); - expect( - readdirSync(cacheDir).filter((entry) => entry.endsWith(SUFFIX)), - ).toHaveLength(3); - }); - - test("runs without a cache when the directory cannot be written", () => { - const file = join(cacheDir, "..", "not-a-directory"); - runToExit(chunk("module.exports.v = 1;"), cacheDir); - writeFileSync(file, ""); - - expect(() => - runToExit(chunk("module.exports.v = 1;"), join(file, "code")), - ).not.toThrow(); - }); - - test("resolves import() with Node's default loader", async () => { - const target = scope(); - runSeaChunk( - chunk("module.exports.os = import('node:os');"), - target, - undefined, - ); - const os = (await target.result.os) as typeof import("node:os"); - expect(typeof os.tmpdir).toBe("function"); - }); -}); diff --git a/packages/cli/test/script/cli-startup.test.ts b/packages/cli/test/script/cli-startup.test.ts index 0f193e99b..65a67a674 100644 --- a/packages/cli/test/script/cli-startup.test.ts +++ b/packages/cli/test/script/cli-startup.test.ts @@ -111,30 +111,43 @@ test("the __complete fast path loads only light modules", async () => { expect(sourceModules.length).toBeLessThanOrEqual(COMPLETION_MODULE_BUDGET); }); -test("the standalone binary's main script loads only builtins", async () => { - // It runs before every command, ahead of the CLI or completion chunk - const { metafile } = await bundleStartup("sea-main.ts", () => false); +test("the CLI's main script loads only builtins", async () => { + // `dist/bin.cjs` and the standalone binary's main script run it before + // every command, ahead of the CLI or completion chunk + const { metafile } = await bundleStartup("chunk-main.ts", () => false); expect(Object.keys(metafile.inputs).sort()).toEqual([ + "src/chunk-main.ts", + "src/lib/chunk-loader.ts", "src/lib/config-dir.ts", - "src/lib/sea-chunk.ts", - "src/sea-main.ts", ]); }); -test("picking the application loads none of its commands", async () => { - // `app-for-args.ts` loads only the route being run, so no route module of - // `app.ts` (each one a `ROUTE_LOADERS` import) may load before it picks one +/** The modules of the top-level routes in `src/generated/route-loaders.ts`. */ +async function routeModules(): Promise { const loaders = await readFile( join(import.meta.dirname, "../../src/generated/route-loaders.ts"), "utf8", ); - const routeModules = [...loaders.matchAll(ROUTE_MODULE_RE)].map( + return [...loaders.matchAll(ROUTE_MODULE_RE)].map( ([, module]) => `src/${module?.replace(TS_EXTENSION_RE, ".ts")}`, ); - const { metafile } = await bundleStartup("app-for-args.ts", () => true); +} + +test.each([ + // `app-for-args.ts` loads only the route being run, so no route module of + // `app.ts` (each one a `ROUTE_LOADERS` import) may load before it picks one + "app-for-args.ts", + // The same for each SDK call + "sdk-app.ts", + // Importing the library loads no command at all, and no CLI runner + "index.ts", +])("%s loads none of the commands", async (entry) => { + const routes = await routeModules(); + const { metafile } = await bundleStartup(entry, () => true); const inputs = Object.keys(metafile.inputs); - expect(routeModules.length).toBeGreaterThan(0); + expect(routes.length).toBeGreaterThan(0); expect(inputs).not.toContain("src/app.ts"); - expect(inputs.filter((input) => routeModules.includes(input))).toEqual([]); + expect(inputs).not.toContain("src/cli.ts"); + expect(inputs.filter((input) => routes.includes(input))).toEqual([]); }); diff --git a/packages/cli/test/script/package-exports.test.ts b/packages/cli/test/script/package-exports.test.ts index 9656e00ff..0bffd5d74 100644 --- a/packages/cli/test/script/package-exports.test.ts +++ b/packages/cli/test/script/package-exports.test.ts @@ -1,9 +1,12 @@ import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, test } from "vitest"; import pkg from "../../package.json"; +/** The JavaScript files of a build: entry points and chunks. */ +const JS_OUTPUT_RE = /\.[cm]js$/; + /** * Guards the dual-package (ESM + CJS) contract of the published `sentry` * library. The bundler-plugin SDK and other ESM consumers import the default @@ -26,10 +29,12 @@ describe("package.json exports (dual ESM/CJS)", () => { expect(dot.require?.types).toBe("./dist/index.d.cts"); }); - test("ships both bundles and their type declarations in files", () => { + test("ships every chunk of both formats and the type declarations", () => { + // The bundles are code-split: an entry point requires its chunks + // (`dist/chunk-*.cjs`, `dist/chunk-*.mjs`, `dist/lazy-*.cjs`) for (const f of [ - "dist/index.mjs", - "dist/index.cjs", + "dist/*.mjs", + "dist/*.cjs", "dist/index.d.mts", "dist/index.d.cts", ]) { @@ -39,6 +44,24 @@ describe("package.json exports (dual ESM/CJS)", () => { // Only meaningful after a build; skipped in a clean checkout where dist/ is absent. const built = existsSync(resolve("dist/index.mjs")); + + test.runIf(built)("the package contains every chunk and no sourcemap", () => { + const [packed] = JSON.parse( + execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: resolve("."), + encoding: "utf-8", + timeout: 60_000, + }), + ) as [{ files: { path: string }[] }]; + const files = packed.files.map((file) => file.path); + const chunks = readdirSync(resolve("dist")) + .filter((name) => JS_OUTPUT_RE.test(name)) + .map((name) => `dist/${name}`); + + expect(chunks.length).toBeGreaterThan(10); + expect(files).toEqual(expect.arrayContaining(chunks)); + expect(files.filter((path) => path.endsWith(".map"))).toEqual([]); + }); test.runIf(built)("built ESM entry exposes createSentrySDK", () => { // Load the built bundle in Node, outside Vitest's source-map transformer. execFileSync( @@ -66,12 +89,18 @@ describe("package.json exports (dual ESM/CJS)", () => { // guards against a regression (e.g. a new dep importing from bare "zlib", // which the shim must also cover). test.runIf(built)( - "built ESM entry has no link-fatal named zlib imports", + "built ESM chunks have no link-fatal named zlib imports", () => { - const src = readFileSync(resolve("dist/index.mjs"), "utf-8"); const namedZlibImport = /import\s*\{[^}]*zstd[^}]*\}\s*from\s*["'](?:node:)?zlib["']/; - expect(src).not.toMatch(namedZlibImport); + const chunks = readdirSync(resolve("dist")).filter((name) => + name.endsWith(".mjs"), + ); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + const src = readFileSync(resolve(`dist/${chunk}`), "utf-8"); + expect(src, chunk).not.toMatch(namedZlibImport); + } }, ); }); From 44b49d1293aa1007a252c54be031b523602c5b7f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:57:11 +0000 Subject: [PATCH 2/2] docs(cli): Explain why chunks keep their own code cache and why route loads need no limit --- packages/cli/src/lib/chunk-loader.ts | 12 ++++++++---- packages/cli/src/sdk-app.ts | 3 +++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/chunk-loader.ts b/packages/cli/src/lib/chunk-loader.ts index 71bc33bc3..a59c79538 100644 --- a/packages/cli/src/lib/chunk-loader.ts +++ b/packages/cli/src/lib/chunk-loader.ts @@ -10,10 +10,14 @@ * * Each chunk keeps a code cache: the first run compiles it from source and, * on exit, writes the V8 code cache for every function that ran. Later runs - * read it back. Node's SEA code cache covers only the main script, and its - * compile cache only the files Node loads itself, so the loader keeps its - * own. Because the program that reads a cache also writes it, it works on - * every platform, including the binaries cross-compiled in CI. + * read it back. Node has no cache for these chunks: its SEA `useCodeCache` + * compiles only the `main` script when it builds the binary, and SEA assets + * are bytes that `sea.getAsset()` returns, which Node never compiles. Its + * compile cache (`module.enableCompileCache()`) covers only the files its + * module loader reads, not source run through `vm.Script`. So the loader + * keeps its own. Because the program that reads a cache also writes it, it + * works on every platform, including the binaries cross-compiled in CI, + * where a build-time code cache would come from the wrong platform. * * Imports only `node:` builtins: it runs before every command. */ diff --git a/packages/cli/src/sdk-app.ts b/packages/cli/src/sdk-app.ts index 5e8d2af73..f868e6b74 100644 --- a/packages/cli/src/sdk-app.ts +++ b/packages/cli/src/sdk-app.ts @@ -41,6 +41,9 @@ export async function loadSdkApp(args: readonly string[]): Promise { return await fullApp; } const missing = names.filter((name) => !Object.hasOwn(loaded, name)); + // At most three imports (`help`, the route, its singular), so no limit is + // needed. Calls that load the same route at once share the module, which + // `import()` evaluates once. await Promise.all( missing.map(async (name) => { const load = ROUTE_LOADERS[name];