From 13284a5c48df8051624c0da0a097f8d484cd9847 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 23 Oct 2026 13:48:11 -0700 Subject: [PATCH] test(bundler-tests): Guard server runtime entries against build-time tooling Build tooling keeps leaking into server runtime entries: bundler plugins, orchestrion bundler transforms, Vite, Babel, WASM. Each time, users find it in a different consumer, often in a production deploy (#22794, #23034, #23243, #24775, #25470). Nothing stopped the same shape from coming back. The new test traces each server runtime entry with `@vercel/nft`, the tracer behind Next standalone and the Vercel adapters. It resolves each entry through a fixture, under the export conditions of each runtime, in ESM and CJS. It fails if the trace reaches build-only code, and prints the import chain to it. The trace stops at the first build-time module and does not expand globs. Without that, an entry that does reach build tooling walks all of Vite and Babel, and globs the whole repo, which takes minutes instead of seconds. `@sentry/astro` and `@sentry/solidstart` are known violations: their root entries also re-export the config-time `sentryAstro` and `withSentry`, and must keep doing so until v12. The test fails if either becomes clean, so the list cannot go stale. Refs #25676 Co-Authored-By: Claude Opus 5.5 (1M context) --- dev-packages/bundler-tests/package.json | 16 ++ .../tests/runtime-entries.test.ts | 197 ++++++++++++++++++ pnpm-lock.yaml | 43 ++++ 3 files changed, 256 insertions(+) create mode 100644 dev-packages/bundler-tests/tests/runtime-entries.test.ts diff --git a/dev-packages/bundler-tests/package.json b/dev-packages/bundler-tests/package.json index 1210085b32ef..36502c297b61 100644 --- a/dev-packages/bundler-tests/package.json +++ b/dev-packages/bundler-tests/package.json @@ -19,6 +19,22 @@ "vitest": "^4.1.11", "webpack": "^5.0.0" }, + "devDependencies": { + "@sentry/astro": "11.9.0", + "@sentry/bun": "11.9.0", + "@sentry/cloudflare": "11.9.0", + "@sentry/deno": "11.9.0", + "@sentry/nextjs": "11.9.0", + "@sentry/node": "11.9.0", + "@sentry/nuxt": "11.9.0", + "@sentry/react-router": "11.9.0", + "@sentry/remix": "11.9.0", + "@sentry/solidstart": "11.9.0", + "@sentry/sveltekit": "11.9.0", + "@sentry/tanstackstart-react": "11.9.0", + "@sentry/vercel-edge": "11.9.0", + "@vercel/nft": "^1.11.0" + }, "volta": { "extends": "../../package.json" }, diff --git a/dev-packages/bundler-tests/tests/runtime-entries.test.ts b/dev-packages/bundler-tests/tests/runtime-entries.test.ts new file mode 100644 index 000000000000..4006b833bc70 --- /dev/null +++ b/dev-packages/bundler-tests/tests/runtime-entries.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from 'vitest'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { nodeFileTrace, resolve } from '@vercel/nft'; + +/** + * Server runtime entries must not reach build-time tooling. When they do, + * the tooling ends up in server bundles and traced outputs (Next standalone, + * Nitro, workerd, ...), where it breaks in a different way for each + * consumer. `@vercel/nft` is the tracer that Next standalone and the Vercel + * adapters use, so it reports what those consumers would ship. + */ + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = path.join(__dirname, '../../..'); +const FIXTURE_DIR = path.join(__dirname, '..', 'dist', 'runtime-entries'); + +const BUILD_TIME_PATTERNS: RegExp[] = [ + /(^|\/)packages\/bundler-plugins\//, + /(^|\/)node_modules\/@sentry\/bundler-plugins(\/|$)/, + /(^|\/)packages\/server-utils\/build\/[^/]+\/orchestrion\/bundler\//, + /(^|\/)node_modules\/(vite|webpack|rollup|esbuild|oxc-parser)\//, + /(^|\/)node_modules\/@(babel|rollup|oxc-parser)\//, + /\.wasm$/, +]; + +/** + * These root entries also re-export config-time code (`sentryAstro`, + * `withSentry`). They must keep it until v12 for backwards compatibility. + */ +const KNOWN_VIOLATIONS = new Set(['@sentry/astro', '@sentry/solidstart']); + +type Format = 'esm' | 'cjs'; + +interface Case { + specifier: string; + conditions: string[]; + formats: Format[]; +} + +const NODE = ['node']; +const WORKERD = ['workerd', 'worker']; +const BOTH: Format[] = ['esm', 'cjs']; +const ESM: Format[] = ['esm']; + +const CASES: Case[] = [ + { specifier: '@sentry/node', conditions: NODE, formats: BOTH }, + { specifier: '@sentry/node/import', conditions: NODE, formats: ESM }, + { specifier: '@sentry/bun', conditions: ['bun', 'node'], formats: BOTH }, + { specifier: '@sentry/deno', conditions: ['deno', 'node'], formats: ESM }, + { specifier: '@sentry/vercel-edge', conditions: ['edge-light'], formats: BOTH }, + { specifier: '@sentry/cloudflare', conditions: WORKERD, formats: BOTH }, + { specifier: '@sentry/cloudflare', conditions: [...WORKERD, 'development'], formats: BOTH }, + + { specifier: '@sentry/nextjs', conditions: NODE, formats: BOTH }, + { specifier: '@sentry/nextjs', conditions: ['edge-light'], formats: BOTH }, + { specifier: '@sentry/nextjs', conditions: WORKERD, formats: BOTH }, + { specifier: '@sentry/nextjs/cloudflare', conditions: WORKERD, formats: BOTH }, + { specifier: '@sentry/nextjs/import', conditions: NODE, formats: ESM }, + { specifier: '@sentry/nextjs/async-storage-shim', conditions: NODE, formats: BOTH }, + { specifier: '@sentry/nextjs/orchestrion-runtime/index', conditions: NODE, formats: BOTH }, + { specifier: '@sentry/nextjs/orchestrion-runtime/register', conditions: NODE, formats: BOTH }, + + { specifier: '@sentry/nuxt', conditions: NODE, formats: BOTH }, + { specifier: '@sentry/nuxt/module/plugins', conditions: NODE, formats: ESM }, + + { specifier: '@sentry/sveltekit', conditions: NODE, formats: BOTH }, + { specifier: '@sentry/sveltekit', conditions: WORKERD, formats: BOTH }, + { specifier: '@sentry/sveltekit', conditions: ['worker'], formats: BOTH }, + + { specifier: '@sentry/astro', conditions: NODE, formats: BOTH }, + { specifier: '@sentry/astro/middleware', conditions: NODE, formats: BOTH }, + { specifier: '@sentry/astro/import', conditions: NODE, formats: ESM }, + + { specifier: '@sentry/react-router', conditions: NODE, formats: BOTH }, + { specifier: '@sentry/react-router', conditions: WORKERD, formats: BOTH }, + { specifier: '@sentry/react-router/cloudflare', conditions: WORKERD, formats: BOTH }, + + { specifier: '@sentry/remix', conditions: NODE, formats: BOTH }, + { specifier: '@sentry/remix', conditions: ['worker'], formats: BOTH }, + { specifier: '@sentry/remix/cloudflare', conditions: WORKERD, formats: BOTH }, + { specifier: '@sentry/remix/import', conditions: NODE, formats: ESM }, + + { specifier: '@sentry/solidstart', conditions: NODE, formats: BOTH }, + + { specifier: '@sentry/tanstackstart-react', conditions: NODE, formats: BOTH }, + { specifier: '@sentry/tanstackstart-react/import', conditions: NODE, formats: ESM }, +]; + +function writeFixture(specifier: string, conditions: string[], format: Format): string { + fs.mkdirSync(FIXTURE_DIR, { recursive: true }); + const name = `${specifier.replace(/[@/]/g, '_')}-${conditions.join('_')}.${format === 'esm' ? 'mjs' : 'cjs'}`; + const file = path.join(FIXTURE_DIR, name); + fs.writeFileSync(file, format === 'esm' ? `import '${specifier}';\n` : `require('${specifier}');\n`); + return file; +} + +function isBuildTime(file: string): boolean { + return BUILD_TIME_PATTERNS.some(pattern => pattern.test(file)); +} + +type Reasons = Awaited>['reasons']; + +// Most entries share `@sentry/node` and its dependencies, so sharing the +// file and analysis cache saves most of the work. +const traceCache = {}; + +/** The shortest import chain from `entry` to `file`, both relative to `ROOT_DIR`. */ +function importChain(reasons: Reasons, entry: string, file: string): string[] { + const importedBy = new Map([[file, file]]); + const queue = [file]; + while (queue.length) { + const current = queue.shift() as string; + if (current === entry) { + const chain = [current]; + for (let link = current; link !== file;) { + link = importedBy.get(link) as string; + chain.push(link); + } + return chain; + } + for (const parent of reasons.get(current)?.parents ?? []) { + if (!importedBy.has(parent)) { + importedBy.set(parent, current); + queue.push(parent); + } + } + } + return [file]; +} + +/** + * Each place where a runtime module imports a build-time module, as the + * import chain from the entry. + */ +async function findBuildTimeImports(fixture: string, conditions: string[]): Promise { + const imports: [importer: string, file: string][] = []; + + const { fileList, reasons } = await nodeFileTrace([fixture], { + base: ROOT_DIR, + conditions, + cache: traceCache, + // A dynamic `fs` path in config code makes nft glob the whole repo. + // That is slow and finds user files, not package code. + analysis: { emitGlobs: false }, + // Stop at the first build-time module, instead of tracing all of Vite + // or Babel. `.wasm` files are assets, so they stay in the trace with + // their importer. + ignore: file => isBuildTime(file) && !file.endsWith('.wasm'), + async resolve(id, parent, job, cjsResolve) { + const resolved = await resolve(id, parent, job, cjsResolve); + for (const file of [resolved].flat()) { + if (isBuildTime(file)) { + imports.push([path.relative(ROOT_DIR, parent), path.relative(ROOT_DIR, file)]); + } + } + return resolved; + }, + }); + + for (const file of fileList) { + if (file.endsWith('.wasm')) { + for (const parent of reasons.get(file)?.parents ?? []) { + imports.push([parent, file]); + } + } + } + + const entry = path.relative(ROOT_DIR, fixture); + const violations = imports.map(([importer, file]) => + importChain(reasons, entry, importer).concat(file).join('\n -> '), + ); + return [...new Set(violations)].sort(); +} + +describe('server runtime entries do not reach build-time tooling', () => { + for (const { specifier, conditions, formats } of CASES) { + for (const format of formats) { + const name = `${specifier} (${format}, conditions: ${conditions.join(', ')})`; + + if (KNOWN_VIOLATIONS.has(specifier)) { + test(`${name} is a known violation`, async () => { + const violations = await findBuildTimeImports(writeFixture(specifier, conditions, format), conditions); + // Remove the entry from KNOWN_VIOLATIONS when this fails. + expect(violations).not.toEqual([]); + }); + } else { + test(name, async () => { + const violations = await findBuildTimeImports(writeFixture(specifier, conditions, format), conditions); + expect(violations).toEqual([]); + }); + } + } + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 617e70dc091a..9d9050c1f98e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -432,6 +432,49 @@ importers: webpack: specifier: ^5.0.0 version: 5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3) + devDependencies: + '@sentry/astro': + specifier: 11.9.0 + version: link:../../packages/astro + '@sentry/bun': + specifier: 11.9.0 + version: link:../../packages/bun + '@sentry/cloudflare': + specifier: 11.9.0 + version: link:../../packages/cloudflare + '@sentry/deno': + specifier: 11.9.0 + version: link:../../packages/deno + '@sentry/nextjs': + specifier: 11.9.0 + version: link:../../packages/nextjs + '@sentry/node': + specifier: 11.9.0 + version: link:../../packages/node + '@sentry/nuxt': + specifier: 11.9.0 + version: link:../../packages/nuxt + '@sentry/react-router': + specifier: 11.9.0 + version: link:../../packages/react-router + '@sentry/remix': + specifier: 11.9.0 + version: link:../../packages/remix + '@sentry/solidstart': + specifier: 11.9.0 + version: link:../../packages/solidstart + '@sentry/sveltekit': + specifier: 11.9.0 + version: link:../../packages/sveltekit + '@sentry/tanstackstart-react': + specifier: 11.9.0 + version: link:../../packages/tanstackstart-react + '@sentry/vercel-edge': + specifier: 11.9.0 + version: link:../../packages/vercel-edge + '@vercel/nft': + specifier: ^1.11.0 + version: 1.11.0(encoding@0.1.13)(rollup@4.62.3)(supports-color@10.0.0) dev-packages/clear-cache-gh-action: dependencies: