From 340b4ad0fe6c9f95d996ce89a9ad078dd2080ab0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 12:43:44 -0400 Subject: [PATCH 1/6] feat(react-router): Serve the managed tunnel from a route in the server build React Router has no request hook to answer from, so `sentryReactRouter()` adds a resource route whose path is read from the DSN at runtime. React Router reads it on its first request, so a request before `Sentry.init` keeps the tunnel off until restart. --- .../app/entry.client.tsx | 4 +- .../react-router-8-framework/app/globals.d.ts | 1 + .../react-router-8-framework/app/root.tsx | 14 +++++ .../react-router-8-framework/instrument.mjs | 4 +- .../react-router-8-framework/package.json | 9 ++- .../tests/tunnel.test.ts | 40 +++++++++++++ .../react-router-8-framework/vite.config.ts | 3 + packages/react-router/src/cloudflare/index.ts | 1 + packages/react-router/src/server/index.ts | 1 + .../react-router/src/server/tunnelRoute.ts | 38 +++++++++++++ .../src/vite/makeTunnelRoutePlugin.ts | 28 +++++++++ packages/react-router/src/vite/plugin.ts | 2 + .../test/server/tunnelRoute.test.ts | 57 +++++++++++++++++++ .../test/vite/makeTunnelRoutePlugin.test.ts | 20 +++++++ .../react-router/test/vite/plugin.test.ts | 11 +++- 15 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts create mode 100644 packages/react-router/src/server/tunnelRoute.ts create mode 100644 packages/react-router/src/vite/makeTunnelRoutePlugin.ts create mode 100644 packages/react-router/test/server/tunnelRoute.test.ts create mode 100644 packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts 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..9f2ece437fd1 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,8 @@ 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 + tunnel: process.env.E2E_TEST_TUNNEL ? true : `http://localhost:3031/`, // proxy server }); 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..0dc083191099 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,9 @@ "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:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test:playwright tunnel" }, "eslintConfig": { "extends": [ @@ -85,6 +87,11 @@ { "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)" } ] } 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..cf5b3322993f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts @@ -0,0 +1,40 @@ +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 TUNNEL_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('/', { 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('/'); + 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 ?? '') === '/performance', + ); + await page.request.get('/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.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/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index b94430728125..d691eac21ed2 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 { createSentryTunnelRoute } 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..99fb32c72ad3 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 { createSentryTunnelRoute } 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..1c281cc46adb --- /dev/null +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -0,0 +1,38 @@ +import { debug, getClient, getTunnelPaths, handleTunnelRequestIfMatched } 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__'; + +/** + * A resource route that serves the managed tunnel. `sentryReactRouter()` adds it to the server build, since React + * Router has no request hook to answer from. Not meant to be used directly. + */ +export function createSentryTunnelRoute(): Record { + return { + id: 'sentry-tunnel', + parentId: undefined, + index: undefined, + caseSensitive: undefined, + // React Router reads the path once, on its first request, and keeps it until the server restarts. + get path(): string { + if (!getClient()) { + DEBUG_BUILD && + debug.warn('The managed tunnel stays off until restart: a request came in before `Sentry.init`.'); + return DISABLED_TUNNEL_PATH; + } + + const [path, ...otherPaths] = getTunnelPaths(); + if (otherPaths.length) { + DEBUG_BUILD && debug.warn(`React Router can only serve one managed tunnel path, so only ${path} is served.`); + } + + return path ?? DISABLED_TUNNEL_PATH; + }, + 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..c8ed968d5e76 --- /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 route to the server build. It only serves requests when `Sentry.init` enables the + * managed tunnel. + */ +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 { createSentryTunnelRoute as __sentryCreateTunnelRoute } from '@sentry/react-router'; +routes['sentry-tunnel'] = __sentryCreateTunnelRoute(); +`, + 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/server/tunnelRoute.test.ts b/packages/react-router/test/server/tunnelRoute.test.ts new file mode 100644 index 000000000000..b540cf6c5c7e --- /dev/null +++ b/packages/react-router/test/server/tunnelRoute.test.ts @@ -0,0 +1,57 @@ +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createSentryTunnelRoute } 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 setClient(managedTunnel: object | undefined): void { + const options = { dsn: DSN, _managedTunnel: managedTunnel }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); +} + +describe('createSentryTunnelRoute', () => { + 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 = createSentryTunnelRoute() as TunnelRoute; + + expect(route.parentId).toBeUndefined(); + expect(route.path).toBe(getTunnelPath(DSN)); + }); + + it('serves a path that never matches when the tunnel is off or Sentry is not initialized yet', () => { + const route = createSentryTunnelRoute() as TunnelRoute; + const pathWithoutClient = route.path; + setClient(undefined); + + expect(pathWithoutClient).toBe('/__sentry-tunnel-disabled__'); + expect(route.path).toBe('/__sentry-tunnel-disabled__'); + }); + + it('forwards tunnel POSTs and rejects other methods', async () => { + setClient({}); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const route = createSentryTunnelRoute() as TunnelRoute; + 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); + }); +}); 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..758d0a6f56b6 --- /dev/null +++ b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts @@ -0,0 +1,20 @@ +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("routes['sentry-tunnel'] = __sentryCreateTunnelRoute();"); + 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, From 205324342ef535ebefe22b3419f9ba49748672ad Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:15:49 -0400 Subject: [PATCH 2/6] test(react-router): Define the tunnel flag in the Cloudflare build --- .../react-router-8-framework/vite.cloudflare.config.ts | 3 +++ 1 file changed, 3 insertions(+) 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(), From 3b3656d764528649c61cb9a85dc93410165d8c55 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 13:11:01 -0400 Subject: [PATCH 3/6] fix(react-router): Serve the managed tunnel under the app's `basename` --- .../react-router-8-framework/package.json | 9 ++++++++- .../react-router.config.ts | 2 ++ .../tests/tunnel.test.ts | 13 ++++++++----- packages/react-router/src/client/sdk.ts | 11 ++++++++++- packages/react-router/src/server/tunnelRoute.ts | 14 +++++++++++--- .../src/vite/makeTunnelRoutePlugin.ts | 4 ++-- packages/react-router/test/client/sdk.test.ts | 12 ++++++++++++ .../test/server/tunnelRoute.test.ts | 17 +++++++++++++++++ .../test/vite/makeTunnelRoutePlugin.test.ts | 4 +++- 9 files changed, 73 insertions(+), 13 deletions(-) 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 0dc083191099..3d9063bb6d0a 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 @@ -46,7 +46,9 @@ "test:ts": "pnpm typecheck", "test:playwright": "playwright test", "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", - "test:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test:playwright tunnel" + "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" }, "eslintConfig": { "extends": [ @@ -92,6 +94,11 @@ "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)" } ] } 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 index cf5b3322993f..03f309305e78 100644 --- 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 @@ -4,11 +4,12 @@ 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 TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; +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('/', { data: 'x' })).status()).toBe(401); + expect((await page.request.post(`${BASE_PATH}/`, { data: 'x' })).status()).toBe(401); const tunnelServerSpanPromise = waitForStreamedSpan( APP_NAME, @@ -18,7 +19,7 @@ test('answers the managed tunnel before the root middleware', async ({ page }) = response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), ); - await page.goto('/'); + await page.goto(`${BASE_PATH}/`); const tunnelResponse = await tunnelResponsePromise; expect(tunnelResponse.status()).toBe(200); @@ -28,9 +29,11 @@ test('answers the managed tunnel before the root middleware', async ({ page }) = // would arrive first. const anchorSpanPromise = waitForStreamedSpan( APP_NAME, - span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/performance', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/performance`, ); - await page.request.get('/performance'); + await page.request.get(`${BASE_PATH}/performance`); const winner = await Promise.race([ tunnelServerSpanPromise.then(() => 'tunnel' as const), diff --git a/packages/react-router/src/client/sdk.ts b/packages/react-router/src/client/sdk.ts index a9b13f2e7cd0..0452bee053ce 100644 --- a/packages/react-router/src/client/sdk.ts +++ b/packages/react-router/src/client/sdk.ts @@ -1,10 +1,15 @@ 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, 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 +30,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 = GLOBAL_OBJ_WITH_ROUTER_CONTEXT.__reactRouterContext?.basename?.replace(/\/+$/, ''); + 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/server/tunnelRoute.ts b/packages/react-router/src/server/tunnelRoute.ts index 1c281cc46adb..98b9f8460144 100644 --- a/packages/react-router/src/server/tunnelRoute.ts +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -1,4 +1,4 @@ -import { debug, getClient, getTunnelPaths, handleTunnelRequestIfMatched } from '@sentry/core'; +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. @@ -7,8 +7,13 @@ const DISABLED_TUNNEL_PATH = '/__sentry-tunnel-disabled__'; /** * A resource route that serves the managed tunnel. `sentryReactRouter()` adds it to the server build, since React * Router has no request hook to answer from. Not meant to be used directly. + * + * @param basename The app's `basename`, which React Router matches the route under but leaves in the request URL. */ -export function createSentryTunnelRoute(): Record { +export function createSentryTunnelRoute(basename?: string): Record { + // `init` runs from `--import` before the server build loads, so the client is usually there already. + setManagedTunnelBasePath(getClient(), basename); + return { id: 'sentry-tunnel', parentId: undefined, @@ -16,12 +21,15 @@ export function createSentryTunnelRoute(): Record { caseSensitive: undefined, // React Router reads the path once, on its first request, and keeps it until the server restarts. get path(): string { - if (!getClient()) { + 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(`React Router can only serve one managed tunnel path, so only ${path} is served.`); diff --git a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts index c8ed968d5e76..3b9f5a2da2ed 100644 --- a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts +++ b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts @@ -4,7 +4,7 @@ const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build'; /** * Adds the managed tunnel route to the server build. It only serves requests when `Sentry.init` enables the - * managed tunnel. + * managed tunnel. The server build exports the app's `basename`, which the route needs to match the request URL. */ export function makeTunnelRoutePlugin(): Plugin { return { @@ -19,7 +19,7 @@ export function makeTunnelRoutePlugin(): Plugin { return { code: `${code} import { createSentryTunnelRoute as __sentryCreateTunnelRoute } from '@sentry/react-router'; -routes['sentry-tunnel'] = __sentryCreateTunnelRoute(); +routes['sentry-tunnel'] = __sentryCreateTunnelRoute(typeof basename === 'string' ? basename : undefined); `, map: null, }; 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 index b540cf6c5c7e..d6a7e1fc5b84 100644 --- a/packages/react-router/test/server/tunnelRoute.test.ts +++ b/packages/react-router/test/server/tunnelRoute.test.ts @@ -54,4 +54,21 @@ describe('createSentryTunnelRoute', () => { 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 = createSentryTunnelRoute('/app/') as TunnelRoute; + 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 index 758d0a6f56b6..52e264a9fdea 100644 --- a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts +++ b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts @@ -9,7 +9,9 @@ describe('makeTunnelRoutePlugin', () => { 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("routes['sentry-tunnel'] = __sentryCreateTunnelRoute();"); + expect(result?.code).toContain( + "routes['sentry-tunnel'] = __sentryCreateTunnelRoute(typeof basename === 'string' ? basename : undefined);", + ); expect(result?.code).toContain("from '@sentry/react-router'"); }); From 0dea44869d0c7af24ee337c8bbf2b5c2eebbb091 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 14:52:58 -0400 Subject: [PATCH 4/6] test(react-router): Check the managed tunnel under `react-router dev` too --- .../react-router-8-framework/package.json | 14 +++++++++++++- .../react-router-8-framework/playwright.config.mjs | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) 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 3d9063bb6d0a..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 @@ -48,7 +48,9 @@ "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-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": [ @@ -99,6 +101,16 @@ "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. From 21ed36a31195da6cf5412f44a0ae71cd3c8391f9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 17:01:01 -0400 Subject: [PATCH 5/6] fix(react-router): Normalize the tunnel base path with the core util --- packages/react-router/src/client/sdk.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/client/sdk.ts b/packages/react-router/src/client/sdk.ts index 0452bee053ce..5c140ca27f36 100644 --- a/packages/react-router/src/client/sdk.ts +++ b/packages/react-router/src/client/sdk.ts @@ -1,7 +1,14 @@ import type { BrowserOptions } from '@sentry/browser'; import { init as browserInit } from '@sentry/browser'; import type { Client } from '@sentry/core'; -import { applySdkMetadata, applyTunnelDefaults, consoleSandbox, GLOBAL_OBJ, setTag } from '@sentry/core'; +import { + applySdkMetadata, + applyTunnelDefaults, + consoleSandbox, + GLOBAL_OBJ, + normalizeTunnelBasePath, + setTag, +} from '@sentry/core'; const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; @@ -31,7 +38,7 @@ 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 = GLOBAL_OBJ_WITH_ROUTER_CONTEXT.__reactRouterContext?.basename?.replace(/\/+$/, ''); + 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']); From 59d092b064a80ca43dc15c87a2d03ab1589505b1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 22:25:19 -0400 Subject: [PATCH 6/6] fix(react-router): Serve every managed tunnel path, one route each --- .../react-router-8-framework/instrument.mjs | 5 +- packages/react-router/src/cloudflare/index.ts | 2 +- packages/react-router/src/server/index.ts | 2 +- .../react-router/src/server/tunnelRoute.ts | 68 ++++++++++++------- .../src/vite/makeTunnelRoutePlugin.ts | 6 +- .../test/server/tunnelRoute.test.ts | 19 ++++-- .../test/vite/makeTunnelRoutePlugin.test.ts | 2 +- 7 files changed, 67 insertions(+), 37 deletions(-) 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 9f2ece437fd1..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 @@ -4,5 +4,8 @@ Sentry.init({ 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: process.env.E2E_TEST_TUNNEL ? true : `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/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index d691eac21ed2..23dc670a803e 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -4,7 +4,7 @@ import { isPrerenderRequest } from '../server/serverBuild'; export * from '../client'; export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest'; -export { createSentryTunnelRoute } from '../server/tunnelRoute'; +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 99fb32c72ad3..aaa6bfcb4e14 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -8,7 +8,7 @@ export { wrapSentryHandleRequest } from './wrapSentryHandleRequest'; export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest'; export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError'; export { getMetaTagTransformer } from './getMetaTagTransformer'; -export { createSentryTunnelRoute } from './tunnelRoute'; +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 index 98b9f8460144..2d1ce3d3744b 100644 --- a/packages/react-router/src/server/tunnelRoute.ts +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -5,37 +5,59 @@ import { DEBUG_BUILD } from '../common/debug-build'; const DISABLED_TUNNEL_PATH = '/__sentry-tunnel-disabled__'; /** - * A resource route that serves the managed tunnel. `sentryReactRouter()` adds it to the server build, since React - * Router has no request hook to answer from. Not meant to be used directly. + * 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 route under but leaves in the request URL. + * @param basename The app's `basename`, which React Router matches the routes under but leaves in the request URL. */ -export function createSentryTunnelRoute(basename?: string): Record { - // `init` runs from `--import` before the server build loads, so the client is usually there already. - setManagedTunnelBasePath(getClient(), basename); +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: 'sentry-tunnel', + id, parentId: undefined, index: undefined, caseSensitive: undefined, - // React Router reads the path once, on its first request, and keeps it until the server restarts. get path(): 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(`React Router can only serve one managed tunnel path, so only ${path} is served.`); - } - - return path ?? DISABLED_TUNNEL_PATH; + return getPath(); }, module: { action: ({ request }: { request: Request }) => diff --git a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts index 3b9f5a2da2ed..4c03163913d9 100644 --- a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts +++ b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts @@ -3,7 +3,7 @@ import type { Plugin } from 'vite'; const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build'; /** - * Adds the managed tunnel route to the server build. It only serves requests when `Sentry.init` enables the + * 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 { @@ -18,8 +18,8 @@ export function makeTunnelRoutePlugin(): Plugin { return { code: `${code} -import { createSentryTunnelRoute as __sentryCreateTunnelRoute } from '@sentry/react-router'; -routes['sentry-tunnel'] = __sentryCreateTunnelRoute(typeof basename === 'string' ? basename : undefined); +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/test/server/tunnelRoute.test.ts b/packages/react-router/test/server/tunnelRoute.test.ts index d6a7e1fc5b84..cc27a2b3e385 100644 --- a/packages/react-router/test/server/tunnelRoute.test.ts +++ b/packages/react-router/test/server/tunnelRoute.test.ts @@ -1,7 +1,7 @@ import type { Client } from '@sentry/core'; import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createSentryTunnelRoute } from '../../src/server/tunnelRoute'; +import { createSentryTunnelRoutes } from '../../src/server/tunnelRoute'; const DSN = 'https://public@o1.ingest.sentry.io/1337'; @@ -11,12 +11,16 @@ type TunnelRoute = { 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('createSentryTunnelRoute', () => { +describe('createSentryTunnelRoutes', () => { afterEach(() => { getCurrentScope().setClient(undefined); vi.unstubAllGlobals(); @@ -24,19 +28,20 @@ describe('createSentryTunnelRoute', () => { it('is a top-level route at the tunnel path when `init` enabled the managed tunnel', () => { setClient({}); - const route = createSentryTunnelRoute() as TunnelRoute; + const route = firstRoute(); expect(route.parentId).toBeUndefined(); expect(route.path).toBe(getTunnelPath(DSN)); }); - it('serves a path that never matches when the tunnel is off or Sentry is not initialized yet', () => { - const route = createSentryTunnelRoute() as TunnelRoute; + 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 () => { @@ -45,7 +50,7 @@ describe('createSentryTunnelRoute', () => { 'fetch', vi.fn(() => Promise.resolve(new Response('forwarded'))), ); - const route = createSentryTunnelRoute() as TunnelRoute; + const route = firstRoute(); const request = new Request(`http://localhost${getTunnelPath(DSN)}`, { method: 'POST', body: serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as BodyInit, @@ -61,7 +66,7 @@ describe('createSentryTunnelRoute', () => { 'fetch', vi.fn(() => Promise.resolve(new Response('forwarded'))), ); - const route = createSentryTunnelRoute('/app/') as TunnelRoute; + const route = firstRoute('/app/'); const request = new Request(`http://localhost/app${getTunnelPath(DSN)}`, { method: 'POST', body: serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as BodyInit, diff --git a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts index 52e264a9fdea..4982ed40acc1 100644 --- a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts +++ b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts @@ -10,7 +10,7 @@ describe('makeTunnelRoutePlugin', () => { const result = transform('export const routes = {};', '\0virtual:react-router/server-build', { ssr: true }); expect(result?.code).toContain( - "routes['sentry-tunnel'] = __sentryCreateTunnelRoute(typeof basename === 'string' ? basename : undefined);", + "Object.assign(routes, __sentryCreateTunnelRoutes(typeof basename === 'string' ? basename : undefined));", ); expect(result?.code).toContain("from '@sentry/react-router'"); });