diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 6d431226dbfc..f689aba2d7e2 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -9,8 +9,17 @@ "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install && pnpm build", + "test:build:tunnel-generated": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", + "test:build:tunnel-object": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:assert:proxy": "pnpm test", + "test:assert": "pnpm test:assert:proxy", + "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", + "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", + "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test", + "test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test" }, "dependencies": { "@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz", @@ -35,5 +44,29 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "label": "tunnel-generated", + "build-command": "pnpm test:build:tunnel-generated", + "assert-command": "pnpm test:assert:tunnel-generated" + }, + { + "label": "tunnel-static", + "build-command": "pnpm test:build:tunnel-static", + "assert-command": "pnpm test:assert:tunnel-static" + }, + { + "label": "tunnel-custom", + "build-command": "pnpm test:build:tunnel-custom", + "assert-command": "pnpm test:assert:tunnel-custom" + }, + { + "label": "tunnel-object", + "build-command": "pnpm test:build:tunnel-object", + "assert-command": "pnpm test:assert:tunnel-object" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts new file mode 100644 index 000000000000..6e7d31c7a4e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts @@ -0,0 +1,2 @@ +declare const __APP_DSN__: string; +declare const __APP_TUNNEL__: string | undefined; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx index b1c6f7727a26..9a39b6f35c42 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx @@ -11,13 +11,13 @@ export const getRouter = () => { if (!router.isServer) { Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: __APP_DSN__, integrations: [Sentry.tanstackRouterBrowserTracingIntegration(router)], // We recommend adjusting this value in production, or using tracesSampler // for finer control tracesSampleRate: 1.0, release: 'e2e-test', - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __APP_TUNNEL__, }); } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts new file mode 100644 index 000000000000..1409123f0402 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/tanstackstart-react'; +import { createFileRoute } from '@tanstack/react-router'; + +const USE_CUSTOM_TUNNEL_ROUTE = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +const DEFAULT_DSN = 'https://public@dsn.ingest.sentry.io/1337'; +const TUNNEL_DSN = 'http://public@localhost:3031/1337'; + +// Example of a manually defined tunnel endpoint without relying on the +// managed route injected by `sentryTanstackStart({ tunnelRoute: ... })`. +// If you use a custom route like this one, set `tunnel: '/custom-monitor'` in the client SDK's +// `Sentry.init()` call so browser events are sent to the same endpoint. +export const Route = createFileRoute('/custom-monitor')({ + server: Sentry.createSentryTunnelRoute({ + allowedDsns: [USE_CUSTOM_TUNNEL_ROUTE ? TUNNEL_DSN : DEFAULT_DSN], + }), +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts index 04d93e550824..a49b77a293b1 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts @@ -1,6 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + test('Sends client-side error to Sentry with auto-instrumentation', async ({ page }) => { const errorEventPromise = waitForError('tanstackstart-react', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts index dffab8ea2aa3..ab31ce5e022a 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts @@ -1,6 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + test('Sends spans for multiple middlewares and verifies they are siblings under the same parent span', async ({ page, }) => { diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts index 5186514d277a..f5e70a676432 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts @@ -1,6 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + test('Sends a server function transaction with auto-instrumentation', async ({ page }) => { const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { return ( diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts new file mode 100644 index 000000000000..27f200b8ef62 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const tunnelRouteMode = + process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off'); +const expectedTunnelPathMatcher = + tunnelRouteMode === 'static' + ? '/monitor' + : tunnelRouteMode === 'custom' + ? '/custom-monitor' + : tunnelRouteMode === 'object' + ? '/object-monitor' + : /^\/[a-z0-9]{8}$/; + +test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); + +test('Sends client-side errors through the configured tunnel route', async ({ page }) => { + const errorEventPromise = waitForError('tanstackstart-react', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; + }); + + await page.goto('/'); + const pageOrigin = new URL(page.url()).origin; + + await expect(page.locator('button').filter({ hasText: 'Break the client' })).toBeVisible(); + + const managedTunnelResponsePromise = page.waitForResponse(response => { + const responseUrl = new URL(response.url()); + + return ( + responseUrl.origin === pageOrigin && + response.request().method() === 'POST' && + (typeof expectedTunnelPathMatcher === 'string' + ? responseUrl.pathname === expectedTunnelPathMatcher + : expectedTunnelPathMatcher.test(responseUrl.pathname)) + ); + }); + + await page.locator('button').filter({ hasText: 'Break the client' }).click(); + + const managedTunnelResponse = await managedTunnelResponsePromise; + const managedTunnelUrl = new URL(managedTunnelResponse.url()); + const errorEvent = await errorEventPromise; + + expect(managedTunnelResponse.status()).toBe(200); + expect(managedTunnelUrl.origin).toBe(pageOrigin); + + if (typeof expectedTunnelPathMatcher === 'string') { + expect(managedTunnelUrl.pathname).toBe(expectedTunnelPathMatcher); + } else { + expect(managedTunnelUrl.pathname).toMatch(expectedTunnelPathMatcher); + expect(managedTunnelUrl.pathname).not.toBe('/monitor'); + } + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Sentry Client Test Error'); + expect(errorEvent.transaction).toBe('/'); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index a2b39609717d..2385d8aa5e93 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -5,10 +5,44 @@ import viteReact from '@vitejs/plugin-react-swc'; import { nitro } from 'nitro/vite'; import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; +const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off'; +const useManagedTunnelRoute = tunnelRouteMode !== 'off'; +const useCustomTunnelRoute = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +const appDsn = + useManagedTunnelRoute || useCustomTunnelRoute + ? 'http://public@localhost:3031/1337' + : 'https://public@dsn.ingest.sentry.io/1337'; + +const appTunnel = useManagedTunnelRoute + ? undefined + : useCustomTunnelRoute + ? '/custom-monitor' + : 'http://localhost:3031/'; + +function resolveTunnelRouteOption() { + switch (tunnelRouteMode) { + case 'dynamic': + return true; + case 'static': + return '/monitor'; + case 'object': + return { path: '/object-monitor', allowedDsns: [appDsn] }; + default: + return undefined; + } +} + +const tunnelRoute = resolveTunnelRouteOption(); + export default defineConfig({ server: { port: 3000, }, + define: { + __APP_DSN__: JSON.stringify(appDsn), + __APP_TUNNEL__: appTunnel === undefined ? 'undefined' : JSON.stringify(appTunnel), + }, plugins: [ tsConfigPaths(), tanstackStart(), @@ -20,6 +54,7 @@ export default defineConfig({ project: process.env.E2E_TEST_SENTRY_PROJECT, authToken: process.env.E2E_TEST_AUTH_TOKEN, debug: true, + tunnelRoute, }), ], }); diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index 3e762580830c..7607c32faaa3 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -2,6 +2,7 @@ // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ import type { TanStackMiddlewareBase } from '../common/types'; +import type { CreateSentryTunnelRouteOptions } from '../server/tunnelRoute'; export * from '@sentry/react'; @@ -26,3 +27,19 @@ export const sentryGlobalRequestMiddleware: TanStackMiddlewareBase = { '~types': * The actual implementation is server-only, but this stub is needed to prevent rendering errors. */ export const sentryGlobalFunctionMiddleware: TanStackMiddlewareBase = { '~types': undefined, options: {} }; + +/** + * No-op stub for client-side builds. + * The actual implementation is server-only, but this stub is needed to prevent rendering errors. + */ +export function createSentryTunnelRoute(_options: CreateSentryTunnelRouteOptions): { + handlers: { + POST: () => Promise; + }; +} { + return { + handlers: { + POST: async () => new Response(null, { status: 500 }), + }, + }; +} diff --git a/packages/tanstackstart-react/src/client/sdk.ts b/packages/tanstackstart-react/src/client/sdk.ts index b0ee3b53053f..0998c027f112 100644 --- a/packages/tanstackstart-react/src/client/sdk.ts +++ b/packages/tanstackstart-react/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { Client } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react'; import { getDefaultIntegrations as getReactDefaultIntegrations, init as initReactSDK } from '@sentry/react'; +import { applyTunnelRouteOption } from './tunnelRoute'; /** * Initializes the TanStack Start React SDK @@ -14,6 +15,7 @@ export function init(options: ReactBrowserOptions): Client | undefined { ...options, }; + applyTunnelRouteOption(sentryOptions); applySdkMetadata(sentryOptions, 'tanstackstart-react', ['tanstackstart-react', 'react']); return initReactSDK(sentryOptions); diff --git a/packages/tanstackstart-react/src/client/tunnelRoute.ts b/packages/tanstackstart-react/src/client/tunnelRoute.ts new file mode 100644 index 000000000000..b22612c123d0 --- /dev/null +++ b/packages/tanstackstart-react/src/client/tunnelRoute.ts @@ -0,0 +1,35 @@ +import { consoleSandbox } from '@sentry/core'; +import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react'; + +declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: string | undefined; + +let hasWarnedAboutManagedTunnelRouteOverride = false; + +/** + * Applies the managed tunnel route from `sentryTanstackStart({ tunnelRoute: ... })` unless the user already + * configured an explicit runtime `tunnel` option in `Sentry.init()`. + */ +export function applyTunnelRouteOption(options: ReactBrowserOptions): void { + const managedTunnelRoute = + typeof __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ : undefined; + + if (!managedTunnelRoute) { + return; + } + + if (options.tunnel) { + if (!hasWarnedAboutManagedTunnelRouteOverride) { + hasWarnedAboutManagedTunnelRouteOverride = true; + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/tanstackstart-react] `Sentry.init({ tunnel: ... })` overrides the managed `sentryTanstackStart({ tunnelRoute: ... })` route. Remove the runtime `tunnel` option if you want the managed tunnel route to be used.', + ); + }); + } + + return; + } + + options.tunnel = managedTunnelRoute; +} diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 76e4ced73c92..79edcef0bbfa 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -42,3 +42,4 @@ export declare const wrapMiddlewaresWithSentry: typeof serverSdk.wrapMiddlewares export declare const tanstackRouterBrowserTracingIntegration: typeof clientSdk.tanstackRouterBrowserTracingIntegration; export declare const sentryGlobalRequestMiddleware: typeof serverSdk.sentryGlobalRequestMiddleware; export declare const sentryGlobalFunctionMiddleware: typeof serverSdk.sentryGlobalFunctionMiddleware; +export declare const createSentryTunnelRoute: typeof serverSdk.createSentryTunnelRoute; diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index 4fe781b6d778..0ae0968e574b 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -9,6 +9,7 @@ export { init } from './sdk'; export { wrapFetchWithSentry } from './wrapFetchWithSentry'; export { wrapMiddlewaresWithSentry } from './middleware'; export { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } from './globalMiddleware'; +export { createSentryTunnelRoute } from './tunnelRoute'; /** * A no-op stub of the browser tracing integration for the server. Router setup code is shared between client and server, diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts new file mode 100644 index 000000000000..815a8d635c9f --- /dev/null +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -0,0 +1,60 @@ +import { dsnToString, getClient, handleTunnelRequest } from '@sentry/core'; + +export interface CreateSentryTunnelRouteOptions { + allowedDsns?: string[]; +} + +type SentryTunnelRouteHandlerContext = { + request: Request; +}; + +type SentryTunnelRoute = { + handlers: { + POST: (context: SentryTunnelRouteHandlerContext) => Promise; + }; +}; + +/** + * Creates a TanStack Start server route configuration for tunneling Sentry envelopes. + * + * @example + * ```ts + * import { createFileRoute } from '@tanstack/react-router'; + * import * as Sentry from '@sentry/tanstackstart-react'; + * + * export const Route = createFileRoute('/monitoring')({ + * server: Sentry.createSentryTunnelRoute({ + * allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + * }), + * }); + * ``` + */ +export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions): SentryTunnelRoute { + return { + handlers: { + POST: async ({ request }) => { + const allowedDsnsFromOptions = options.allowedDsns?.length ? options.allowedDsns : undefined; + + const allowedDsns = + allowedDsnsFromOptions ?? + (() => { + const client = getClient(); + const dsn = client?.getDsn(); + return dsn ? [dsnToString(dsn)] : undefined; + })(); + + if (!allowedDsns) { + return new Response( + 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.', + { status: 500 }, + ); + } + + return handleTunnelRequest({ + request, + allowedDsns, + }); + }, + }, + }; +} diff --git a/packages/tanstackstart-react/src/vite/index.ts b/packages/tanstackstart-react/src/vite/index.ts index 85143344028d..b7f65e26f5d2 100644 --- a/packages/tanstackstart-react/src/vite/index.ts +++ b/packages/tanstackstart-react/src/vite/index.ts @@ -1,2 +1,3 @@ export { sentryTanstackStart } from './sentryTanstackStart'; export type { SentryTanstackStartOptions } from './sentryTanstackStart'; +export type { TunnelRouteOptions } from './tunnelRoute'; diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index fd5d5b2f0d05..75e9963c0387 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -2,6 +2,8 @@ import type { BuildTimeOptionsBase } from '@sentry/core'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; +import type { TunnelRouteOptions } from './tunnelRoute'; +import { makeTunnelRoutePlugin } from './tunnelRoute'; /** * Build-time options for the Sentry TanStack Start SDK. @@ -19,6 +21,23 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @default true */ autoInstrumentMiddleware?: boolean; + + /** + * Configures a framework-managed same-origin tunnel route for Sentry envelopes. + * + * This creates a TanStack Start server route backed by `createSentryTunnelRoute()` and applies the resulting path + * as the default `tunnel` option on the client. + * + * You can pass: + * - `true` to generate an opaque route path per dev session or production build. + * - `'/custom-path'` to use a fixed static route path. + * - `{ allowedDsns, path }` for full control. If `allowedDsns` is omitted or empty, the tunnel route derives the DSN + * from the active server Sentry client at runtime. + * + * If you also pass `tunnel` to `Sentry.init()`, that explicit runtime option wins and a warning is emitted because + * the managed tunnel route is being bypassed. + */ + tunnelRoute?: TunnelRouteOptions; } /** @@ -46,13 +65,19 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - // only add plugins in production builds + const tunnelRoutePlugin = options.tunnelRoute ? makeTunnelRoutePlugin(options.tunnelRoute, options.debug) : undefined; + + // only add build-time plugins in production builds if (process.env.NODE_ENV === 'development') { - return []; + return tunnelRoutePlugin ? [tunnelRoutePlugin] : []; } const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)]; + if (tunnelRoutePlugin) { + plugins.push(tunnelRoutePlugin); + } + // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug })); diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 288c725dbc93..296e8582cde8 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -65,7 +65,9 @@ export function makeAddSentryVitePlugin(options: BuildTimeOptionsBase): Plugin[] assets: sourcemaps?.assets, disable: sourcemaps?.disable, ignore: sourcemaps?.ignore, - rewriteSources: sourcemaps?.rewriteSources, + // BuildTimeOptionsBase types can lag behind bundler plugin options in some local setups. + // Keep runtime support while staying resilient to type version skew. + rewriteSources: (sourcemaps as unknown as { rewriteSources?: unknown } | undefined)?.rewriteSources as never, filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, }, telemetry: telemetry ?? true, diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts new file mode 100644 index 000000000000..6a9400b32c95 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -0,0 +1,202 @@ +import type { Plugin } from 'vite'; + +export type TunnelRouteOptions = + | true + | string + | { + /** + * A list of DSNs that are allowed to use the managed tunnel route. + * + * If omitted or empty, the tunnel route will derive the allowed DSN from the active server Sentry SDK at runtime. + */ + allowedDsns?: string[]; + + /** + * Controls the public route path used by the managed tunnel route. + * + * If omitted, an opaque path is generated once per dev session or production build. + */ + path?: string; + }; + +const MANAGED_TUNNEL_ROUTE_IMPORT = 'SentryManagedTunnelRouteImport'; +const MANAGED_TUNNEL_ROUTE_NAME = 'SentryManagedTunnelRoute'; +const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = '__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__'; + +const VIRTUAL_TUNNEL_ROUTE_ID = 'virtual:sentry-tanstackstart-react/tunnel-route'; +const RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID = `\0${VIRTUAL_TUNNEL_ROUTE_ID}`; + +function generateRandomTunnelRoute(): string { + const randomPath = Array.from({ length: 8 }, () => Math.floor(Math.random() * 36).toString(36)).join(''); + + return `/${randomPath}`; +} + +export function resolveTunnelRoute(tunnel: true | string): string { + if (typeof tunnel === 'string') { + return tunnel; + } + + if (process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]) { + return process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]; + } + + const resolvedTunnelRoute = generateRandomTunnelRoute(); + process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY] = resolvedTunnelRoute; + return resolvedTunnelRoute; +} + +type NormalizedTunnelRouteOptions = { + resolvedPath: string; + allowedDsns: string[] | undefined; +}; + +function validateStaticPath(path: string): void { + if (!path.startsWith('/') || path.includes('?') || path.includes('#')) { + throw new Error( + '[@sentry/tanstackstart-react] `tunnelRoute` static paths must start with `/` and must not contain query or hash segments.', + ); + } +} + +function normalizeTunnelRouteOptions(options: TunnelRouteOptions): NormalizedTunnelRouteOptions { + if (options === true) { + return { resolvedPath: resolveTunnelRoute(true), allowedDsns: undefined }; + } + + if (typeof options === 'string') { + validateStaticPath(options); + return { + resolvedPath: resolveTunnelRoute(options), + allowedDsns: undefined, + }; + } + + const allowedDsns = options.allowedDsns && options.allowedDsns.length > 0 ? options.allowedDsns : undefined; + const path = options.path; + + if (path) { + validateStaticPath(path); + } + + return { resolvedPath: resolveTunnelRoute(path || true), allowedDsns }; +} + +// `routeTree.gen.ts` quote style follows `tsr.config.json#quoteStyle` (`single` | `double`), +// so we check both forms for each route-identifying key. +const ROUTE_CONFLICT_KEYS = ['fullPath', 'path', 'id'] as const; + +function hasRouteConflict(source: string, resolvedTunnelRoute: string): boolean { + const literals = [`'${resolvedTunnelRoute}'`, `"${resolvedTunnelRoute}"`]; + return ROUTE_CONFLICT_KEYS.some(key => literals.some(literal => source.includes(`${key}: ${literal}`))); +} + +function injectAfterLastImport(source: string, statement: string): string { + const importMatches = [...source.matchAll(/^import .+$/gm)]; + const lastImport = importMatches.at(-1); + + if (lastImport?.index === undefined) { + throw new Error( + '[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because `routeTree.gen.ts` imports could not be located.', + ); + } + + const insertIndex = lastImport.index + lastImport[0].length; + return `${source.slice(0, insertIndex)}\n${statement}${source.slice(insertIndex)}`; +} + +export function injectManagedTunnelRoute(source: string, resolvedTunnelRoute: string): string { + if (source.includes(VIRTUAL_TUNNEL_ROUTE_ID)) { + return source; + } + + if (hasRouteConflict(source, resolvedTunnelRoute)) { + throw new Error( + `[@sentry/tanstackstart-react] Cannot register managed tunnel route "${resolvedTunnelRoute}" because an existing TanStack Start route already uses that path.`, + ); + } + + const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); + + let transformedSource = injectAfterLastImport( + source, + `import { Route as ${MANAGED_TUNNEL_ROUTE_IMPORT} } from '${VIRTUAL_TUNNEL_ROUTE_ID}'`, + ); + + const rootRouteChildrenMatch = transformedSource.match( + /const rootRouteChildren(?:\s*:\s*RootRouteChildren)?\s*=\s*\{/, + ); + + if (rootRouteChildrenMatch?.index === undefined) { + throw new Error( + '[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because the generated TanStack route tree did not contain `rootRouteChildren`.', + ); + } + + const injectedRootRouteChildrenDeclaration = `const ${MANAGED_TUNNEL_ROUTE_NAME} = ${MANAGED_TUNNEL_ROUTE_IMPORT}.update({ + id: ${serializedTunnelRoute}, + path: ${serializedTunnelRoute}, + getParentRoute: () => rootRouteImport, +} as any) + +${rootRouteChildrenMatch[0]} + ${MANAGED_TUNNEL_ROUTE_NAME}: ${MANAGED_TUNNEL_ROUTE_NAME}, +`; + + transformedSource = `${transformedSource.slice(0, rootRouteChildrenMatch.index)}${injectedRootRouteChildrenDeclaration}${transformedSource.slice(rootRouteChildrenMatch.index + rootRouteChildrenMatch[0].length)}`; + + return transformedSource; +} + +export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boolean): Plugin { + const normalized = normalizeTunnelRouteOptions(options); + const resolvedTunnelRoute = normalized.resolvedPath; + const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); + const serializedAllowedDsns = normalized.allowedDsns ? JSON.stringify(normalized.allowedDsns) : undefined; + + if (debug) { + // eslint-disable-next-line no-console + console.log(`[@sentry/tanstackstart-react] Registered tunnel route: ${resolvedTunnelRoute}`); + } + + return { + name: 'sentry-tanstackstart-tunnel-route', + enforce: 'pre', + config() { + return { + define: { + __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: serializedTunnelRoute, + }, + }; + }, + resolveId(source) { + return source === VIRTUAL_TUNNEL_ROUTE_ID ? RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID : null; + }, + load(id) { + if (id !== RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID) { + return null; + } + + return `import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute(${serializedTunnelRoute})({ + server: { + handlers: { + async POST({ request }) { + const Sentry = await import('@sentry/tanstackstart-react'); + return Sentry.createSentryTunnelRoute(${serializedAllowedDsns ? `{ allowedDsns: ${serializedAllowedDsns} }` : `{}`}).handlers.POST({ request }); + }, + }, + }, +}); +`; + }, + transform(source, id) { + if (!id.endsWith('/routeTree.gen.ts') && !id.endsWith('\\routeTree.gen.ts')) { + return null; + } + + return injectManagedTunnelRoute(source, resolvedTunnelRoute); + }, + }; +} diff --git a/packages/tanstackstart-react/test/client/sdk.test.ts b/packages/tanstackstart-react/test/client/sdk.test.ts index 4cba4a199ef5..400bbe877dc1 100644 --- a/packages/tanstackstart-react/test/client/sdk.test.ts +++ b/packages/tanstackstart-react/test/client/sdk.test.ts @@ -9,6 +9,7 @@ describe('TanStack Start React Client SDK', () => { describe('init', () => { beforeEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); }); it('Adds TanStack Start React client metadata to the SDK options', () => { @@ -41,5 +42,15 @@ describe('TanStack Start React Client SDK', () => { it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); + + it('applies the managed tunnel route when no runtime tunnel is provided', () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + + expect(reactInit).toHaveBeenLastCalledWith(expect.objectContaining({ tunnel: '/managed-tunnel' })); + }); }); }); diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts new file mode 100644 index 000000000000..90b91481305b --- /dev/null +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -0,0 +1,59 @@ +import type { BrowserOptions } from '@sentry/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('applyTunnelRouteOption()', () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('applies the managed tunnel route when no runtime tunnel is set', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe('/managed-tunnel'); + }); + + it('does not override an explicit runtime tunnel and warns instead', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tunnel: '/runtime-tunnel', + }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe('/runtime-tunnel'); + expect(warnSpy).toHaveBeenCalledWith( + '[@sentry/tanstackstart-react] `Sentry.init({ tunnel: ... })` overrides the managed `sentryTanstackStart({ tunnelRoute: ... })` route. Remove the runtime `tunnel` option if you want the managed tunnel route to be used.', + ); + }); + + it('does nothing when no managed tunnel route was injected', async () => { + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBeUndefined(); + }); +}); diff --git a/packages/tanstackstart-react/test/server/tunnelRoute.test.ts b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts new file mode 100644 index 000000000000..59e9ae130e0e --- /dev/null +++ b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const handleTunnelRequestSpy = vi.fn(); +const getClientSpy = vi.fn(); + +vi.mock('@sentry/core', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + handleTunnelRequest: (...args: unknown[]) => handleTunnelRequestSpy(...args), + getClient: (...args: unknown[]) => getClientSpy(...args), + }; +}); + +const { createSentryTunnelRoute } = await import('../../src/server/tunnelRoute'); + +describe('createSentryTunnelRoute', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns a server route config with only a POST handler', () => { + const route = createSentryTunnelRoute({ + allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + }); + + expect(Object.keys(route.handlers)).toEqual(['POST']); + expect(route.handlers.POST).toBeTypeOf('function'); + }); + + it('forwards the request and allowed DSNs to handleTunnelRequest', async () => { + const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' }); + const allowedDsns = ['https://public@o0.ingest.sentry.io/0']; + const response = new Response('ok', { status: 200 }); + + handleTunnelRequestSpy.mockResolvedValueOnce(response); + + const route = createSentryTunnelRoute({ allowedDsns }); + const result = await route.handlers.POST({ request }); + + expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1); + const [options] = handleTunnelRequestSpy.mock.calls[0]!; + expect(options).toEqual({ + request, + allowedDsns, + }); + expect(options.allowedDsns).toBe(allowedDsns); + expect(result).toBe(response); + }); + + it('derives the allowed DSN from the active server Sentry client when allowedDsns is omitted', async () => { + const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' }); + const response = new Response('ok', { status: 200 }); + + getClientSpy.mockReturnValueOnce({ + getDsn: () => ({ + protocol: 'http', + publicKey: 'public', + pass: '', + host: 'localhost', + port: '3031', + path: '', + projectId: '1337', + }), + }); + handleTunnelRequestSpy.mockResolvedValueOnce(response); + + const route = createSentryTunnelRoute({}); + const result = await route.handlers.POST({ request }); + + expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1); + const [options] = handleTunnelRequestSpy.mock.calls[0]!; + expect(options).toEqual({ + request, + allowedDsns: ['http://public@localhost:3031/1337'], + }); + expect(result).toBe(response); + }); + + it('returns 500 when allowedDsns is omitted and no active server Sentry client DSN exists', async () => { + const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' }); + + getClientSpy.mockReturnValueOnce(undefined); + + const route = createSentryTunnelRoute({}); + const result = await route.handlers.POST({ request }); + + expect(handleTunnelRequestSpy).not.toHaveBeenCalled(); + expect(result.status).toBe(500); + await expect(result.text()).resolves.toContain('Tunnel route requires Sentry server SDK initialized with a DSN'); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index ef18da74d03a..516edadd0bb0 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -1,7 +1,8 @@ import type { Plugin } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeAutoInstrumentMiddlewarePlugin } from '../../src/vite/autoInstrumentMiddleware'; -import { sentryTanstackStart } from '../../src/vite/sentryTanstackStart'; +import { sentryTanstackStart, type SentryTanstackStartOptions } from '../../src/vite/sentryTanstackStart'; +import { makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; const mockSourceMapsConfigPlugin: Plugin = { name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', @@ -28,6 +29,12 @@ const mockMiddlewarePlugin: Plugin = { transform: vi.fn(), }; +const mockTunnelRoutePlugin: Plugin = { + name: 'sentry-tanstackstart-tunnel-route', + enforce: 'pre', + transform: vi.fn(), +}; + vi.mock('../../src/vite/sourceMaps', () => ({ makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsConfigPlugin, mockSentryVitePlugin]), makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), @@ -37,6 +44,10 @@ vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ makeAutoInstrumentMiddlewarePlugin: vi.fn(() => mockMiddlewarePlugin), })); +vi.mock('../../src/vite/tunnelRoute', () => ({ + makeTunnelRoutePlugin: vi.fn(() => mockTunnelRoutePlugin), +})); + describe('sentryTanstackStart()', () => { beforeEach(() => { vi.clearAllMocks(); @@ -54,7 +65,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); }); - it('returns no plugins in development mode', () => { + it('returns no plugins in development mode when tunnelRoute is not configured', () => { process.env.NODE_ENV = 'development'; const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); @@ -62,6 +73,17 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([]); }); + it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { + process.env.NODE_ENV = 'development'; + + const plugins = sentryTanstackStart({ + autoInstrumentMiddleware: false, + tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, + }); + + expect(plugins).toEqual([mockTunnelRoutePlugin]); + }); + it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false, @@ -127,4 +149,37 @@ describe('sentryTanstackStart()', () => { expect(makeAutoInstrumentMiddlewarePlugin).toHaveBeenCalledWith({ enabled: true, debug: undefined }); }); }); + + describe('managed tunnel route', () => { + it('includes the managed tunnel route plugin in production when configured', () => { + const plugins = sentryTanstackStart({ + tunnelRoute: { + allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + path: '/monitor', + }, + sourcemaps: { disable: true }, + }); + + expect(plugins).toEqual([ + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockTunnelRoutePlugin, + mockMiddlewarePlugin, + ]); + }); + + it('passes tunnelRoute options through to the tunnel route plugin', () => { + const options: SentryTanstackStartOptions = { + tunnelRoute: { + allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + path: '/monitor' as const, + }, + sourcemaps: { disable: true }, + }; + + sentryTanstackStart(options); + + expect(makeTunnelRoutePlugin).toHaveBeenCalledWith(options.tunnelRoute, undefined); + }); + }); }); diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts new file mode 100644 index 000000000000..822a01aeeeff --- /dev/null +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { vi } from 'vitest'; +import { injectManagedTunnelRoute, makeTunnelRoutePlugin, resolveTunnelRoute } from '../../src/vite/tunnelRoute'; + +const ROUTE_TREE_SOURCE = `import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) +`; + +const UNTYPED_ROUTE_TREE_SOURCE = ROUTE_TREE_SOURCE.replace( + 'const rootRouteChildren: RootRouteChildren = {', + 'const rootRouteChildren = {', +); + +describe('tunnelRoute vite plugin', () => { + beforeEach(() => { + delete process.env.__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__; + }); + + afterEach(() => { + delete process.env.__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__; + }); + + it('reuses the same generated tunnel route within one process', () => { + const firstTunnelRoute = resolveTunnelRoute(true); + const secondTunnelRoute = resolveTunnelRoute(true); + + expect(firstTunnelRoute).toBe(secondTunnelRoute); + expect(firstTunnelRoute).toMatch(/^\/[a-z0-9]{8}$/); + }); + + it('always generates an 8-character tunnel route', () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + + expect(resolveTunnelRoute(true)).toBe('/iiiiiiii'); + }); + + it('returns the provided static tunnel route without reusing a generated one', () => { + resolveTunnelRoute(true); + + expect(resolveTunnelRoute('/monitor')).toBe('/monitor'); + }); + + it('rejects invalid static tunnel routes', () => { + expect(() => makeTunnelRoutePlugin('monitor')).toThrow( + 'static paths must start with `/` and must not contain query or hash segments', + ); + expect(() => makeTunnelRoutePlugin('/monitor?x=1')).toThrow( + 'static paths must start with `/` and must not contain query or hash segments', + ); + expect(() => makeTunnelRoutePlugin({ path: 'monitor' })).toThrow( + 'static paths must start with `/` and must not contain query or hash segments', + ); + }); + + it('injects the managed tunnel route into the generated TanStack route tree', () => { + const transformedRouteTree = injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/monitor'); + + expect(transformedRouteTree).toContain( + "import { Route as SentryManagedTunnelRouteImport } from 'virtual:sentry-tanstackstart-react/tunnel-route'", + ); + expect(transformedRouteTree).toContain('const SentryManagedTunnelRoute = SentryManagedTunnelRouteImport.update({'); + expect(transformedRouteTree).toContain('id: "/monitor"'); + expect(transformedRouteTree).toContain('path: "/monitor"'); + expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); + expect(transformedRouteTree).toContain('IndexRoute: IndexRoute,'); + }); + + it('injects the managed tunnel route when rootRouteChildren is untyped', () => { + const transformedRouteTree = injectManagedTunnelRoute(UNTYPED_ROUTE_TREE_SOURCE, '/monitor'); + + expect(transformedRouteTree).toContain('const rootRouteChildren = {'); + expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); + }); + + it('fails when the managed tunnel route conflicts with an existing route', () => { + expect(() => injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/')).toThrow( + 'Cannot register managed tunnel route "/" because an existing TanStack Start route already uses that path.', + ); + }); + + it('fails on route conflict when routeTree.gen.ts uses double quotes (tsr quoteStyle: double)', () => { + const doubleQuotedMonitorTree = ROUTE_TREE_SOURCE.replace("path: '/'", 'path: "/monitor"').replace( + "id: '/'", + 'id: "/monitor"', + ); + + expect(() => injectManagedTunnelRoute(doubleQuotedMonitorTree, '/monitor')).toThrow( + 'Cannot register managed tunnel route "/monitor" because an existing TanStack Start route already uses that path.', + ); + }); + + it('loads a virtual managed tunnel route module for a static tunnel path', async () => { + const plugin = makeTunnelRoutePlugin({ + allowedDsns: ['http://public@localhost:3031/1337'], + path: '/monitor', + }); + + expect(plugin.config && plugin.config()).toEqual({ + define: { + __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: '"/monitor"', + }, + }); + + expect(plugin.resolveId && plugin.resolveId('virtual:sentry-tanstackstart-react/tunnel-route')).toBe( + '\0virtual:sentry-tanstackstart-react/tunnel-route', + ); + + const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + + expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); + expect(virtualRouteModule).toContain('allowedDsns: ["http://public@localhost:3031/1337"]'); + }); + + it('omits allowedDsns from the virtual managed tunnel route module when not provided', async () => { + const plugin = makeTunnelRoutePlugin('/monitor'); + + const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + + expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); + expect(virtualRouteModule).toContain('createSentryTunnelRoute({})'); + }); + + it('treats an empty string `path` like omitted and uses a generated tunnel route', () => { + const plugin = makeTunnelRoutePlugin({ path: '' }); + + const defined = plugin.config && plugin.config(); + const serialized = defined?.define?.__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__; + expect(typeof serialized).toBe('string'); + expect(serialized).toMatch(/^"\/[a-z0-9]{8}"$/); + }); +});