diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts index b35026939d88..1d2f09c0b10c 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts @@ -2,6 +2,8 @@ import * as Sentry from '@sentry/cloudflare'; import { wrapFetchWithSentry } from '@sentry/tanstackstart-react'; import handler from '@tanstack/react-start/server-entry'; +const app = wrapFetchWithSentry(handler); + export default Sentry.withSentry( (env: Env) => ({ dsn: env.E2E_TEST_DSN, @@ -9,5 +11,14 @@ export default Sentry.withSentry( tracesSampleRate: 1.0, environment: 'qa', }), - wrapFetchWithSentry(handler), + { + fetch(request, env, ctx) { + // Lets a test check that the Worker's `ctx` survives the Sentry Vite plugin wrapping the server entry. + if (new URL(request.url).pathname === '/test-execution-context') { + return new Response(typeof ctx?.waitUntil); + } + + return (app.fetch as ExportedHandlerFetchHandler)(request, env, ctx); + }, + } satisfies ExportedHandler, ); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts new file mode 100644 index 000000000000..82060b30bd13 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test'; + +test("keeps the Worker's ExecutionContext when the Sentry Vite plugin wraps the server entry", async ({ request }) => { + const response = await request.get('/test-execution-context'); + + expect(await response.text()).toBe('function'); +}); + +test('answers a fixed tunnelRoute with the DSN that `withSentry` initializes', async ({ request }) => { + // A DSN the server doesn't allow, so the tunnel answers without forwarding anywhere. + const envelopeHeader = { dsn: 'https://other@o1.ingest.sentry.io/42', sent_at: new Date().toISOString() }; + const response = await request.post('/monitoring', { + data: `${JSON.stringify(envelopeHeader)}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(403); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts index 8e749133c7d8..b9657a989669 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts @@ -3,10 +3,17 @@ import tsConfigPaths from 'vite-tsconfig-paths'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import viteReact from '@vitejs/plugin-react'; import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; export default defineConfig({ server: { port: 3030, }, - plugins: [cloudflare({ viteEnvironment: { name: 'ssr' } }), tsConfigPaths(), tanstackStart(), viteReact()], + plugins: [ + cloudflare({ viteEnvironment: { name: 'ssr' } }), + tsConfigPaths(), + tanstackStart(), + viteReact(), + sentryTanstackStart({ tunnelRoute: '/monitoring', sourcemaps: { disable: true } }), + ], }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts index 76a39f44be81..966441835367 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts @@ -10,7 +10,7 @@ const expectedTunnelPathMatcher = ? '/custom-monitor' : tunnelRouteMode === 'object' ? '/object-monitor' - : /^\/[a-z0-9]{8}$/; + : /^\/[a-z0-9]{1,7}$/; test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); 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 555271be7beb..9b0bb01e60f0 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -14,13 +14,17 @@ "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:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest nitro@latest vite@latest @vitejs/plugin-react-swc@latest vite-tsconfig-paths@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test && TEST_ENV=development pnpm playwright test db-drivers", "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" + "test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development pnpm test tunnel", + "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development pnpm test tunnel" }, "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { @@ -70,6 +74,21 @@ "label": "tanstackstart-react (tunnel-object)", "build-command": "pnpm test:build:tunnel-object", "assert-command": "pnpm test:assert:tunnel-object" + }, + { + "label": "tanstackstart-react (tunnel-basepath)", + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath" + }, + { + "label": "tanstackstart-react (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" + }, + { + "label": "tanstackstart-react (tunnel-basepath-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev" } ] } 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 index 9857d951b2d8..e14011b4a557 100644 --- 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 @@ -3,6 +3,7 @@ import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/t const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off'); +const basePath = process.env.E2E_TEST_BASE_PATH ?? ''; const expectedTunnelPathMatcher = tunnelRouteMode === 'static' ? '/monitor' @@ -10,7 +11,8 @@ const expectedTunnelPathMatcher = ? '/custom-monitor' : tunnelRouteMode === 'object' ? '/object-monitor' - : /^\/[a-z0-9]{8}$/; + : // The path `tunnelRoute: true` derives from the DSN. + new RegExp(`^${basePath}/[a-z0-9]{1,7}$`); test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); @@ -19,7 +21,7 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; }); - await page.goto('/'); + await page.goto(`${basePath}/`); const pageOrigin = new URL(page.url()).origin; // Wait for React to hydrate (see __root.tsx) before clicking — the SSR HTML @@ -54,20 +56,19 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa expect(managedTunnelUrl.pathname).toBe(expectedTunnelPathMatcher); } else { expect(managedTunnelUrl.pathname).toMatch(expectedTunnelPathMatcher); - expect(managedTunnelUrl.pathname).not.toBe('/monitor'); + expect(managedTunnelUrl.pathname).not.toBe(`${basePath}/monitor`); } expect(errorEvent.exception?.values?.[0]?.value).toBe('Sentry Client Test Error'); - expect(errorEvent.transaction).toBe('/'); + expect(errorEvent.transaction).toBe(`${basePath}/`); }); -test('Rejects non-POST requests to the managed tunnel route instead of rendering the app', async ({ request }) => { +test('Only answers POSTs on the managed tunnel path and leaves other methods to the app', async ({ request }) => { test.skip(tunnelRouteMode !== 'static', 'Requires a known managed tunnel path'); const response = await request.get('/monitor'); - expect(response.status()).toBe(405); - expect(response.headers()['allow']).toBe('POST'); + expect(response.status()).toBe(404); }); function pathnameMatchesTunnelRoute(pathname: string): boolean { @@ -101,7 +102,7 @@ test('Does not create a server transaction for the tunnel route', async ({ page // transaction by the server SDK — tunnel traffic is plumbing, not application requests. const tunnelServerEventPromise = waitForServerHttpSpan(pathnameMatchesTunnelRoute); - await page.goto('/'); + await page.goto(`${basePath}/`); const pageOrigin = new URL(page.url()).origin; await page.locator('html[data-hydrated="true"]').waitFor(); @@ -127,7 +128,7 @@ test('Does not create a server transaction for the tunnel route', async ({ page // this anchor. Racing the two lets us assert the absence of a tunnel transaction without idling on a timeout. const anchorServerEventPromise = waitForServerHttpSpan(pathname => pathname.includes('/api/user/')); - await page.request.get('/api/user/456'); + await page.request.get(`${basePath}/api/user/456`); const winner = await Promise.race([ tunnelServerEventPromise.then(() => 'tunnel' as const), 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 2385d8aa5e93..9e0813a8d701 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 @@ -45,7 +45,10 @@ export default defineConfig({ }, plugins: [ tsConfigPaths(), - tanstackStart(), + // Start routes the whole app under the base path, so the tunnel has to be served under it too. + tanstackStart( + process.env.E2E_TEST_BASE_PATH ? { router: { basepath: process.env.E2E_TEST_BASE_PATH } } : undefined, + ), nitro(), // react's vite plugin must come after start's vite plugin viteReact(), diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index 73c7e4cf7b24..8e9def78a531 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -62,3 +62,10 @@ export function createSentryTunnelRoute(_options: CreateSentryTunnelRouteOptions * can statically import it (the call is server-guarded and tree-shaken from the client bundle). */ export function registerSentryServerTunnelRoute(_path: string): void {} + +/** + * No-op stub for client-side builds. The server entry the Vite plugin wraps only runs on the server. + */ +export function wrapServerEntryWithSentryTunnel(entry: T, _options?: unknown): T { + return entry; +} diff --git a/packages/tanstackstart-react/src/client/tunnelRoute.ts b/packages/tanstackstart-react/src/client/tunnelRoute.ts index b22612c123d0..d5f92d76f435 100644 --- a/packages/tanstackstart-react/src/client/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/client/tunnelRoute.ts @@ -1,15 +1,25 @@ -import { consoleSandbox } from '@sentry/core'; +import { applyTunnelDefaults, consoleSandbox } from '@sentry/core'; import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react'; -declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: string | undefined; +declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: string | true | undefined; +declare const __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__: 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()`. + * configured an explicit runtime `tunnel` option in `Sentry.init()`, then serves the tunnel under the base path + * Start routes on. A `basePath` the user set wins. */ export function applyTunnelRouteOption(options: ReactBrowserOptions): void { + applyManagedTunnelRoute(options); + + const basePath = + typeof __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__ : ''; + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); +} + +function applyManagedTunnelRoute(options: ReactBrowserOptions): void { const managedTunnelRoute = typeof __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ : undefined; @@ -18,7 +28,7 @@ export function applyTunnelRouteOption(options: ReactBrowserOptions): void { } if (options.tunnel) { - if (!hasWarnedAboutManagedTunnelRouteOverride) { + if (options.tunnel !== managedTunnelRoute && !hasWarnedAboutManagedTunnelRouteOverride) { hasWarnedAboutManagedTunnelRouteOverride = true; consoleSandbox(() => { // eslint-disable-next-line no-console diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index a5464d731373..3e3e8fe690ef 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -49,3 +49,4 @@ export declare const sentryGlobalRequestMiddleware: typeof serverSdk.sentryGloba export declare const sentryGlobalFunctionMiddleware: typeof serverSdk.sentryGlobalFunctionMiddleware; export declare const createSentryTunnelRoute: typeof serverSdk.createSentryTunnelRoute; export declare const registerSentryServerTunnelRoute: typeof serverSdk.registerSentryServerTunnelRoute; +export declare const wrapServerEntryWithSentryTunnel: typeof serverSdk.wrapServerEntryWithSentryTunnel; diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index 2d2b1bb85d26..2d96ca364495 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -9,7 +9,11 @@ export { init } from './sdk'; export { wrapFetchWithSentry } from './wrapFetchWithSentry'; export { wrapMiddlewaresWithSentry } from './middleware'; export { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } from './globalMiddleware'; -export { createSentryTunnelRoute, registerSentryServerTunnelRoute } from './tunnelRoute'; +export { + createSentryTunnelRoute, + registerSentryServerTunnelRoute, + wrapServerEntryWithSentryTunnel, +} from './tunnelRoute'; /** * A no-op stub of the replay 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 index 89501d8a2da4..11d52796f1ca 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -1,5 +1,14 @@ import { URL_PATH } from '@sentry/conventions/attributes'; -import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; +import type { TunnelOptions } from '@sentry/core'; +import { + dsnToString, + escapeStringForRegex, + getClient, + getTunnelPaths, + handleTunnelRequest, + handleTunnelRequestIfMatched, +} from '@sentry/core'; +import type { ServerEntry } from './wrapFetchWithSentry'; const registeredTunnelRoutePaths = new Set(); @@ -92,3 +101,47 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) }, }; } + +/** + * Answers managed tunnel requests before the server entry runs. The `sentryTanstackStart` Vite plugin applies this + * to the app's server entry automatically, with the base path Start routes on. + */ +export function wrapServerEntryWithSentryTunnel( + entry: T, + options?: TunnelOptions, + basePath?: string, +): T { + // Requests still carry Start's base path here, so the tunnel is matched under it. + const getTunnelOptions = (): TunnelOptions | undefined => { + const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; + + return tunnelOptions && basePath ? { basePath, ...tunnelOptions } : tunnelOptions; + }; + + // When `init` ran before the server entry loads, drop the tunnel's spans from the first request on. + const startupOptions = getTunnelOptions(); + getTunnelPaths(startupOptions).forEach(path => + registerSentryServerTunnelRoute(`${startupOptions?.basePath?.replace(/\/$/, '') ?? ''}${path}`), + ); + + // A class entry, like Cloudflare's `WorkerEntrypoint`, can't be spread into an object. The tunnel is then served + // by the wrapped default server entry it calls. + if (typeof entry === 'function') { + return entry; + } + + return { + ...entry, + // Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush. + fetch(request: Request, ...rest: unknown[]) { + const tunnelResponse = handleTunnelRequestIfMatched(request, getTunnelOptions()); + if (tunnelResponse) { + registerSentryServerTunnelRoute(new URL(request.url).pathname); + + return tunnelResponse; + } + + return entry.fetch(request, ...rest); + }, + }; +} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index c3359c077d1b..bebffcee875b 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -5,7 +5,7 @@ import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; -import { makeTunnelRoutePlugin } from './tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelBasePathPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; /** * Build-time options for the Sentry TanStack Start SDK. @@ -40,7 +40,7 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * as the default `tunnel` option on the client. * * You can pass: - * - `true` to generate an opaque route path per dev session or production build. + * - `true` to serve the path derived from the DSN, the same one `Sentry.init({ tunnel: true })` uses. * - `'/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. @@ -76,7 +76,12 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - const plugins: Plugin[] = [makeRoutePatternPlugin()]; + const tunnelBasePath = makeTunnelBasePathPlugin(); + const plugins: Plugin[] = [ + makeRoutePatternPlugin(), + tunnelBasePath.plugin, + makeServerEntryTunnelPlugin(options.tunnelRoute, tunnelBasePath.getBasePath), + ]; if (options.tunnelRoute) { plugins.push(makeTunnelRoutePlugin(options.tunnelRoute, options.debug)); diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 06f3337b4e5e..74d14132a6af 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,4 +1,7 @@ +import type { TunnelOptions } from '@sentry/core'; +import { getClient, getTunnelPaths, normalizeTunnelBasePath, setManagedTunnelBasePath } from '@sentry/core'; import type { Plugin } from 'vite'; +import { registerSentryServerTunnelRoute } from '../server/tunnelRoute'; export type TunnelRouteOptions = | true @@ -14,43 +17,11 @@ export type TunnelRouteOptions = /** * 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. + * If omitted, the path is derived from the DSN at runtime, the same way `Sentry.init({ tunnel: true })` does. */ 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( @@ -59,156 +30,144 @@ function validateStaticPath(path: string): void { } } -function normalizeTunnelRouteOptions(options: TunnelRouteOptions): NormalizedTunnelRouteOptions { +/** + * Maps `tunnelRoute` onto the options `Sentry.init({ tunnel })` takes, so both are served the same way. + */ +export function normalizeTunnelRouteOptions(options: TunnelRouteOptions): TunnelOptions { if (options === true) { - return { resolvedPath: resolveTunnelRoute(true), allowedDsns: undefined }; + return {}; } 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 { path: options }; } - 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; + if (options.path) { + validateStaticPath(options.path); + } -function hasRouteConflict(source: string, resolvedTunnelRoute: string): boolean { - const literals = [`'${resolvedTunnelRoute}'`, `"${resolvedTunnelRoute}"`]; - return ROUTE_CONFLICT_KEYS.some(key => literals.some(literal => source.includes(`${key}: ${literal}`))); + return { + path: options.path || undefined, + allowedDsns: options.allowedDsns?.length ? options.allowedDsns : undefined, + }; } -function injectAfterLastImport(source: string, statement: string): string { - const importMatches = [...source.matchAll(/^import .+$/gm)]; - const lastImport = importMatches.at(-1); +/** + * Tells the client SDK which tunnel to use. The server entry wrapper serves it. + */ +export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boolean): Plugin { + const { path } = normalizeTunnelRouteOptions(options); - 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.', - ); + if (debug) { + // eslint-disable-next-line no-console + console.log(`[@sentry/tanstackstart-react] Tunnel route: ${path ?? 'derived from the DSN'}`); } - const insertIndex = lastImport.index + lastImport[0].length; - return `${source.slice(0, insertIndex)}\n${statement}${source.slice(insertIndex)}`; + return { + name: 'sentry-tanstackstart-tunnel-route', + config() { + return { define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: path ? JSON.stringify(path) : 'true' } }; + }, + }; } -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); +const START_BASE_PATH_DEFINE = 'process.env.TSS_ROUTER_BASEPATH'; + +/** + * Reads the base path TanStack Start routes on and hands it to the client SDK and the server entry wrapper, so the + * managed tunnel is served under it without the user repeating it. Start resolves it from `router.basepath`, or from + * Vite's `base`, and defines it for every environment. This runs after Start's own `config` hook to see that define. + */ +export function makeTunnelBasePathPlugin(): { plugin: Plugin; getBasePath: () => string } { + let basePath = ''; + + const plugin: Plugin = { + name: 'sentry-tanstackstart-tunnel-base-path', + enforce: 'post', + config(config) { + const startBasePath = config.define?.[START_BASE_PATH_DEFINE]; + basePath = normalizeTunnelBasePath( + typeof startBasePath === 'string' ? parseDefine(startBasePath) : getRouterBasePathFromViteBase(config.base), + ); + + return { define: { __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__: JSON.stringify(basePath) } }; + }, + }; - let transformedSource = injectAfterLastImport( - source, - `import { Route as ${MANAGED_TUNNEL_ROUTE_IMPORT} } from '${VIRTUAL_TUNNEL_ROUTE_ID}'`, - ); + return { plugin, getBasePath: () => basePath }; +} - const rootRouteChildrenMatch = transformedSource.match( - /const rootRouteChildren(?:\s*:\s*RootRouteChildren)?\s*=\s*\{/, - ); +function parseDefine(value: string): string | undefined { + try { + const parsed: unknown = JSON.parse(value); - 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`.', - ); + return typeof parsed === 'string' ? parsed : undefined; + } catch { + return undefined; } - - 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; +// Start's own fallback when `router.basepath` isn't set: an absolute (CDN) `base` doesn't move the app's routes. +function getRouterBasePathFromViteBase(base: string | undefined): string | undefined { + return base && !/^[a-z][a-z\d+.-]*:/i.test(base) && !base.startsWith('//') ? base : undefined; +} - if (debug) { - // eslint-disable-next-line no-console - console.log(`[@sentry/tanstackstart-react] Registered tunnel route: ${resolvedTunnelRoute}`); - } +const SERVER_ENTRY_ALIAS = 'virtual:tanstack-start-server-entry'; +const ORIGINAL_SERVER_ENTRY_QUERY = '?sentry-tunnel-original'; +// The default entry is used directly when the app has no `src/server.ts`, and by Cloudflare's `main`. +const DEFAULT_SERVER_ENTRY = /\/react-start\/dist\/(?:plugin\/)?default-entry\/(?:esm\/)?server\.[jt]s$/; + +/** + * Wraps the TanStack Start server entry so tunnel requests are answered before Start's own request middleware and + * routing. It does nothing unless the managed tunnel is enabled, either here via `tunnelRoute` or at runtime via + * `Sentry.init({ tunnel: true })`. + */ +export function makeServerEntryTunnelPlugin( + tunnelRoute: TunnelRouteOptions | undefined, + getBasePath: () => string = () => '', +): Plugin { + const normalizedTunnelOptions = tunnelRoute ? normalizeTunnelRouteOptions(tunnelRoute) : undefined; + const tunnelOptions = normalizedTunnelOptions ? JSON.stringify(normalizedTunnelOptions) : 'undefined'; + const serverEntryIds = new Set(); return { - name: 'sentry-tanstackstart-tunnel-route', + name: 'sentry-tanstackstart-server-entry-tunnel', enforce: 'pre', - config() { - return { - define: { - __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: serializedTunnelRoute, - }, - }; + configResolved(config) { + const aliases = Array.isArray(config.resolve.alias) ? config.resolve.alias : []; + const serverEntry = aliases.find(alias => alias.find === SERVER_ENTRY_ALIAS); + if (serverEntry) { + serverEntryIds.add(normalizePath(serverEntry.replacement)); + } }, - resolveId(source) { - return source === VIRTUAL_TUNNEL_ROUTE_ID ? RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID : null; + // `vite dev` takes each request in this process and passes it on to where the server entry runs, so its + // `http.server` span comes from the SDK `--import` set up here, which the server entry never reaches. + configureServer() { + const basePath = getBasePath(); + setManagedTunnelBasePath(getClient(), basePath); + if (normalizedTunnelOptions) { + getTunnelPaths(normalizedTunnelOptions).forEach(path => registerSentryServerTunnelRoute(`${basePath}${path}`)); + } }, load(id) { - if (id !== RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID) { + const normalizedId = normalizePath(id); + if (!serverEntryIds.has(normalizedId) && !DEFAULT_SERVER_ENTRY.test(normalizedId)) { return null; } - return `import { createFileRoute } from '@tanstack/react-router'; -import { registerSentryServerTunnelRoute } from '@sentry/tanstackstart-react'; - -// Server-only: drop the tunnel route's own transaction. Registered synchronously at module evaluation -// (before any request) so streamed spans are filtered from the first request. The SSR guard tree-shakes -// it (and the import) out of the client bundle. -if (import.meta.env.SSR) { - registerSentryServerTunnelRoute(${serializedTunnelRoute}); -} + const original = JSON.stringify(`${id}${ORIGINAL_SERVER_ENTRY_QUERY}`); -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 }); - }, - // Without a handler for a method, TanStack Start falls back to SSR-rendering the app (running root loaders). - ANY() { - return new Response(null, { status: 405, headers: { Allow: 'POST' } }); - }, - }, - }, -}); + return `import entry from ${original}; +import { wrapServerEntryWithSentryTunnel } from '@sentry/tanstackstart-react'; +export * from ${original}; +export default wrapServerEntryWithSentryTunnel(entry, ${tunnelOptions}, ${JSON.stringify(getBasePath())}); `; }, - transform(source, id) { - if (!id.endsWith('/routeTree.gen.ts') && !id.endsWith('\\routeTree.gen.ts')) { - return null; - } - - return injectManagedTunnelRoute(source, resolvedTunnelRoute); - }, }; } + +function normalizePath(id: string): string { + return id.replace(/\\/g, '/'); +} diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts index 9dca196f072d..f1f01669c999 100644 --- a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -50,6 +50,30 @@ describe('applyTunnelRouteOption()', () => { ); }); + it('turns on the DSN-derived tunnel when the plugin derives the path from the DSN', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', true); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { dsn: 'https://public@dsn.ingest.sentry.io/1337' }; + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe(true); + }); + + it('does not warn when the runtime option already matches the managed tunnel', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', true); + + 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: true }; + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe(true); + expect(warnSpy).not.toHaveBeenCalled(); + }); + it('does nothing when no managed tunnel route was injected', async () => { const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); @@ -61,4 +85,34 @@ describe('applyTunnelRouteOption()', () => { expect(options.tunnel).toBeUndefined(); }); + + it('serves the tunnel under the base path Start routes on, unless the user set one', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__', '/app'); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const managed: BrowserOptions = { tunnel: true }; + applyTunnelRouteOption(managed); + expect(managed.tunnel).toEqual({ basePath: '/app' }); + + const own: BrowserOptions = { tunnel: { basePath: '/custom' } }; + applyTunnelRouteOption(own); + expect(own.tunnel).toEqual({ basePath: '/custom' }); + + const url: BrowserOptions = { tunnel: 'https://example.com/tunnel' }; + applyTunnelRouteOption(url); + expect(url.tunnel).toBe('https://example.com/tunnel'); + }); + + it('serves a static `tunnelRoute` under the base path too', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__', '/app'); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = {}; + applyTunnelRouteOption(options); + + expect(options.tunnel).toEqual({ basePath: '/app', path: '/managed-tunnel' }); + }); }); diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts new file mode 100644 index 000000000000..94605d4f3b6f --- /dev/null +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -0,0 +1,125 @@ +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapServerEntryWithSentryTunnel } from '../../src/server/tunnelRoute'; + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; +const TUNNEL_URL = `http://localhost:3000${getTunnelPath(DSN)}`; + +function makeEnvelopeRequest(url: string): Request { + return new Request(url, { method: 'POST', body: serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as BodyInit }); +} + +function setClient(managedTunnel: object | undefined): void { + const options = { dsn: DSN, _managedTunnel: managedTunnel, ignoreSpans: [] }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); +} + +describe('wrapServerEntryWithSentryTunnel', () => { + const appFetch = vi.fn(() => new Response('app')); + const sentryFetch = vi.fn(() => Promise.resolve(new Response('forwarded'))); + + beforeEach(() => { + vi.stubGlobal('fetch', sentryFetch); + }); + + afterEach(() => { + getCurrentScope().setClient(undefined); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('answers the tunnel path when `init` enabled the managed tunnel, without calling the app', async () => { + setClient({}); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(makeEnvelopeRequest(TUNNEL_URL)); + + expect(await response.text()).toBe('forwarded'); + expect(appFetch).not.toHaveBeenCalled(); + }); + + it('hands every request to the app when the managed tunnel is off', async () => { + setClient(undefined); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(makeEnvelopeRequest(TUNNEL_URL)); + + expect(await response.text()).toBe('app'); + expect(sentryFetch).not.toHaveBeenCalled(); + }); + + it('serves the tunnel with build-time options even when `init` did not enable it', async () => { + setClient(undefined); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}).fetch( + makeEnvelopeRequest(TUNNEL_URL), + ); + + expect(await response.text()).toBe('forwarded'); + }); + + it('passes other requests and their options to the app', async () => { + setClient({}); + const request = makeEnvelopeRequest('http://localhost:3000/api/users'); + + await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(request, { context: 1 }); + + expect(appFetch).toHaveBeenCalledWith(request, { context: 1 }); + }); + + it("passes every argument to the app, like a Cloudflare Worker's `env` and `ctx`", async () => { + setClient({}); + const request = makeEnvelopeRequest('http://localhost:3000/api/users'); + const env = { E2E_TEST_DSN: DSN }; + const ctx = { waitUntil: vi.fn(), passThroughOnException: vi.fn() }; + + await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(request, env, ctx); + + expect(appFetch).toHaveBeenCalledWith(request, env, ctx); + }); + + it('returns a class entry, like a `WorkerEntrypoint`, unchanged', () => { + class Worker { + public fetch(): Response { + return new Response('app'); + } + } + + expect(wrapServerEntryWithSentryTunnel(Worker as unknown as { fetch: () => Response })).toBe(Worker); + }); + + it('ignores spans for the tunnel path from the start when the client already exists', async () => { + // Paths are registered once per module, and earlier tests already registered this one. + vi.resetModules(); + const { wrapServerEntryWithSentryTunnel: freshWrap } = await import('../../src/server/tunnelRoute'); + setClient({}); + const options = getCurrentScope().getClient()!.getOptions(); + + freshWrap({ fetch: appFetch }); + + expect(options.ignoreSpans).toEqual([{ attributes: { 'url.path': expect.any(RegExp) } }]); + expect( + (options.ignoreSpans![0] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test( + getTunnelPath(DSN)!, + ), + ).toBe(true); + }); + + it('answers the tunnel under the base path Start routes on, and only there', async () => { + setClient({}); + const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); + + expect( + await (await wrapped.fetch(makeEnvelopeRequest(`http://localhost:3000/app${getTunnelPath(DSN)}`))).text(), + ).toBe('forwarded'); + expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('app'); + }); + + it('keeps a base path the server `init` set', async () => { + setClient({ basePath: '/custom' }); + const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); + + expect( + await (await wrapped.fetch(makeEnvelopeRequest(`http://localhost:3000/custom${getTunnelPath(DSN)}`))).text(), + ).toBe('forwarded'); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 0715873cdfa5..02ffa14e8dd1 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -2,7 +2,7 @@ import type { Plugin } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeAutoInstrumentMiddlewarePlugin } from '../../src/vite/autoInstrumentMiddleware'; import { sentryTanstackStart, type SentryTanstackStartOptions } from '../../src/vite/sentryTanstackStart'; -import { makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; const mockSourceMapsConfigPlugin: Plugin = { name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', @@ -35,6 +35,16 @@ const mockTunnelRoutePlugin: Plugin = { transform: vi.fn(), }; +const mockServerEntryTunnelPlugin: Plugin = { + name: 'sentry-tanstackstart-server-entry-tunnel', + enforce: 'pre', + load: vi.fn(), +}; + +const mockTunnelBasePathPlugin: Plugin = { + name: 'sentry-tanstackstart-tunnel-base-path', +}; + const mockRoutePatternPlugin: Plugin = { name: 'sentry-tanstackstart-route-patterns', enforce: 'pre', @@ -67,6 +77,8 @@ vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ vi.mock('../../src/vite/tunnelRoute', () => ({ makeTunnelRoutePlugin: vi.fn(() => mockTunnelRoutePlugin), + makeServerEntryTunnelPlugin: vi.fn(() => mockServerEntryTunnelPlugin), + makeTunnelBasePathPlugin: vi.fn(() => ({ plugin: mockTunnelBasePathPlugin, getBasePath: () => '' })), })); describe('sentryTanstackStart()', () => { @@ -85,6 +97,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -97,7 +111,7 @@ describe('sentryTanstackStart()', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([mockRoutePatternPlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin]); }); it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { @@ -108,7 +122,12 @@ describe('sentryTanstackStart()', () => { tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockTunnelRoutePlugin]); + expect(plugins).toEqual([ + mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, + mockTunnelRoutePlugin, + ]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { @@ -119,6 +138,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -133,6 +154,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -147,6 +170,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -161,6 +186,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -176,6 +203,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -191,6 +220,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -222,6 +253,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockTunnelRoutePlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -242,6 +275,7 @@ describe('sentryTanstackStart()', () => { sentryTanstackStart(options); expect(makeTunnelRoutePlugin).toHaveBeenCalledWith(options.tunnelRoute, undefined); + expect(makeServerEntryTunnelPlugin).toHaveBeenCalledWith(options.tunnelRoute, expect.any(Function)); }); }); diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 22b3a9be56a5..238f71886e60 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -1,62 +1,25 @@ -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 = {', -); +import type { Client } from '@sentry/core'; +import { getCurrentScope, getTunnelPath, makeDsn } from '@sentry/core'; +import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + makeServerEntryTunnelPlugin, + makeTunnelBasePathPlugin, + makeTunnelRoutePlugin, + normalizeTunnelRouteOptions, +} from '../../src/vite/tunnelRoute'; 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('maps `tunnelRoute` onto the `tunnel` init options', () => { + expect(normalizeTunnelRouteOptions(true)).toEqual({}); + expect(normalizeTunnelRouteOptions('/monitor')).toEqual({ path: '/monitor' }); + expect(normalizeTunnelRouteOptions({ path: '', allowedDsns: [] })).toEqual({ + path: undefined, + allowedDsns: undefined, + }); + expect( + normalizeTunnelRouteOptions({ path: '/monitor', allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }), + ).toEqual({ path: '/monitor', allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }); }); it('rejects invalid static tunnel routes', () => { @@ -71,89 +34,129 @@ describe('tunnelRoute vite plugin', () => { ); }); - it('injects the managed tunnel route into the generated TanStack route tree', () => { - const transformedRouteTree = injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/monitor'); + it('tells the client the static path, or that the path comes from the DSN', () => { + const staticPlugin = makeTunnelRoutePlugin({ path: '/monitor' }); + const dsnPlugin = makeTunnelRoutePlugin(true); + + expect(staticPlugin.config && staticPlugin.config()).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: '"/monitor"' }, + }); + expect(dsnPlugin.config && dsnPlugin.config()).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: 'true' }, + }); + }); +}); - expect(transformedRouteTree).toContain( - "import { Route as SentryManagedTunnelRouteImport } from 'virtual:sentry-tanstackstart-react/tunnel-route'", +describe('server entry tunnel vite plugin', () => { + const USER_ENTRY = '/app/src/server.ts'; + + function setup( + tunnelRoute?: Parameters[0], + basePath = '', + ): (id: string) => unknown { + const plugin = makeServerEntryTunnelPlugin(tunnelRoute, () => basePath); + const config = { + resolve: { alias: [{ find: 'virtual:tanstack-start-server-entry', replacement: USER_ENTRY }] }, + } as unknown as ResolvedConfig; + (plugin.configResolved as (config: ResolvedConfig) => void)(config); + + return id => (plugin.load as Extract unknown>).call({} as never, id); + } + + it('wraps the app server entry and the default entry', () => { + const load = setup(); + + expect(load(USER_ENTRY)).toContain('export default wrapServerEntryWithSentryTunnel(entry, undefined, "");'); + expect(load(`${USER_ENTRY}?sentry-tunnel-original`)).toBeNull(); + expect(load('/app/node_modules/@tanstack/react-start/dist/default-entry/esm/server.js')).toContain( + 'wrapServerEntryWithSentryTunnel', + ); + expect(load('C:\\app\\node_modules\\@tanstack\\react-start\\dist\\plugin\\default-entry\\server.ts')).toContain( + 'wrapServerEntryWithSentryTunnel', ); - 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,'); + expect(load('/app/src/routes/index.tsx')).toBeNull(); }); - it('injects the managed tunnel route when rootRouteChildren is untyped', () => { - const transformedRouteTree = injectManagedTunnelRoute(UNTYPED_ROUTE_TREE_SOURCE, '/monitor'); + it('re-exports everything from the original server entry', () => { + const load = setup(); - expect(transformedRouteTree).toContain('const rootRouteChildren = {'); - expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); + expect(load(USER_ENTRY)).toContain(`export * from "${USER_ENTRY}?sentry-tunnel-original";`); }); - 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('passes the build-time options when `tunnelRoute` derives the path from the DSN', () => { + expect(setup(true)(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {}, "");'); + expect(setup({ allowedDsns: ['https://public@o0.ingest.sentry.io/0'] })(USER_ENTRY)).toContain( + 'wrapServerEntryWithSentryTunnel(entry, {"allowedDsns":["https://public@o0.ingest.sentry.io/0"]}, "");', ); }); - 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"', - ); + it('serves a static `tunnelRoute` path from the wrapper too', () => { + expect(setup('/monitor')(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {"path":"/monitor"}, "");'); + }); - expect(() => injectManagedTunnelRoute(doubleQuotedMonitorTree, '/monitor')).toThrow( - 'Cannot register managed tunnel route "/monitor" because an existing TanStack Start route already uses that path.', + it('passes the base path Start routes on to the wrapper', () => { + expect(setup(undefined, '/app')(USER_ENTRY)).toContain( + 'wrapServerEntryWithSentryTunnel(entry, undefined, "/app");', ); }); +}); - 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"', - }, - }); +describe('tunnel base path vite plugin', () => { + function resolve(config: UserConfig): { define: unknown; basePath: string } { + const { plugin, getBasePath } = makeTunnelBasePathPlugin(); + const result = (plugin.config as (config: UserConfig) => { define: unknown })(config); - expect(plugin.resolveId && plugin.resolveId('virtual:sentry-tanstackstart-react/tunnel-route')).toBe( - '\0virtual:sentry-tanstackstart-react/tunnel-route', - ); + return { define: result.define, basePath: getBasePath() }; + } - const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + it('reads the base path Start defines for every environment', () => { + expect(resolve({ define: { 'process.env.TSS_ROUTER_BASEPATH': JSON.stringify('/app/') } })).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__: '"/app"' }, + basePath: '/app', + }); + }); - expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); - expect(virtualRouteModule).toContain('allowedDsns: ["http://public@localhost:3031/1337"]'); + it("falls back to Vite's `base`, ignoring an absolute one", () => { + expect(resolve({ base: '/my-app/' }).basePath).toBe('/my-app'); + expect(resolve({ base: 'https://cdn.example.com/assets/' }).basePath).toBe(''); }); - it('omits allowedDsns from the virtual managed tunnel route module when not provided', async () => { - const plugin = makeTunnelRoutePlugin('/monitor'); + it('serves the tunnel from the root without a base path', () => { + expect(resolve({ define: { 'process.env.TSS_ROUTER_BASEPATH': JSON.stringify('/') } }).basePath).toBe(''); + expect(resolve({}).basePath).toBe(''); + }); +}); - const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); +describe('server entry tunnel vite plugin in `vite dev`', () => { + const DSN = 'https://public@o1.ingest.sentry.io/1337'; - expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); - expect(virtualRouteModule).toContain('createSentryTunnelRoute({})'); + afterEach(() => { + getCurrentScope().setClient(undefined); }); - it('rejects non-POST requests in the virtual managed tunnel route module', async () => { - const plugin = makeTunnelRoutePlugin('/monitor'); + function setClient(managedTunnel: object | undefined): Record { + const options = { dsn: DSN, _managedTunnel: managedTunnel, ignoreSpans: [] as unknown[] }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); + + return options; + } - const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + it("drops the dev server's spans for a `tunnelRoute` under the base path", () => { + const options = setClient(undefined); + const plugin = makeServerEntryTunnelPlugin(true, () => '/app'); - expect(virtualRouteModule).toContain('ANY()'); - expect(virtualRouteModule).toContain("status: 405, headers: { Allow: 'POST' }"); + (plugin.configureServer as () => void)(); + + const [filter] = options.ignoreSpans as { attributes: Record }[]; + expect(filter?.attributes['url.path']?.test(`/app${getTunnelPath(DSN)}`)).toBe(true); }); - it('treats an empty string `path` like omitted and uses a generated tunnel route', () => { - const plugin = makeTunnelRoutePlugin({ path: '' }); + it("serves the dev server's managed tunnel from `init` under the base path", () => { + const options = setClient({}); + const plugin = makeServerEntryTunnelPlugin(undefined, () => '/app'); + + (plugin.configureServer as () => void)(); - 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}"$/); + expect(options._managedTunnel).toEqual({ basePath: '/app' }); }); });