diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.server.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs rename to dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.server.mjs diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json index 0ce52dd6cca2..515a9a131f55 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json @@ -25,8 +25,8 @@ }, "scripts": { "build": "react-router build", - "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev", - "start": "NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js", + "dev": "react-router dev", + "start": "react-router-serve ./build/server/index.js", "proxy": "node start-event-proxy.mjs", "typecheck": "react-router typegen && tsc", "clean": "npx rimraf node_modules pnpm-lock.yaml", diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/react-router.config.ts index 72f2eef3b0f5..62fddf4ae812 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/react-router.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/react-router.config.ts @@ -1,4 +1,5 @@ import type { Config } from '@react-router/dev/config'; +import { sentryOnBuildEnd } from '@sentry/react-router'; export default { ssr: true, @@ -6,4 +7,6 @@ export default { future: { v8_middleware: true, }, + // Required for `autoInjectServerInstrumentation`: the auto-injection runs in this build-end hook. + buildEnd: sentryOnBuildEnd, } satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/vite.config.ts index 4da306d41cc7..30d1afa141b4 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/vite.config.ts @@ -6,6 +6,13 @@ export default defineConfig(async config => ({ plugins: [ reactRouter(), // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...((await sentryReactRouter({ sourcemaps: { disable: true } }, config)) as any[]), + ...((await sentryReactRouter( + { + sourcemaps: { disable: true }, + // Auto-inject server instrumentation into the build output - no `NODE_OPTIONS='--import ...'` needed. + autoInjectServerInstrumentation: true, + }, + config, + )) as any[]), ], })); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.server.tsx index b646f036b3ad..178a8ed4e377 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.server.tsx @@ -1,4 +1,3 @@ -import '../instrument.mjs'; import { createReadableStreamFromReadable } from '@react-router/node'; import * as Sentry from '@sentry/react-router'; import { renderToPipeableStream } from 'react-dom/server'; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/instrument.server.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/instrument.mjs rename to dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/instrument.server.mjs diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/react-router.config.ts index 51e8967770b3..a5314e0a13b6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/react-router.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/react-router.config.ts @@ -1,5 +1,7 @@ import type { Config } from '@react-router/dev/config'; +import { sentryOnBuildEnd } from '@sentry/react-router'; export default { ssr: true, + buildEnd: sentryOnBuildEnd, } satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/vite.config.ts index 494f3d41f2ce..e28131928b5b 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/vite.config.ts @@ -1,13 +1,24 @@ import { reactRouter } from '@react-router/dev/vite'; +import { sentryReactRouter } from '@sentry/react-router'; import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import { defineConfig } from 'vite'; -export default defineConfig({ +export default defineConfig(async config => ({ plugins: [ reactRouter(), // Runs the orchestrion code transform over the SSR server bundle and // force-bundles the instrumented deps (mysql, ioredis, …) so the // diagnostics-channel calls are actually injected at build time. sentryOrchestrionPlugin(), + // Auto-injects `instrument.server.mjs` into the server build output (top-level import), + // so no manual `import '../instrument.server.mjs'` or `--import` flag is needed. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...((await sentryReactRouter( + { + sourcemaps: { disable: true }, + autoInjectServerInstrumentation: true, + }, + config, + )) as any[]), ], -}); +})); diff --git a/packages/react-router/src/vite/buildEnd/detectDeployTarget.ts b/packages/react-router/src/vite/buildEnd/detectDeployTarget.ts new file mode 100644 index 000000000000..0010b6c9fcd1 --- /dev/null +++ b/packages/react-router/src/vite/buildEnd/detectDeployTarget.ts @@ -0,0 +1,37 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// Dependencies that signal a Cloudflare Workers build. There is no long-running Node server entry to wrap and +// `Sentry.init` must run inside the worker, so build-time injection must be skipped entirely for these apps. +const CLOUDFLARE_DEPENDENCIES = ['@cloudflare/vite-plugin', 'wrangler', '@react-router/cloudflare']; + +/** + * Whether the given set of (dev)dependencies indicates a Cloudflare Workers target. Pure and therefore + * unit-testable without touching the filesystem. + */ +export function isCloudflareTarget(dependencies: Record): boolean { + return CLOUDFLARE_DEPENDENCIES.some(dep => dependencies[dep]); +} + +/** + * Detects whether a React Router app targets Cloudflare Workers by reading its `package.json` dependencies. + * On any read/parse error we assume it does not (auto-injection then proceeds and falls back to its own guards). + * + * @param root - The (absolute) project root directory, e.g. Vite's `config.root`. + */ +export function detectCloudflareTarget(root: string): boolean { + try { + const packageJsonPath = path.resolve(root, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as { + dependencies?: Record; + devDependencies?: Record; + }; + + return isCloudflareTarget({ + ...packageJson.dependencies, + ...packageJson.devDependencies, + }); + } catch { + return false; + } +} diff --git a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts index 2cdd2c5cd09b..b06485afa530 100644 --- a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts +++ b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts @@ -4,13 +4,48 @@ import SentryCli from '@sentry/cli'; import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; import { glob } from 'glob'; import type { SentryReactRouterBuildOptions } from '../types'; +import { DEFAULT_SERVER_INSTRUMENTATION_FILE, injectServerInstrumentation } from './injectServerInstrumentation'; type BuildEndHook = NonNullable; +type BuildEndHookArgs = Parameters[0]; + +/** + * Auto-injects Sentry server instrumentation into the build output when `autoInjectServerInstrumentation` is enabled. + * Extracted from `sentryOnBuildEnd` to keep that hook's complexity manageable. + */ +async function maybeAutoInjectServerInstrumentation( + sentryConfig: SentryReactRouterBuildOptions, + reactRouterConfig: BuildEndHookArgs['reactRouterConfig'], + viteConfig: BuildEndHookArgs['viteConfig'], + debug: boolean, +): Promise { + if (!sentryConfig.autoInjectServerInstrumentation) { + return; + } + + try { + await injectServerInstrumentation({ + root: viteConfig.root, + buildDirectory: reactRouterConfig.buildDirectory, + serverBuildFile: reactRouterConfig.serverBuildFile, + serverModuleFormat: reactRouterConfig.serverModuleFormat, + ssr: reactRouterConfig.ssr, + hasServerBundles: !!reactRouterConfig.serverBundles, + serverInstrumentationFile: sentryConfig.serverInstrumentationFile ?? DEFAULT_SERVER_INSTRUMENTATION_FILE, + debug, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('[Sentry] Could not auto-inject server instrumentation', error); + } +} function getSentryConfig(viteConfig: unknown): SentryReactRouterBuildOptions { if (!viteConfig || typeof viteConfig !== 'object' || !('sentryConfig' in viteConfig)) { // eslint-disable-next-line no-console console.error('[Sentry] sentryConfig not found - it needs to be passed to vite.config.ts'); + // Fall back to an empty config so the build hook degrades gracefully instead of throwing on destructuring. + return {}; } return (viteConfig as { sentryConfig: SentryReactRouterBuildOptions }).sentryConfig; @@ -148,4 +183,8 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo console.error('Error deleting files after sourcemap upload:', error); } } + + // Auto-inject server instrumentation into the built server bundle (after source maps are handled, so we never + // interfere with debug-id injection / upload). + await maybeAutoInjectServerInstrumentation(sentryConfig, reactRouterConfig, viteConfig, debug); }; diff --git a/packages/react-router/src/vite/buildEnd/injectServerInstrumentation.ts b/packages/react-router/src/vite/buildEnd/injectServerInstrumentation.ts new file mode 100644 index 000000000000..f91350bdca98 --- /dev/null +++ b/packages/react-router/src/vite/buildEnd/injectServerInstrumentation.ts @@ -0,0 +1,154 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { consoleSandbox } from '@sentry/core'; +import { detectCloudflareTarget } from './detectDeployTarget'; + +/** Marker written into generated files so re-runs of the build are idempotent and don't double-inject. */ +export const SENTRY_AUTO_INJECT_MARKER = '/* @sentry/react-router auto-injected server instrumentation */'; + +/** Default (relative to project root) path to the user's pre-built ESM server instrumentation file. */ +export const DEFAULT_SERVER_INSTRUMENTATION_FILE = './instrument.server.mjs'; + +const LOG_PREFIX = '[Sentry React Router]'; + +interface InjectServerInstrumentationOptions { + /** Absolute path to the project root (Vite's `config.root`). */ + root: string; + /** Absolute path to the build directory (`reactRouterConfig.buildDirectory`). */ + buildDirectory: string; + /** The server build file name (`reactRouterConfig.serverBuildFile`, e.g. `index.js`). */ + serverBuildFile: string; + /** + * The server build output format (`reactRouterConfig.serverModuleFormat`, `'esm'` or `'cjs'`). We only inject the + * ESM `import` prefix into ESM builds; CJS builds are skipped with a warning. + */ + serverModuleFormat: 'esm' | 'cjs'; + /** Whether SSR is enabled (`reactRouterConfig.ssr`). When `false` there is no server build to wrap. */ + ssr: boolean; + /** Whether server bundles are in use (`reactRouterConfig.serverBundles`). Not supported by auto-injection. */ + hasServerBundles: boolean; + /** Path (relative to root) to the user's server instrumentation file. */ + serverInstrumentationFile: string; + debug: boolean; +} + +/** + * Generates the top-level import prefix that is prepended to the server build entry. This loads the Sentry server + * config before the rest of the entry module body runs. + * + * With orchestrion (diagnostics-channel) instrumentation, a top-level import is sufficient: instrumented libraries + * are patched as they are loaded, so there is no need to defer the entry behind a dynamic `import()`. + */ +export function generateTopLevelImportPrefix(instrumentationImportPath: string): string { + return `${SENTRY_AUTO_INJECT_MARKER}\nimport ${JSON.stringify(instrumentationImportPath)};\n`; +} + +function log(message: string, debug: boolean): void { + if (!debug) { + return; + } + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log(`${LOG_PREFIX} ${message}`); + }); +} + +function warn(message: string): void { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn(`${LOG_PREFIX} ${message}`); + }); +} + +/** + * Auto-injects Sentry server instrumentation into the React Router server build output, removing the need for a + * manual `NODE_OPTIONS='--import ./instrument.server.mjs'`. Called from `sentryOnBuildEnd` after source map + * handling, so it never interferes with debug-id injection / source map upload. + * + * Gracefully no-ops (with a debug log) for SPA/prerender-only builds and skips (with a warning) for deploy targets + * that aren't supported yet. + */ +export async function injectServerInstrumentation(options: InjectServerInstrumentationOptions): Promise { + const { + root, + buildDirectory, + serverBuildFile, + serverModuleFormat, + ssr, + hasServerBundles, + serverInstrumentationFile, + debug, + } = options; + + // SPA / prerender-only builds have no server entry to wrap. + if (!ssr) { + log('`ssr` is disabled (SPA mode) - skipping server instrumentation auto-injection.', debug); + return; + } + + // We prepend an ESM `import`, which is invalid in a CJS server build and would crash the server at startup. + if (serverModuleFormat === 'cjs') { + warn( + '`autoInjectServerInstrumentation` only supports ESM server builds (`serverModuleFormat: "esm"`) - skipping. ' + + 'Please import your server instrumentation file manually at the top of your server entry instead.', + ); + return; + } + + if (hasServerBundles) { + warn( + '`autoInjectServerInstrumentation` does not support `serverBundles` yet - skipping. ' + + 'Please import your server instrumentation file manually at the top of your server entry instead.', + ); + return; + } + + if (detectCloudflareTarget(root)) { + log( + 'Detected a Cloudflare deploy target - skipping injection. Initialize Sentry inside your worker instead.', + debug, + ); + return; + } + + // Read the server entry directly rather than `existsSync`-then-read: checking first opens a + // file-system race (the file could change between check and use) and CodeQL flags it. + const serverEntryPath = path.resolve(buildDirectory, 'server', serverBuildFile); + let originalContent: string; + try { + originalContent = fs.readFileSync(serverEntryPath, 'utf-8'); + } catch { + warn(`Could not read server build entry at \`${serverEntryPath}\` - skipping auto-injection.`); + return; + } + + // Idempotency: if we already injected (e.g. a rebuild without cleaning the output dir), do nothing. + if (originalContent.includes(SENTRY_AUTO_INJECT_MARKER)) { + log('Server build already instrumented - skipping.', debug); + return; + } + + // Copy the user's instrumentation file next to the server entry so the build output is self-contained. + // Attempt the copy directly (no prior `existsSync` check) to avoid a check-then-use race. + const copiedInstrumentationFileName = 'instrument.server.mjs'; + const copiedInstrumentationPath = path.resolve(buildDirectory, 'server', copiedInstrumentationFileName); + const instrumentationSourcePath = path.resolve(root, serverInstrumentationFile); + try { + fs.copyFileSync(instrumentationSourcePath, copiedInstrumentationPath); + } catch { + warn( + `Could not read server instrumentation file at \`${instrumentationSourcePath}\`. ` + + 'Create it (calling `Sentry.init`) or set the `autoInjectServerInstrumentation` option to `false`.', + ); + return; + } + const instrumentationImportPath = `./${copiedInstrumentationFileName}`; + + fs.writeFileSync(serverEntryPath, generateTopLevelImportPrefix(instrumentationImportPath) + originalContent); + + log( + `Prepended a top-level import of \`${instrumentationImportPath}\` to \`${serverBuildFile}\` so Sentry is ` + + 'initialized before the server starts.', + debug, + ); +} diff --git a/packages/react-router/src/vite/types.ts b/packages/react-router/src/vite/types.ts index 3f0ab34879b6..8ceec5337319 100644 --- a/packages/react-router/src/vite/types.ts +++ b/packages/react-router/src/vite/types.ts @@ -74,4 +74,24 @@ export type SentryReactRouterBuildOptions = BuildTimeOptionsBase & */ sourceMapsUploadOptions?: SourceMapsOptions; // todo(v11): Remove this option (all options already exist in BuildTimeOptionsBase) + + /** + * Prepends a top-level import of your server instrumentation file (see {@link serverInstrumentationFile}) to the + * built server entry. + * + * Requires `buildEnd: sentryOnBuildEnd` in `react-router.config.ts`. + * no-ops for CJS/SPA/serverless/Cloudflare targets. Do not also use `--import`, or Sentry initializes twice. + * + * @default false + */ + // todo(v11): Default this to `true`. + autoInjectServerInstrumentation?: boolean; + + /** + * Path (relative to the project root) to the server instrumentation file that calls `Sentry.init`. + * Only used when {@link autoInjectServerInstrumentation} is enabled. + * + * @default './instrument.server.mjs' + */ + serverInstrumentationFile?: string; }; diff --git a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts index a607ff3ccfc6..1c948c2bb317 100644 --- a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts +++ b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts @@ -365,4 +365,16 @@ describe('sentryOnBuildEnd', () => { url: 'https://custom-instance.ejemplo.es', }); }); + + it('does not throw when sentryConfig is missing from viteConfig', async () => { + const config = { + ...defaultConfig, + viteConfig: { + build: { sourcemap: true }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await expect(sentryOnBuildEnd(config)).resolves.toBeUndefined(); + }); }); diff --git a/packages/react-router/test/vite/buildEnd/injectServerInstrumentation.test.ts b/packages/react-router/test/vite/buildEnd/injectServerInstrumentation.test.ts new file mode 100644 index 000000000000..0aaac71acccc --- /dev/null +++ b/packages/react-router/test/vite/buildEnd/injectServerInstrumentation.test.ts @@ -0,0 +1,150 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { isCloudflareTarget } from '../../../src/vite/buildEnd/detectDeployTarget'; +import { + generateTopLevelImportPrefix, + injectServerInstrumentation, + SENTRY_AUTO_INJECT_MARKER, +} from '../../../src/vite/buildEnd/injectServerInstrumentation'; + +describe('generateTopLevelImportPrefix', () => { + it('produces a marked top-level import', () => { + const prefix = generateTopLevelImportPrefix('./instrument.server.mjs'); + expect(prefix).toContain(SENTRY_AUTO_INJECT_MARKER); + expect(prefix).toContain('import "./instrument.server.mjs";'); + }); +}); + +describe('isCloudflareTarget', () => { + it.each([ + [true, { '@cloudflare/vite-plugin': '1.0.0', '@react-router/node': '7.0.0' }], + [true, { wrangler: '3.0.0' }], + [true, { '@react-router/cloudflare': '7.0.0' }], + [false, { '@react-router/serve': '7.0.0' }], + [false, { '@react-router/node': '7.0.0' }], + [false, { '@vercel/react-router': '1.0.0' }], + [false, { react: '18.0.0' }], + ] as const)('returns %s for the given deps', (expected, deps) => { + expect(isCloudflareTarget(deps)).toBe(expected); + }); +}); + +describe('injectServerInstrumentation (filesystem)', () => { + let tmpRoot: string; + let buildDirectory: string; + let serverDir: string; + + const SERVER_BUILD = [ + 'import * as route0 from "./assets/home.js";', + 'export const entry = { module: route0 };', + 'export const routes = {};', + 'export const ssr = true;', + ].join('\n'); + + function setup({ + withInstrumentFile = true, + deps = { '@react-router/serve': '7.0.0' }, + }: { withInstrumentFile?: boolean; deps?: Record } = {}): void { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-rr-')); + buildDirectory = path.join(tmpRoot, 'build'); + serverDir = path.join(buildDirectory, 'server'); + fs.mkdirSync(serverDir, { recursive: true }); + fs.writeFileSync(path.join(serverDir, 'index.js'), SERVER_BUILD); + fs.writeFileSync(path.join(tmpRoot, 'package.json'), JSON.stringify({ dependencies: deps })); + if (withInstrumentFile) { + fs.writeFileSync(path.join(tmpRoot, 'instrument.server.mjs'), 'import * as Sentry from "@sentry/react-router";'); + } + } + + const baseOptions = { + root: '', + buildDirectory: '', + serverBuildFile: 'index.js', + serverModuleFormat: 'esm' as 'esm' | 'cjs', + ssr: true, + hasServerBundles: false, + serverInstrumentationFile: './instrument.server.mjs', + debug: false, + }; + + function options(overrides: Partial = {}): typeof baseOptions { + return { ...baseOptions, root: tmpRoot, buildDirectory, ...overrides }; + } + + function readServerEntry(): string { + return fs.readFileSync(path.join(serverDir, 'index.js'), 'utf-8'); + } + + afterEach(() => { + if (tmpRoot) { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + it('prepends a top-level import, copies the instrumentation file, and preserves the original body', async () => { + setup(); + await injectServerInstrumentation(options()); + + const entry = readServerEntry(); + expect(entry).toContain(SENTRY_AUTO_INJECT_MARKER); + expect(entry).toContain('import "./instrument.server.mjs";'); + // original body is kept after the injected prefix + expect(entry).toContain('export const entry = { module: route0 };'); + expect(entry.indexOf(SENTRY_AUTO_INJECT_MARKER)).toBeLessThan(entry.indexOf('export const entry')); + // instrumentation file copied next to the server entry + expect(fs.existsSync(path.join(serverDir, 'instrument.server.mjs'))).toBe(true); + }); + + it('is idempotent - a second run does not double-inject', async () => { + setup(); + await injectServerInstrumentation(options()); + const first = readServerEntry(); + await injectServerInstrumentation(options()); + const second = readServerEntry(); + + expect(second).toBe(first); + expect( + second.match(new RegExp(SENTRY_AUTO_INJECT_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')), + ).toHaveLength(1); + }); + + it('no-ops for SPA (ssr: false) builds', async () => { + setup(); + await injectServerInstrumentation(options({ ssr: false })); + expect(readServerEntry()).not.toContain(SENTRY_AUTO_INJECT_MARKER); + }); + + it('skips when serverBundles are in use', async () => { + setup(); + await injectServerInstrumentation(options({ hasServerBundles: true })); + expect(readServerEntry()).not.toContain(SENTRY_AUTO_INJECT_MARKER); + }); + + it('skips for CJS server builds (ESM import would crash a CJS bundle)', async () => { + setup(); + await injectServerInstrumentation(options({ serverModuleFormat: 'cjs' })); + expect(readServerEntry()).not.toContain(SENTRY_AUTO_INJECT_MARKER); + // must not copy the instrumentation file either + expect(fs.existsSync(path.join(serverDir, 'instrument.server.mjs'))).toBe(false); + }); + + it('skips for a cloudflare deploy target', async () => { + setup({ deps: { '@cloudflare/vite-plugin': '1.0.0' } }); + await injectServerInstrumentation(options()); + expect(readServerEntry()).not.toContain(SENTRY_AUTO_INJECT_MARKER); + }); + + it('injects for a serverless (non-cloudflare) deploy target', async () => { + setup({ deps: { '@vercel/react-router': '1.0.0' } }); + await injectServerInstrumentation(options()); + expect(readServerEntry()).toContain(SENTRY_AUTO_INJECT_MARKER); + }); + + it('skips when the instrumentation file is missing', async () => { + setup({ withInstrumentFile: false }); + await injectServerInstrumentation(options()); + expect(readServerEntry()).not.toContain(SENTRY_AUTO_INJECT_MARKER); + }); +});