diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx index 005268b40ad0..0215cdd728b9 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx @@ -6,8 +6,8 @@ import { HydratedRouter } from 'react-router/dom'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions // todo: get this from env - dsn: 'https://username@domain/123', - tunnel: `http://localhost:3031/`, // proxy server + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://username@domain/123', + tunnel: __E2E_TEST_TUNNEL__ ? true : `http://localhost:3031/`, // proxy server integrations: [Sentry.reactRouterTracingIntegration()], tracesSampleRate: 1.0, tracePropagationTargets: [/^\//], diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts new file mode 100644 index 000000000000..dc2e8cbcb587 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts @@ -0,0 +1 @@ +declare const __E2E_TEST_TUNNEL__: boolean; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx index bc1b8f1236c0..3ae4b730b9e1 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx @@ -2,6 +2,20 @@ import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } import type { Route } from './+types/root'; import stylesheet from './app.css?url'; +// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. Other variants +// get none, so their span assertions stay as they are. +export const middleware: Route.MiddlewareFunction[] = __E2E_TEST_TUNNEL__ + ? [ + ({ request }, next) => { + if (request.method === 'POST' && request.headers.get('x-auth') !== 'ok') { + return new Response('blocked by user auth', { status: 401 }); + } + + return next(); + }, + ] + : []; + export const links: Route.LinksFunction = () => [ { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, { diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs index c16240141b6d..8a52c804b85a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs @@ -1,8 +1,11 @@ import * as Sentry from '@sentry/react-router'; Sentry.init({ - dsn: 'https://username@domain/123', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://username@domain/123', environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, - tunnel: `http://localhost:3031/`, // proxy server + // The tunnel variants also accept another app's DSN, so the server serves more than one tunnel path. + tunnel: process.env.E2E_TEST_TUNNEL + ? { allowedDsns: ['http://other@localhost:3031/1338'] } + : `http://localhost:3031/`, }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index 552e505b3457..db1993aa7905 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -44,7 +44,13 @@ "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:ts && pnpm test:playwright", "test:ts": "pnpm typecheck", - "test:playwright": "playwright test" + "test:playwright": "playwright test", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test:playwright tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm test:playwright tunnel", + "test:assert:tunnel-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 pnpm test:playwright tunnel", + "test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm test:playwright tunnel" }, "eslintConfig": { "extends": [ @@ -85,6 +91,26 @@ { "runtime": "cloudflare", "label": "react-router-8-framework (cloudflare)" + }, + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "react-router-8-framework (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "react-router-8-framework (tunnel-basepath)" + }, + { + "label": "react-router-8-framework (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" + }, + { + "label": "react-router-8-framework (tunnel-basepath-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev" } ] } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs index 6e93d6fc020e..2fa8a93bb8bc 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs @@ -10,7 +10,7 @@ const startCommands = { const config = getPlaywrightConfig( { - startCommand: startCommands[getRuntime()], + startCommand: process.env.TEST_ENV === 'development' ? 'pnpm dev --port 3030' : startCommands[getRuntime()], port: 3030, }, // Boot Redis before the tests run, outside the webServer startup-timeout window. diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts index bb1f96469dd2..a74ae0b46048 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts @@ -2,5 +2,7 @@ import type { Config } from '@react-router/dev/config'; export default { ssr: true, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { basename: `${process.env.E2E_TEST_BASE_PATH}/` }), prerender: ['/performance/static'], } satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts new file mode 100644 index 000000000000..03f309305e78 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'react-router-8-framework'; +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the root middleware', async ({ page }) => { + // The variant's root middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/`, { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + APP_NAME, + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/performance`, + ); + await page.request.get(`${BASE_PATH}/performance`); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts index 002bb3a77fbe..032293400dbf 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts @@ -5,6 +5,9 @@ import { sentryReactRouter } from '@sentry/react-router/vite'; import { defineConfig } from 'vite'; export default defineConfig(async config => ({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, plugins: [ cloudflare({ viteEnvironment: { name: 'ssr' } }), reactRouter(), diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts index 0fe4cdde8c7a..3663eb2a74b5 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts @@ -4,6 +4,9 @@ import { defineConfig } from 'vite'; export default defineConfig(config => { return { + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, plugins: [reactRouter(), sentryReactRouter({ sourcemaps: { disable: true } }, config)], }; }); diff --git a/packages/react-router/src/client/sdk.ts b/packages/react-router/src/client/sdk.ts index a9b13f2e7cd0..5c140ca27f36 100644 --- a/packages/react-router/src/client/sdk.ts +++ b/packages/react-router/src/client/sdk.ts @@ -1,10 +1,22 @@ import type { BrowserOptions } from '@sentry/browser'; import { init as browserInit } from '@sentry/browser'; import type { Client } from '@sentry/core'; -import { applySdkMetadata, consoleSandbox, setTag } from '@sentry/core'; +import { + applySdkMetadata, + applyTunnelDefaults, + consoleSandbox, + GLOBAL_OBJ, + normalizeTunnelBasePath, + setTag, +} from '@sentry/core'; const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; +// React Router inlines this context before the client entry runs, so it's there before `init`. +const GLOBAL_OBJ_WITH_ROUTER_CONTEXT = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + __reactRouterContext?: { basename?: string }; +}; + /** * Initializes the client side of the React Router SDK. */ @@ -25,6 +37,10 @@ export function init(options: BrowserOptions): Client | undefined { } } + // The managed tunnel is served under the app's `basename`, like every other route. A `basePath` the user set wins. + const basePath = normalizeTunnelBasePath(GLOBAL_OBJ_WITH_ROUTER_CONTEXT.__reactRouterContext?.basename); + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); + applySdkMetadata(options, 'react-router', ['react-router', 'browser']); const client = browserInit(options); diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index b94430728125..23dc670a803e 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -4,6 +4,7 @@ import { isPrerenderRequest } from '../server/serverBuild'; export * from '../client'; export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest'; +export { createSentryTunnelRoutes } from '../server/tunnelRoute'; export { createSentryServerInstrumentation, type CreateSentryServerInstrumentationOptions, diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index 8245169f01a7..aaa6bfcb4e14 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -8,6 +8,7 @@ export { wrapSentryHandleRequest } from './wrapSentryHandleRequest'; export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest'; export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError'; export { getMetaTagTransformer } from './getMetaTagTransformer'; +export { createSentryTunnelRoutes } from './tunnelRoute'; // React Router instrumentation API support export { diff --git a/packages/react-router/src/server/tunnelRoute.ts b/packages/react-router/src/server/tunnelRoute.ts new file mode 100644 index 000000000000..2d1ce3d3744b --- /dev/null +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -0,0 +1,68 @@ +import { debug, getClient, getTunnelPaths, handleTunnelRequestIfMatched, setManagedTunnelBasePath } from '@sentry/core'; +import { DEBUG_BUILD } from '../common/debug-build'; + +// Never matches a request, for when the managed tunnel is off. +const DISABLED_TUNNEL_PATH = '/__sentry-tunnel-disabled__'; + +/** + * The resource routes that serve the managed tunnel, one per tunnel path. `sentryReactRouter()` adds them to the + * server build, since React Router has no request hook to answer from and a route matches only one path. Not meant + * to be used directly. + * + * @param basename The app's `basename`, which React Router matches the routes under but leaves in the request URL. + */ +export function createSentryTunnelRoutes(basename?: string): Record> { + // `init` runs from `--import` before the server build loads, so the client and its tunnel paths are usually known. + const client = getClient(); + if (!client) { + return { 'sentry-tunnel': makeTunnelRoute('sentry-tunnel', () => getLazyTunnelPath(basename)) }; + } + + setManagedTunnelBasePath(client, basename); + + return Object.fromEntries( + getTunnelPaths().map((path, index) => { + const id = `sentry-tunnel-${index}`; + + return [id, makeTunnelRoute(id, () => path)]; + }), + ); +} + +/** + * The path of the single route added when `init` runs after the server build loads, e.g. per request on Cloudflare. + * React Router reads it on its first request and keeps it until the server restarts. + */ +function getLazyTunnelPath(basename: string | undefined): string { + const client = getClient(); + if (!client) { + DEBUG_BUILD && debug.warn('The managed tunnel stays off until restart: a request came in before `Sentry.init`.'); + return DISABLED_TUNNEL_PATH; + } + + setManagedTunnelBasePath(client, basename); + + const [path, ...otherPaths] = getTunnelPaths(); + if (otherPaths.length) { + DEBUG_BUILD && debug.warn(`Sentry.init ran after the server build loaded, so only ${path} is served.`); + } + + return path ?? DISABLED_TUNNEL_PATH; +} + +function makeTunnelRoute(id: string, getPath: () => string): Record { + return { + id, + parentId: undefined, + index: undefined, + caseSensitive: undefined, + get path(): string { + return getPath(); + }, + module: { + action: ({ request }: { request: Request }) => + handleTunnelRequestIfMatched(request) ?? new Response(null, { status: 404 }), + loader: () => new Response(null, { status: 405, headers: { Allow: 'POST' } }), + }, + }; +} diff --git a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts new file mode 100644 index 000000000000..4c03163913d9 --- /dev/null +++ b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts @@ -0,0 +1,28 @@ +import type { Plugin } from 'vite'; + +const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build'; + +/** + * Adds the managed tunnel routes to the server build. They only serve requests when `Sentry.init` enables the + * managed tunnel. The server build exports the app's `basename`, which the route needs to match the request URL. + */ +export function makeTunnelRoutePlugin(): Plugin { + return { + name: 'sentry-react-router-tunnel-route', + enforce: 'post', + + transform(code, id, options) { + if (!options?.ssr || !id.includes(SERVER_BUILD_MODULE_ID)) { + return null; + } + + return { + code: `${code} +import { createSentryTunnelRoutes as __sentryCreateTunnelRoutes } from '@sentry/react-router'; +Object.assign(routes, __sentryCreateTunnelRoutes(typeof basename === 'string' ? basename : undefined)); +`, + map: null, + }; + }, + }; +} diff --git a/packages/react-router/src/vite/plugin.ts b/packages/react-router/src/vite/plugin.ts index 602ca6f4c412..2992b9a5bbc0 100644 --- a/packages/react-router/src/vite/plugin.ts +++ b/packages/react-router/src/vite/plugin.ts @@ -4,6 +4,7 @@ import { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin'; import { makeCustomSentryVitePlugins } from './makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from './makeEnableSourceMapsPlugin'; import { makeServerBuildCapturePlugin } from './makeServerBuildCapturePlugin'; +import { makeTunnelRoutePlugin } from './makeTunnelRoutePlugin'; import type { SentryReactRouterBuildOptions } from './types'; /** @@ -21,6 +22,7 @@ export async function sentryReactRouter( plugins.push(makeConfigInjectorPlugin(options)); plugins.push(makeServerBuildCapturePlugin()); + plugins.push(makeTunnelRoutePlugin()); if (process.env.NODE_ENV !== 'development' && viteConfig.command === 'build' && viteConfig.mode !== 'development') { plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation })); diff --git a/packages/react-router/test/client/sdk.test.ts b/packages/react-router/test/client/sdk.test.ts index c932fec91064..4cb7a16fa9d3 100644 --- a/packages/react-router/test/client/sdk.test.ts +++ b/packages/react-router/test/client/sdk.test.ts @@ -41,6 +41,18 @@ describe('React Router client SDK', () => { expect(browserInit).toHaveBeenCalledWith(expect.objectContaining(expectedMetadata)); }); + it("serves the managed tunnel under the app's `basename`, unless the user set a base path", () => { + vi.stubGlobal('__reactRouterContext', { basename: '/app/' }); + + reactRouterInit({ tunnel: true }); + expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining({ tunnel: { basePath: '/app' } })); + + reactRouterInit({ tunnel: { basePath: '/custom' } }); + expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining({ tunnel: { basePath: '/custom' } })); + + vi.unstubAllGlobals(); + }); + it('returns client from init', () => { expect(reactRouterInit({})).not.toBeUndefined(); }); diff --git a/packages/react-router/test/server/tunnelRoute.test.ts b/packages/react-router/test/server/tunnelRoute.test.ts new file mode 100644 index 000000000000..cc27a2b3e385 --- /dev/null +++ b/packages/react-router/test/server/tunnelRoute.test.ts @@ -0,0 +1,79 @@ +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createSentryTunnelRoutes } from '../../src/server/tunnelRoute'; + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; + +type TunnelRoute = { + path: string; + parentId: undefined; + module: { action: (args: { request: Request }) => Promise | Response; loader: () => Response }; +}; + +function firstRoute(basename?: string): TunnelRoute { + return Object.values(createSentryTunnelRoutes(basename))[0] as TunnelRoute; +} + +function setClient(managedTunnel: object | undefined): void { + const options = { dsn: DSN, _managedTunnel: managedTunnel }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); +} + +describe('createSentryTunnelRoutes', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + vi.unstubAllGlobals(); + }); + + it('is a top-level route at the tunnel path when `init` enabled the managed tunnel', () => { + setClient({}); + const route = firstRoute(); + + expect(route.parentId).toBeUndefined(); + expect(route.path).toBe(getTunnelPath(DSN)); + }); + + it('adds no route when the tunnel is off, and a path that never matches when Sentry is not initialized yet', () => { + const route = firstRoute(); + const pathWithoutClient = route.path; + setClient(undefined); + + expect(pathWithoutClient).toBe('/__sentry-tunnel-disabled__'); + expect(route.path).toBe('/__sentry-tunnel-disabled__'); + expect(createSentryTunnelRoutes()).toEqual({}); + }); + + it('forwards tunnel POSTs and rejects other methods', async () => { + setClient({}); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const route = firstRoute(); + const request = new Request(`http://localhost${getTunnelPath(DSN)}`, { + method: 'POST', + body: serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as BodyInit, + }); + + expect(await (await route.module.action({ request })).text()).toBe('forwarded'); + expect(route.module.loader().status).toBe(405); + }); + + it('serves the tunnel under `basename`, which React Router leaves in the request URL', async () => { + setClient({}); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const route = firstRoute('/app/'); + const request = new Request(`http://localhost/app${getTunnelPath(DSN)}`, { + method: 'POST', + body: serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as BodyInit, + }); + + // React Router matches the route under `basename` itself, so the path stays relative to it. + expect(route.path).toBe(getTunnelPath(DSN)); + expect(await (await route.module.action({ request })).text()).toBe('forwarded'); + }); +}); diff --git a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts new file mode 100644 index 000000000000..4982ed40acc1 --- /dev/null +++ b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { makeTunnelRoutePlugin } from '../../src/vite/makeTunnelRoutePlugin'; + +type Transform = (code: string, id: string, options?: { ssr?: boolean }) => { code: string } | null; + +describe('makeTunnelRoutePlugin', () => { + const transform = makeTunnelRoutePlugin().transform as unknown as Transform; + + it('adds the tunnel route to the SSR server build', () => { + const result = transform('export const routes = {};', '\0virtual:react-router/server-build', { ssr: true }); + + expect(result?.code).toContain( + "Object.assign(routes, __sentryCreateTunnelRoutes(typeof basename === 'string' ? basename : undefined));", + ); + expect(result?.code).toContain("from '@sentry/react-router'"); + }); + + it('leaves other modules and the client build alone', () => { + expect(transform('', '\0virtual:react-router/server-build', { ssr: false })).toBeNull(); + expect(transform('', '/app/root.tsx', { ssr: true })).toBeNull(); + }); +}); diff --git a/packages/react-router/test/vite/plugin.test.ts b/packages/react-router/test/vite/plugin.test.ts index 52cbecceb9a9..5c33619821df 100644 --- a/packages/react-router/test/vite/plugin.test.ts +++ b/packages/react-router/test/vite/plugin.test.ts @@ -3,6 +3,7 @@ import { makeConfigInjectorPlugin } from '../../src/vite/makeConfigInjectorPlugi import { makeCustomSentryVitePlugins } from '../../src/vite/makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from '../../src/vite/makeEnableSourceMapsPlugin'; import { makeServerBuildCapturePlugin } from '../../src/vite/makeServerBuildCapturePlugin'; +import { makeTunnelRoutePlugin } from '../../src/vite/makeTunnelRoutePlugin'; import { sentryReactRouter } from '../../src/vite/plugin'; vi.spyOn(console, 'log').mockImplementation(() => { @@ -16,6 +17,7 @@ vi.mock('../../src/vite/makeCustomSentryVitePlugins'); vi.mock('../../src/vite/makeEnableSourceMapsPlugin'); vi.mock('../../src/vite/makeConfigInjectorPlugin'); vi.mock('../../src/vite/makeServerBuildCapturePlugin'); +vi.mock('../../src/vite/makeTunnelRoutePlugin'); // Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in). // Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant. @@ -31,6 +33,7 @@ describe('sentryReactRouter', () => { const mockSourceMapsPlugin = { name: 'source-maps-plugin' }; const mockConfigInjectorPlugin = { name: 'sentry-config-injector' }; const mockServerBuildCapturePlugin = { name: 'sentry-react-router-server-build-capture' }; + const mockTunnelRoutePlugin = { name: 'sentry-react-router-tunnel-route' }; const mockOrchestrionPlugin = { name: 'sentry-orchestrion-vite' }; beforeEach(() => { @@ -39,6 +42,7 @@ describe('sentryReactRouter', () => { vi.mocked(makeEnableSourceMapsPlugin).mockReturnValue(mockSourceMapsPlugin); vi.mocked(makeConfigInjectorPlugin).mockReturnValue(mockConfigInjectorPlugin); vi.mocked(makeServerBuildCapturePlugin).mockReturnValue(mockServerBuildCapturePlugin); + vi.mocked(makeTunnelRoutePlugin).mockReturnValue(mockTunnelRoutePlugin); }); afterEach(() => { @@ -51,7 +55,7 @@ describe('sentryReactRouter', () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); @@ -61,7 +65,7 @@ describe('sentryReactRouter', () => { it('should return config injector plugin when not in build mode', async () => { const result = await sentryReactRouter({}, { command: 'serve', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -69,7 +73,7 @@ describe('sentryReactRouter', () => { it('should return config injector plugin in development build mode', async () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'development' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -83,6 +87,7 @@ describe('sentryReactRouter', () => { expect(result).toEqual([ mockConfigInjectorPlugin, mockServerBuildCapturePlugin, + mockTunnelRoutePlugin, mockOrchestrionPlugin, mockSourceMapsPlugin, ...mockPlugins,