From 1af8f48720a9ff0d2dac30f5a551124a14573c62 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:13:18 -0400 Subject: [PATCH 01/22] feat(tanstackstart-react): Serve the managed tunnel from the server entry The Vite plugin now wraps the app's server entry, so `Sentry.init({ tunnel: true })` works with no build option and tunnel requests are answered before Start's request middleware and routing. Every `tunnelRoute` form is served the same way: `true` uses the DSN-derived path instead of a random one per build, and a static path no longer patches the generated route tree. --- .../tanstackstart-react/tests/tunnel.test.ts | 8 +- .../tanstackstart-react/src/client/index.ts | 7 + .../src/client/tunnelRoute.ts | 4 +- .../tanstackstart-react/src/index.types.ts | 1 + .../tanstackstart-react/src/server/index.ts | 6 +- .../src/server/tunnelRoute.ts | 34 ++- .../src/vite/sentryTanstackStart.ts | 6 +- .../src/vite/tunnelRoute.ts | 211 +++++------------- .../test/client/tunnelRoute.test.ts | 24 ++ .../wrapServerEntryWithSentryTunnel.test.ts | 85 +++++++ .../test/vite/sentryTanstackStart.test.ts | 22 +- .../test/vite/tunnelRoute.test.ts | 181 +++++---------- 12 files changed, 296 insertions(+), 293 deletions(-) create mode 100644 packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts 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..4995a32542ee 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 @@ -10,7 +10,8 @@ const expectedTunnelPathMatcher = ? '/custom-monitor' : tunnelRouteMode === 'object' ? '/object-monitor' - : /^\/[a-z0-9]{8}$/; + : // The path `tunnelRoute: true` derives from the DSN. + /^\/[a-z0-9]{1,7}$/; test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); @@ -61,13 +62,12 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa expect(errorEvent.transaction).toBe('/'); }); -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 { 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..bff595dbaa20 100644 --- a/packages/tanstackstart-react/src/client/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/client/tunnelRoute.ts @@ -1,7 +1,7 @@ import { 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; let hasWarnedAboutManagedTunnelRouteOverride = false; @@ -18,7 +18,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..7a89ec338537 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,26 @@ 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. + */ +export function wrapServerEntryWithSentryTunnel(entry: T, options?: TunnelOptions): T { + // When `init` ran before the server entry loads, drop the tunnel's spans from the first request on. + getTunnelPaths(options).forEach(registerSentryServerTunnelRoute); + + return { + ...entry, + fetch(request: Request, opts?: unknown) { + const tunnelResponse = handleTunnelRequestIfMatched(request, options); + if (tunnelResponse) { + registerSentryServerTunnelRoute(new URL(request.url).pathname); + + return tunnelResponse; + } + + return entry.fetch(request, opts); + }, + }; +} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index 3c1c28db9bd6..5e404cebbbe8 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -6,7 +6,7 @@ import { makeComposeNitroServiceSourceMapsPlugin, makeEnableNitroSourceMapsPlugi import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; -import { makeTunnelRoutePlugin } from './tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; /** * Build-time options for the Sentry TanStack Start SDK. @@ -41,7 +41,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. @@ -77,7 +77,7 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - const plugins: Plugin[] = [makeRoutePatternPlugin()]; + const plugins: Plugin[] = [makeRoutePatternPlugin(), makeServerEntryTunnelPlugin(options.tunnelRoute)]; 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..f445931ae8c9 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,3 +1,4 @@ +import type { TunnelOptions } from '@sentry/core'; import type { Plugin } from 'vite'; export type TunnelRouteOptions = @@ -14,43 +15,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 +28,90 @@ 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 { 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.`, - ); + return { path: options }; } - 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`.', - ); + if (options.path) { + validateStaticPath(options.path); } - 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; + return { + path: options.path || undefined, + allowedDsns: options.allowedDsns?.length ? options.allowedDsns : undefined, + }; } +/** + * Tells the client SDK which tunnel to use. The server entry wrapper serves it. + */ 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; + const { path } = normalizeTunnelRouteOptions(options); if (debug) { // eslint-disable-next-line no-console - console.log(`[@sentry/tanstackstart-react] Registered tunnel route: ${resolvedTunnelRoute}`); + console.log(`[@sentry/tanstackstart-react] Tunnel route: ${path ?? 'derived from the DSN'}`); } return { name: 'sentry-tanstackstart-tunnel-route', - enforce: 'pre', config() { - return { - define: { - __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: serializedTunnelRoute, - }, - }; + return { define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: path ? JSON.stringify(path) : 'true' } }; }, - 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'; -import { registerSentryServerTunnelRoute } from '@sentry/tanstackstart-react'; +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$/; -// 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}); -} +/** + * 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): Plugin { + const tunnelOptions = tunnelRoute ? JSON.stringify(normalizeTunnelRouteOptions(tunnelRoute)) : 'undefined'; + const serverEntryIds = new Set(); -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 { + name: 'sentry-tanstackstart-server-entry-tunnel', + enforce: 'pre', + 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)); + } }, - transform(source, id) { - if (!id.endsWith('/routeTree.gen.ts') && !id.endsWith('\\routeTree.gen.ts')) { + load(id) { + const normalizedId = normalizePath(id); + if (!serverEntryIds.has(normalizedId) && !DEFAULT_SERVER_ENTRY.test(normalizedId)) { return null; } - return injectManagedTunnelRoute(source, resolvedTunnelRoute); + const original = JSON.stringify(`${id}${ORIGINAL_SERVER_ENTRY_QUERY}`); + + return `import entry from ${original}; +import { wrapServerEntryWithSentryTunnel } from '@sentry/tanstackstart-react'; +export * from ${original}; +export default wrapServerEntryWithSentryTunnel(entry, ${tunnelOptions}); +`; }, }; } + +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..bfafb89cc43d 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'); 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..32d7e9b2b3ad --- /dev/null +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -0,0 +1,85 @@ +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('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); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 106c8fb633ae..412f3e7f140b 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', @@ -46,6 +46,12 @@ const mockTunnelRoutePlugin: Plugin = { transform: vi.fn(), }; +const mockServerEntryTunnelPlugin: Plugin = { + name: 'sentry-tanstackstart-server-entry-tunnel', + enforce: 'pre', + load: vi.fn(), +}; + const mockRoutePatternPlugin: Plugin = { name: 'sentry-tanstackstart-route-patterns', enforce: 'pre', @@ -83,6 +89,7 @@ vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ vi.mock('../../src/vite/tunnelRoute', () => ({ makeTunnelRoutePlugin: vi.fn(() => mockTunnelRoutePlugin), + makeServerEntryTunnelPlugin: vi.fn(() => mockServerEntryTunnelPlugin), })); describe('sentryTanstackStart()', () => { @@ -101,6 +108,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -115,7 +123,7 @@ describe('sentryTanstackStart()', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([mockRoutePatternPlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockServerEntryTunnelPlugin]); }); it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { @@ -126,7 +134,7 @@ describe('sentryTanstackStart()', () => { tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockTunnelRoutePlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockServerEntryTunnelPlugin, mockTunnelRoutePlugin]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { @@ -137,6 +145,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -152,6 +161,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -167,6 +177,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -183,6 +194,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -199,6 +211,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -215,6 +228,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -247,6 +261,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockTunnelRoutePlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -268,6 +283,7 @@ describe('sentryTanstackStart()', () => { sentryTanstackStart(options); expect(makeTunnelRoutePlugin).toHaveBeenCalledWith(options.tunnelRoute, undefined); + expect(makeServerEntryTunnelPlugin).toHaveBeenCalledWith(options.tunnelRoute); }); }); diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 22b3a9be56a5..a0fb73bc37c4 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -1,62 +1,22 @@ -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 { Plugin, ResolvedConfig } from 'vite'; +import { describe, expect, it } from 'vitest'; +import { + makeServerEntryTunnelPlugin, + 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 +31,60 @@ 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(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,'); + expect(staticPlugin.config && staticPlugin.config()).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: '"/monitor"' }, + }); + expect(dsnPlugin.config && dsnPlugin.config()).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: 'true' }, + }); }); +}); - it('injects the managed tunnel route when rootRouteChildren is untyped', () => { - const transformedRouteTree = injectManagedTunnelRoute(UNTYPED_ROUTE_TREE_SOURCE, '/monitor'); +describe('server entry tunnel vite plugin', () => { + const USER_ENTRY = '/app/src/server.ts'; - expect(transformedRouteTree).toContain('const rootRouteChildren = {'); - expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); - }); + function setup(tunnelRoute?: Parameters[0]): (id: string) => unknown { + const plugin = makeServerEntryTunnelPlugin(tunnelRoute); + const config = { + resolve: { alias: [{ find: 'virtual:tanstack-start-server-entry', replacement: USER_ENTRY }] }, + } as unknown as ResolvedConfig; + (plugin.configResolved as (config: ResolvedConfig) => void)(config); - 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.', - ); - }); + return id => (plugin.load as Extract unknown>).call({} as never, id); + } - 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('wraps the app server entry and the default entry', () => { + const load = setup(); - expect(() => injectManagedTunnelRoute(doubleQuotedMonitorTree, '/monitor')).toThrow( - 'Cannot register managed tunnel route "/monitor" because an existing TanStack Start route already uses that path.', + 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', ); - }); - - 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', + expect(load('C:\\app\\node_modules\\@tanstack\\react-start\\dist\\plugin\\default-entry\\server.ts')).toContain( + 'wrapServerEntryWithSentryTunnel', ); - - 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"]'); + expect(load('/app/src/routes/index.tsx')).toBeNull(); }); - 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')); + it('re-exports everything from the original server entry', () => { + const load = setup(); - expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); - expect(virtualRouteModule).toContain('createSentryTunnelRoute({})'); + expect(load(USER_ENTRY)).toContain(`export * from "${USER_ENTRY}?sentry-tunnel-original";`); }); - it('rejects non-POST requests in the virtual managed tunnel route module', async () => { - const plugin = makeTunnelRoutePlugin('/monitor'); - - const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); - - expect(virtualRouteModule).toContain('ANY()'); - expect(virtualRouteModule).toContain("status: 405, headers: { Allow: 'POST' }"); + 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('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}"$/); + it('serves a static `tunnelRoute` path from the wrapper too', () => { + expect(setup('/monitor')(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {"path":"/monitor"});'); }); }); From d0424617875ff3a1726c0c11f0b2858c28300aba Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:57:05 -0400 Subject: [PATCH 02/22] feat(tanstackstart-react): Serve the managed tunnel under the base path Start routes on --- .../tests/tunnel.test.ts | 2 +- .../tanstackstart-react/package.json | 9 +++- .../tanstackstart-react/tests/tunnel.test.ts | 13 ++--- .../tanstackstart-react/vite.config.ts | 5 +- .../src/client/tunnelRoute.ts | 14 ++++- .../src/server/tunnelRoute.ts | 22 ++++++-- .../src/vite/sentryTanstackStart.ts | 9 +++- .../src/vite/tunnelRoute.ts | 54 ++++++++++++++++++- .../test/client/tunnelRoute.test.ts | 30 +++++++++++ .../wrapServerEntryWithSentryTunnel.test.ts | 19 +++++++ .../test/vite/sentryTanstackStart.test.ts | 24 +++++++-- .../test/vite/tunnelRoute.test.ts | 50 ++++++++++++++--- 12 files changed, 222 insertions(+), 29 deletions(-) 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 ed739895d0d9..420ed34e0ecf 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -15,13 +15,15 @@ "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:hoisted": "pnpm install --config.node-linker=hoisted && 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" }, "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { @@ -75,6 +77,11 @@ "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" } ] } 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 4995a32542ee..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' @@ -11,7 +12,7 @@ const expectedTunnelPathMatcher = : tunnelRouteMode === 'object' ? '/object-monitor' : // The path `tunnelRoute: true` derives from the DSN. - /^\/[a-z0-9]{1,7}$/; + new RegExp(`^${basePath}/[a-z0-9]{1,7}$`); test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); @@ -20,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 @@ -55,11 +56,11 @@ 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('Only answers POSTs on the managed tunnel path and leaves other methods to the app', async ({ request }) => { @@ -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/tunnelRoute.ts b/packages/tanstackstart-react/src/client/tunnelRoute.ts index bff595dbaa20..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 | 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; diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 7a89ec338537..8340c06ac9d3 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -104,16 +104,30 @@ 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. + * to the app's server entry automatically, with the base path Start routes on. */ -export function wrapServerEntryWithSentryTunnel(entry: T, options?: TunnelOptions): T { +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. - getTunnelPaths(options).forEach(registerSentryServerTunnelRoute); + const startupOptions = getTunnelOptions(); + getTunnelPaths(startupOptions).forEach(path => + registerSentryServerTunnelRoute(`${startupOptions?.basePath?.replace(/\/$/, '') ?? ''}${path}`), + ); return { ...entry, fetch(request: Request, opts?: unknown) { - const tunnelResponse = handleTunnelRequestIfMatched(request, options); + const tunnelResponse = handleTunnelRequestIfMatched(request, getTunnelOptions()); if (tunnelResponse) { registerSentryServerTunnelRoute(new URL(request.url).pathname); diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index 5e404cebbbe8..ca8511da0aa6 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -6,7 +6,7 @@ import { makeComposeNitroServiceSourceMapsPlugin, makeEnableNitroSourceMapsPlugi import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; -import { makeServerEntryTunnelPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelBasePathPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; /** * Build-time options for the Sentry TanStack Start SDK. @@ -77,7 +77,12 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - const plugins: Plugin[] = [makeRoutePatternPlugin(), makeServerEntryTunnelPlugin(options.tunnelRoute)]; + 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 f445931ae8c9..048c85fa470f 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -71,6 +71,53 @@ export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boole }; } +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 = normalizeBasePath( + typeof startBasePath === 'string' ? parseDefine(startBasePath) : getRouterBasePathFromViteBase(config.base), + ); + + return { define: { __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__: JSON.stringify(basePath) } }; + }, + }; + + return { plugin, getBasePath: () => basePath }; +} + +function parseDefine(value: string): string | undefined { + try { + const parsed: unknown = JSON.parse(value); + + return typeof parsed === 'string' ? parsed : undefined; + } catch { + return 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; +} + +function normalizeBasePath(basePath: string | undefined): string { + const trimmed = (basePath ?? '').replace(/^\.?\/+|\/+$/g, ''); + + return trimmed ? `/${trimmed}` : ''; +} + 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`. @@ -81,7 +128,10 @@ const DEFAULT_SERVER_ENTRY = /\/react-start\/dist\/(?:plugin\/)?default-entry\/( * 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): Plugin { +export function makeServerEntryTunnelPlugin( + tunnelRoute: TunnelRouteOptions | undefined, + getBasePath: () => string = () => '', +): Plugin { const tunnelOptions = tunnelRoute ? JSON.stringify(normalizeTunnelRouteOptions(tunnelRoute)) : 'undefined'; const serverEntryIds = new Set(); @@ -106,7 +156,7 @@ export function makeServerEntryTunnelPlugin(tunnelRoute: TunnelRouteOptions | un return `import entry from ${original}; import { wrapServerEntryWithSentryTunnel } from '@sentry/tanstackstart-react'; export * from ${original}; -export default wrapServerEntryWithSentryTunnel(entry, ${tunnelOptions}); +export default wrapServerEntryWithSentryTunnel(entry, ${tunnelOptions}, ${JSON.stringify(getBasePath())}); `; }, }; diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts index bfafb89cc43d..f1f01669c999 100644 --- a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -85,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 index 32d7e9b2b3ad..4fa539ae296e 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -82,4 +82,23 @@ describe('wrapServerEntryWithSentryTunnel', () => { ), ).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 412f3e7f140b..e757cd9d6173 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -52,6 +52,10 @@ const mockServerEntryTunnelPlugin: Plugin = { load: vi.fn(), }; +const mockTunnelBasePathPlugin: Plugin = { + name: 'sentry-tanstackstart-tunnel-base-path', +}; + const mockRoutePatternPlugin: Plugin = { name: 'sentry-tanstackstart-route-patterns', enforce: 'pre', @@ -90,6 +94,7 @@ 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()', () => { @@ -108,6 +113,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -123,7 +129,7 @@ describe('sentryTanstackStart()', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockServerEntryTunnelPlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin]); }); it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { @@ -134,7 +140,12 @@ describe('sentryTanstackStart()', () => { tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockServerEntryTunnelPlugin, mockTunnelRoutePlugin]); + expect(plugins).toEqual([ + mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, + mockTunnelRoutePlugin, + ]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { @@ -145,6 +156,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -161,6 +173,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -177,6 +190,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -194,6 +208,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -211,6 +226,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -228,6 +244,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -261,6 +278,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockTunnelRoutePlugin, mockOrchestrionPlugin, @@ -283,7 +301,7 @@ describe('sentryTanstackStart()', () => { sentryTanstackStart(options); expect(makeTunnelRoutePlugin).toHaveBeenCalledWith(options.tunnelRoute, undefined); - expect(makeServerEntryTunnelPlugin).toHaveBeenCalledWith(options.tunnelRoute); + 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 a0fb73bc37c4..ba14bf21a07e 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -1,7 +1,8 @@ -import type { Plugin, ResolvedConfig } from 'vite'; +import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; import { describe, expect, it } from 'vitest'; import { makeServerEntryTunnelPlugin, + makeTunnelBasePathPlugin, makeTunnelRoutePlugin, normalizeTunnelRouteOptions, } from '../../src/vite/tunnelRoute'; @@ -47,8 +48,11 @@ describe('tunnelRoute vite plugin', () => { describe('server entry tunnel vite plugin', () => { const USER_ENTRY = '/app/src/server.ts'; - function setup(tunnelRoute?: Parameters[0]): (id: string) => unknown { - const plugin = makeServerEntryTunnelPlugin(tunnelRoute); + 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; @@ -60,7 +64,7 @@ describe('server entry tunnel vite plugin', () => { 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)).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', @@ -78,13 +82,45 @@ describe('server entry tunnel vite plugin', () => { }); it('passes the build-time options when `tunnelRoute` derives the path from the DSN', () => { - expect(setup(true)(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {});'); + 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"]});', + 'wrapServerEntryWithSentryTunnel(entry, {"allowedDsns":["https://public@o0.ingest.sentry.io/0"]}, "");', ); }); it('serves a static `tunnelRoute` path from the wrapper too', () => { - expect(setup('/monitor')(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {"path":"/monitor"});'); + expect(setup('/monitor')(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {"path":"/monitor"}, "");'); + }); + + it('passes the base path Start routes on to the wrapper', () => { + expect(setup(undefined, '/app')(USER_ENTRY)).toContain( + 'wrapServerEntryWithSentryTunnel(entry, undefined, "/app");', + ); + }); +}); + +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); + + return { define: result.define, basePath: getBasePath() }; + } + + 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', + }); + }); + + 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('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(''); }); }); From 07ee6b2c962aa2703b4ecde866e9cc0951d3d9fa Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 14:50:04 -0400 Subject: [PATCH 03/22] fix(tanstackstart-react): Drop the tunnel's spans from the Vite dev server too --- .../tanstackstart-react/package.json | 14 ++++++- .../src/vite/tunnelRoute.ts | 14 ++++++- .../test/vite/tunnelRoute.test.ts | 38 ++++++++++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) 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 420ed34e0ecf..73689fb42c70 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -23,7 +23,9 @@ "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-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-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": { @@ -82,6 +84,16 @@ "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/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 048c85fa470f..0fab1073a862 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,5 +1,7 @@ import type { TunnelOptions } from '@sentry/core'; +import { getClient, getTunnelPaths, setManagedTunnelBasePath } from '@sentry/core'; import type { Plugin } from 'vite'; +import { registerSentryServerTunnelRoute } from '../server/tunnelRoute'; export type TunnelRouteOptions = | true @@ -132,7 +134,8 @@ export function makeServerEntryTunnelPlugin( tunnelRoute: TunnelRouteOptions | undefined, getBasePath: () => string = () => '', ): Plugin { - const tunnelOptions = tunnelRoute ? JSON.stringify(normalizeTunnelRouteOptions(tunnelRoute)) : 'undefined'; + const normalizedTunnelOptions = tunnelRoute ? normalizeTunnelRouteOptions(tunnelRoute) : undefined; + const tunnelOptions = normalizedTunnelOptions ? JSON.stringify(normalizedTunnelOptions) : 'undefined'; const serverEntryIds = new Set(); return { @@ -145,6 +148,15 @@ export function makeServerEntryTunnelPlugin( serverEntryIds.add(normalizePath(serverEntry.replacement)); } }, + // `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) { const normalizedId = normalizePath(id); if (!serverEntryIds.has(normalizedId) && !DEFAULT_SERVER_ENTRY.test(normalizedId)) { diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index ba14bf21a07e..238f71886e60 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -1,5 +1,7 @@ +import type { Client } from '@sentry/core'; +import { getCurrentScope, getTunnelPath, makeDsn } from '@sentry/core'; import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { makeServerEntryTunnelPlugin, makeTunnelBasePathPlugin, @@ -124,3 +126,37 @@ describe('tunnel base path vite plugin', () => { expect(resolve({}).basePath).toBe(''); }); }); + +describe('server entry tunnel vite plugin in `vite dev`', () => { + const DSN = 'https://public@o1.ingest.sentry.io/1337'; + + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + 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; + } + + it("drops the dev server's spans for a `tunnelRoute` under the base path", () => { + const options = setClient(undefined); + const plugin = makeServerEntryTunnelPlugin(true, () => '/app'); + + (plugin.configureServer as () => void)(); + + const [filter] = options.ignoreSpans as { attributes: Record }[]; + expect(filter?.attributes['url.path']?.test(`/app${getTunnelPath(DSN)}`)).toBe(true); + }); + + 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)(); + + expect(options._managedTunnel).toEqual({ basePath: '/app' }); + }); +}); From 28acf344012067a446187d5048f2a02c8c67f1c2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 17:01:49 -0400 Subject: [PATCH 04/22] fix(tanstackstart-react): Normalize the tunnel base path with the core util --- packages/tanstackstart-react/src/vite/tunnelRoute.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 0fab1073a862..74d14132a6af 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,5 +1,5 @@ import type { TunnelOptions } from '@sentry/core'; -import { getClient, getTunnelPaths, setManagedTunnelBasePath } from '@sentry/core'; +import { getClient, getTunnelPaths, normalizeTunnelBasePath, setManagedTunnelBasePath } from '@sentry/core'; import type { Plugin } from 'vite'; import { registerSentryServerTunnelRoute } from '../server/tunnelRoute'; @@ -88,7 +88,7 @@ export function makeTunnelBasePathPlugin(): { plugin: Plugin; getBasePath: () => enforce: 'post', config(config) { const startBasePath = config.define?.[START_BASE_PATH_DEFINE]; - basePath = normalizeBasePath( + basePath = normalizeTunnelBasePath( typeof startBasePath === 'string' ? parseDefine(startBasePath) : getRouterBasePathFromViteBase(config.base), ); @@ -114,12 +114,6 @@ function getRouterBasePathFromViteBase(base: string | undefined): string | undef return base && !/^[a-z][a-z\d+.-]*:/i.test(base) && !base.startsWith('//') ? base : undefined; } -function normalizeBasePath(basePath: string | undefined): string { - const trimmed = (basePath ?? '').replace(/^\.?\/+|\/+$/g, ''); - - return trimmed ? `/${trimmed}` : ''; -} - 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`. From 998046cb305ee10cdb6ac6a4d851f67e5fe2d6dc Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 17 Oct 2026 21:08:09 -0400 Subject: [PATCH 05/22] fix(tanstackstart-react): Pass a Worker's env and ctx through the tunnel's server entry wrapper --- .../src/server.ts | 13 +++++++++++- .../tests/tunnel.test.ts | 19 +++++++++++++++++ .../vite.config.ts | 9 +++++++- .../src/server/tunnelRoute.ts | 11 ++++++++-- .../wrapServerEntryWithSentryTunnel.test.ts | 21 +++++++++++++++++++ 5 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts 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/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 8340c06ac9d3..11d52796f1ca 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -124,9 +124,16 @@ export function wrapServerEntryWithSentryTunnel( 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, - fetch(request: Request, opts?: unknown) { + // 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); @@ -134,7 +141,7 @@ export function wrapServerEntryWithSentryTunnel( return tunnelResponse; } - return entry.fetch(request, opts); + return entry.fetch(request, ...rest); }, }; } diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts index 4fa539ae296e..94605d4f3b6f 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -66,6 +66,27 @@ describe('wrapServerEntryWithSentryTunnel', () => { 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(); From 1a03d842950245a6d4376a8bb9a5eef5b283e093 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 22:37:29 -0400 Subject: [PATCH 06/22] fix(tanstackstart-react): Fail the build when a fixed tunnelRoute collides with an app route, as on develop --- .../src/vite/tunnelRoute.ts | 20 +++++++++++++++++ .../test/vite/tunnelRoute.test.ts | 22 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 74d14132a6af..5e370809b08a 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -54,6 +54,15 @@ export function normalizeTunnelRouteOptions(options: TunnelRouteOptions): Tunnel }; } +// `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, tunnelPath: string): boolean { + const literals = [`'${tunnelPath}'`, `"${tunnelPath}"`]; + return ROUTE_CONFLICT_KEYS.some(key => literals.some(literal => source.includes(`${key}: ${literal}`))); +} + /** * Tells the client SDK which tunnel to use. The server entry wrapper serves it. */ @@ -70,6 +79,17 @@ export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boole config() { return { define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: path ? JSON.stringify(path) : 'true' } }; }, + // The server entry wrapper answers POSTs to a fixed path before Start's routing, so an app route on that path + // would silently lose them. + transform(source, id) { + if (path && /[\\/]routeTree\.gen\.ts$/.test(id) && hasRouteConflict(source, path)) { + throw new Error( + `[@sentry/tanstackstart-react] Cannot register managed tunnel route "${path}" because an existing TanStack Start route already uses that path.`, + ); + } + + return null; + }, }; } diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 238f71886e60..e0f734de68a2 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -45,6 +45,28 @@ describe('tunnelRoute vite plugin', () => { define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: 'true' }, }); }); + + it('fails when a static tunnel route conflicts with an existing route', () => { + const transform = (path: string, source: string): unknown => + (makeTunnelRoutePlugin(path).transform as (source: string, id: string) => unknown)( + source, + '/app/src/routeTree.gen.ts', + ); + const singleQuoted = "const MonitorRoute = MonitorRouteImport.update({\n id: '/monitor',\n path: '/monitor',"; + const doubleQuoted = singleQuoted.replace(/'/g, '"'); + + expect(() => transform('/monitor', singleQuoted)).toThrow( + 'Cannot register managed tunnel route "/monitor" because an existing TanStack Start route already uses that path.', + ); + expect(() => transform('/monitor', doubleQuoted)).toThrow('already uses that path'); + expect(transform('/other', singleQuoted)).toBeNull(); + expect( + (makeTunnelRoutePlugin(true).transform as (s: string, id: string) => unknown)( + singleQuoted, + '/app/src/routeTree.gen.ts', + ), + ).toBeNull(); + }); }); describe('server entry tunnel vite plugin', () => { From 92f4f82bda09b83c95fc4b3a48c822e9a718e277 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:29:20 -0400 Subject: [PATCH 07/22] docs(tanstackstart-react): Describe how tunnelRoute is served now --- .../tanstackstart-react/src/vite/sentryTanstackStart.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index ca8511da0aa6..6d56f81a63e7 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -37,14 +37,13 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { /** * 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. + * The server entry answers the tunnel before Start's request middleware and routing, and the resulting path is + * applied as the default `tunnel` option on the client. * * You can pass: * - `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. + * - `{ allowedDsns, path }` for full control. The DSN of the active server Sentry client is always allowed too. * * 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. From 7351b68d28c58566e6b576c3110e1d31264b1397 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:40:21 -0400 Subject: [PATCH 08/22] ref(tanstackstart-react): Enable tunnelRoute on the client like init and serve it from there --- .../src/server/tunnelRoute.ts | 39 +++++++++---------- .../src/vite/tunnelRoute.ts | 7 ++-- .../wrapServerEntryWithSentryTunnel.test.ts | 23 +++++++---- 3 files changed, 38 insertions(+), 31 deletions(-) diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 11d52796f1ca..94d323ef1bab 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -2,11 +2,12 @@ import { URL_PATH } from '@sentry/conventions/attributes'; import type { TunnelOptions } from '@sentry/core'; import { dsnToString, + enableManagedTunnel, escapeStringForRegex, getClient, - getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, + setManagedTunnelBasePath, } from '@sentry/core'; import type { ServerEntry } from './wrapFetchWithSentry'; @@ -104,25 +105,28 @@ 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. + * to the app's server entry automatically, with its `tunnelRoute` and the base path Start routes on. + * + * `tunnelRoute` enables the managed tunnel on the client the same way `Sentry.init({ tunnel })` does, unless that + * `init` already enabled one, and the tunnel is then served from the client's config like in every other SDK. */ export function wrapServerEntryWithSentryTunnel( entry: T, - options?: TunnelOptions, + tunnelRoute?: 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; + // Requests still carry Start's base path here, so the tunnel is served under it. + const applyToClient = (): void => { + const client = getClient(); + if (tunnelRoute) { + enableManagedTunnel(client, basePath ? { basePath, ...tunnelRoute } : tunnelRoute); + } + setManagedTunnelBasePath(client, basePath); }; - // 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}`), - ); + // `init` usually runs from `--import` before the server entry loads, so the tunnel's spans are dropped from the + // first request on. On Cloudflare the client is created per request, which this repeats for. + applyToClient(); // 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. @@ -134,14 +138,9 @@ export function wrapServerEntryWithSentryTunnel( ...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; - } + applyToClient(); - return entry.fetch(request, ...rest); + return handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest); }, }; } diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 5e370809b08a..e539958aff05 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,7 +1,6 @@ import type { TunnelOptions } from '@sentry/core'; -import { getClient, getTunnelPaths, normalizeTunnelBasePath, setManagedTunnelBasePath } from '@sentry/core'; +import { enableManagedTunnel, getClient, normalizeTunnelBasePath, setManagedTunnelBasePath } from '@sentry/core'; import type { Plugin } from 'vite'; -import { registerSentryServerTunnelRoute } from '../server/tunnelRoute'; export type TunnelRouteOptions = | true @@ -166,10 +165,10 @@ export function makeServerEntryTunnelPlugin( // `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}`)); + enableManagedTunnel(getClient(), basePath ? { basePath, ...normalizedTunnelOptions } : normalizedTunnelOptions); } + setManagedTunnelBasePath(getClient(), basePath); }, load(id) { const normalizedId = normalizePath(id); diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts index 94605d4f3b6f..a04bb8558802 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -87,16 +87,13 @@ describe('wrapServerEntryWithSentryTunnel', () => { 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({}); + it('enables `tunnelRoute` on the client like `init` would, dropping its spans from the start', () => { + setClient(undefined); const options = getCurrentScope().getClient()!.getOptions(); - freshWrap({ fetch: appFetch }); + wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}); - expect(options.ignoreSpans).toEqual([{ attributes: { 'url.path': expect.any(RegExp) } }]); + expect(options._managedTunnel).toEqual({}); expect( (options.ignoreSpans![0] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test( getTunnelPath(DSN)!, @@ -104,6 +101,18 @@ describe('wrapServerEntryWithSentryTunnel', () => { ).toBe(true); }); + it('keeps the managed tunnel `init` enabled over `tunnelRoute`', async () => { + setClient({ path: '/from-init' }); + const options = getCurrentScope().getClient()!.getOptions(); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, { path: '/from-build' }).fetch( + makeEnvelopeRequest('http://localhost:3000/from-init'), + ); + + expect(options._managedTunnel).toEqual({ path: '/from-init' }); + expect(await response.text()).toBe('forwarded'); + }); + it('answers the tunnel under the base path Start routes on, and only there', async () => { setClient({}); const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); From 2a72e60044fd49bfb2ef4e3994bd3c43d08ba222 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:56:54 -0400 Subject: [PATCH 09/22] ref(tanstackstart-react): Report tunnelRoute and the base path once with setManagedTunnelDefaults --- .../src/server/tunnelRoute.ts | 32 ++++--------------- .../src/vite/tunnelRoute.ts | 9 ++---- .../wrapServerEntryWithSentryTunnel.test.ts | 2 ++ 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 94d323ef1bab..dc5cdb82ee58 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -1,14 +1,7 @@ import { URL_PATH } from '@sentry/conventions/attributes'; import type { TunnelOptions } from '@sentry/core'; -import { - dsnToString, - enableManagedTunnel, - escapeStringForRegex, - getClient, - handleTunnelRequest, - handleTunnelRequestIfMatched, - setManagedTunnelBasePath, -} from '@sentry/core'; +import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; +import { handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; import type { ServerEntry } from './wrapFetchWithSentry'; const registeredTunnelRoutePaths = new Set(); @@ -107,26 +100,17 @@ 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 its `tunnelRoute` and the base path Start routes on. * - * `tunnelRoute` enables the managed tunnel on the client the same way `Sentry.init({ tunnel })` does, unless that - * `init` already enabled one, and the tunnel is then served from the client's config like in every other SDK. + * `tunnelRoute` enables the managed tunnel on every server client the same way `Sentry.init({ tunnel })` does, unless + * that `init` already enabled one, and the tunnel is then served from the client's config like in every other SDK. */ export function wrapServerEntryWithSentryTunnel( entry: T, tunnelRoute?: TunnelOptions, basePath?: string, ): T { - // Requests still carry Start's base path here, so the tunnel is served under it. - const applyToClient = (): void => { - const client = getClient(); - if (tunnelRoute) { - enableManagedTunnel(client, basePath ? { basePath, ...tunnelRoute } : tunnelRoute); - } - setManagedTunnelBasePath(client, basePath); - }; - - // `init` usually runs from `--import` before the server entry loads, so the tunnel's spans are dropped from the - // first request on. On Cloudflare the client is created per request, which this repeats for. - applyToClient(); + // Runs when the server entry loads, before any request. Requests still carry Start's base path here, so the tunnel + // is served under it. + setManagedTunnelDefaults({ tunnel: tunnelRoute, basePath }); // 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. @@ -138,8 +122,6 @@ export function wrapServerEntryWithSentryTunnel( ...entry, // Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush. fetch(request: Request, ...rest: unknown[]) { - applyToClient(); - return handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest); }, }; diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index e539958aff05..56cb12a90cc5 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,5 +1,6 @@ import type { TunnelOptions } from '@sentry/core'; -import { enableManagedTunnel, getClient, normalizeTunnelBasePath, setManagedTunnelBasePath } from '@sentry/core'; +import { normalizeTunnelBasePath } from '@sentry/core'; +import { setManagedTunnelDefaults } from '@sentry/core/server'; import type { Plugin } from 'vite'; export type TunnelRouteOptions = @@ -164,11 +165,7 @@ export function makeServerEntryTunnelPlugin( // `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(); - if (normalizedTunnelOptions) { - enableManagedTunnel(getClient(), basePath ? { basePath, ...normalizedTunnelOptions } : normalizedTunnelOptions); - } - setManagedTunnelBasePath(getClient(), basePath); + setManagedTunnelDefaults({ tunnel: normalizedTunnelOptions, basePath: getBasePath() }); }, load(id) { const normalizedId = normalizePath(id); diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts index a04bb8558802..1c299a135705 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -1,5 +1,6 @@ import type { Client } from '@sentry/core'; import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { _INTERNAL_resetManagedTunnelDefaults } from '@sentry/core/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { wrapServerEntryWithSentryTunnel } from '../../src/server/tunnelRoute'; @@ -25,6 +26,7 @@ describe('wrapServerEntryWithSentryTunnel', () => { afterEach(() => { getCurrentScope().setClient(undefined); + _INTERNAL_resetManagedTunnelDefaults(); vi.unstubAllGlobals(); vi.clearAllMocks(); }); From 668962bd27828506bdd0eae862fbad7e2da6990f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 14:35:05 -0400 Subject: [PATCH 10/22] test(tanstackstart-react): Send Content-Length with the tunnel test requests The tunnel handler now answers a request without one with a 411. --- .../test/server/wrapServerEntryWithSentryTunnel.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts index 1c299a135705..74f419ceff04 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -8,7 +8,9 @@ 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 }); + const body = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; + + return new Request(url, { method: 'POST', body, headers: { 'content-length': String(body.length) } }); } function setClient(managedTunnel: object | undefined): void { From 9f15930c990d71f72ee85c8eb8eaaf244eddb117 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 15:56:26 -0400 Subject: [PATCH 11/22] test(tanstackstart-react): Drop the tunnel-dev and tunnel-object variants, which other variants already cover --- .../tanstackstart-react/package.json | 13 ------------- .../tanstackstart-react/tests/tunnel.test.ts | 6 ++---- .../tanstackstart-react/vite.config.ts | 2 -- 3 files changed, 2 insertions(+), 19 deletions(-) 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 73689fb42c70..ed28dff4d1ee 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -13,7 +13,6 @@ "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:hoisted": "pnpm install --config.node-linker=hoisted && 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", @@ -22,9 +21,7 @@ "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-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", @@ -75,21 +72,11 @@ "build-command": "pnpm test:build:tunnel-custom", "assert-command": "pnpm test:assert:tunnel-custom" }, - { - "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", 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 e14011b4a557..bd4268f80f9d 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 @@ -9,10 +9,8 @@ const expectedTunnelPathMatcher = ? '/monitor' : tunnelRouteMode === 'custom' ? '/custom-monitor' - : tunnelRouteMode === 'object' - ? '/object-monitor' - : // The path `tunnelRoute: true` derives from the DSN. - new RegExp(`^${basePath}/[a-z0-9]{1,7}$`); + : // 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'); 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 9e0813a8d701..0c5163b3686f 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 @@ -26,8 +26,6 @@ function resolveTunnelRouteOption() { return true; case 'static': return '/monitor'; - case 'object': - return { path: '/object-monitor', allowedDsns: [appDsn] }; default: return undefined; } From 32ff8290f82eb08314773342e32e7450b1c52b6f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 15:56:26 -0400 Subject: [PATCH 12/22] ref(tanstackstart-react): Let core skip an empty base path, and drop the client tests that repeat core's --- .../tanstackstart-react/src/client/tunnelRoute.ts | 2 +- .../test/client/tunnelRoute.test.ts | 15 ++++----------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/tanstackstart-react/src/client/tunnelRoute.ts b/packages/tanstackstart-react/src/client/tunnelRoute.ts index d5f92d76f435..020ed8a4a44a 100644 --- a/packages/tanstackstart-react/src/client/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/client/tunnelRoute.ts @@ -16,7 +16,7 @@ export function applyTunnelRouteOption(options: ReactBrowserOptions): void { const basePath = typeof __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__ : ''; - options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); + options.tunnel = applyTunnelDefaults(options.tunnel, { basePath }); } function applyManagedTunnelRoute(options: ReactBrowserOptions): void { diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts index f1f01669c999..8c883d64581a 100644 --- a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -86,22 +86,15 @@ describe('applyTunnelRouteOption()', () => { expect(options.tunnel).toBeUndefined(); }); - it('serves the tunnel under the base path Start routes on, unless the user set one', async () => { + it('serves the tunnel under the base path Start routes on', 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 options: BrowserOptions = { tunnel: true }; + applyTunnelRouteOption(options); - const url: BrowserOptions = { tunnel: 'https://example.com/tunnel' }; - applyTunnelRouteOption(url); - expect(url.tunnel).toBe('https://example.com/tunnel'); + expect(options.tunnel).toEqual({ basePath: '/app' }); }); it('serves a static `tunnelRoute` under the base path too', async () => { From 8c3eeaa0aa5fee41be3242d8285e3bf3f94aca5f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 22 Oct 2026 10:00:43 -0400 Subject: [PATCH 13/22] ref: Import the internal tunnel helpers under their `_INTERNAL_` names --- packages/tanstackstart-react/src/client/tunnelRoute.ts | 2 +- packages/tanstackstart-react/src/server/tunnelRoute.ts | 5 ++++- packages/tanstackstart-react/src/vite/tunnelRoute.ts | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/tanstackstart-react/src/client/tunnelRoute.ts b/packages/tanstackstart-react/src/client/tunnelRoute.ts index 020ed8a4a44a..f4a676b63d70 100644 --- a/packages/tanstackstart-react/src/client/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/client/tunnelRoute.ts @@ -1,4 +1,4 @@ -import { applyTunnelDefaults, consoleSandbox } from '@sentry/core'; +import { _INTERNAL_applyTunnelDefaults as applyTunnelDefaults, consoleSandbox } from '@sentry/core'; import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react'; declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: string | true | undefined; diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index dc5cdb82ee58..c4c5d2b1b4b5 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -1,7 +1,10 @@ import { URL_PATH } from '@sentry/conventions/attributes'; import type { TunnelOptions } from '@sentry/core'; import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; -import { handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; +import { + handleTunnelRequestIfMatched, + _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, +} from '@sentry/core/server'; import type { ServerEntry } from './wrapFetchWithSentry'; const registeredTunnelRoutePaths = new Set(); diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 56cb12a90cc5..d45a96831398 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,6 +1,6 @@ import type { TunnelOptions } from '@sentry/core'; -import { normalizeTunnelBasePath } from '@sentry/core'; -import { setManagedTunnelDefaults } from '@sentry/core/server'; +import { _INTERNAL_normalizeTunnelBasePath as normalizeTunnelBasePath } from '@sentry/core'; +import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; import type { Plugin } from 'vite'; export type TunnelRouteOptions = From 1cde77e09594806308bd3284e63f137c0c2820bd Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 22 Oct 2026 10:26:05 -0400 Subject: [PATCH 14/22] ref(tanstackstart-react): Type the tunnel route options as server tunnel options --- packages/tanstackstart-react/src/server/tunnelRoute.ts | 4 ++-- packages/tanstackstart-react/src/vite/tunnelRoute.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index c4c5d2b1b4b5..7fad6dcd8223 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -1,5 +1,5 @@ import { URL_PATH } from '@sentry/conventions/attributes'; -import type { TunnelOptions } from '@sentry/core'; +import type { ServerTunnelOptions } from '@sentry/core'; import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; import { handleTunnelRequestIfMatched, @@ -108,7 +108,7 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) */ export function wrapServerEntryWithSentryTunnel( entry: T, - tunnelRoute?: TunnelOptions, + tunnelRoute?: ServerTunnelOptions, basePath?: string, ): T { // Runs when the server entry loads, before any request. Requests still carry Start's base path here, so the tunnel diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index d45a96831398..b68bd85369e5 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,4 +1,4 @@ -import type { TunnelOptions } from '@sentry/core'; +import type { ServerTunnelOptions } from '@sentry/core'; import { _INTERNAL_normalizeTunnelBasePath as normalizeTunnelBasePath } from '@sentry/core'; import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; import type { Plugin } from 'vite'; @@ -33,7 +33,7 @@ function validateStaticPath(path: string): void { /** * Maps `tunnelRoute` onto the options `Sentry.init({ tunnel })` takes, so both are served the same way. */ -export function normalizeTunnelRouteOptions(options: TunnelRouteOptions): TunnelOptions { +export function normalizeTunnelRouteOptions(options: TunnelRouteOptions): ServerTunnelOptions { if (options === true) { return {}; } From 45af1296b4d3feffad6cabd62f9570beced0204e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 10:23:11 -0400 Subject: [PATCH 15/22] test: Stop checking the removed `X-Sentry-Tunnel` header --- .../tanstackstart-react-cloudflare/tests/tunnel.test.ts | 1 - 1 file changed, 1 deletion(-) 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 index 82060b30bd13..cb96639c8bf5 100644 --- 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 @@ -14,6 +14,5 @@ test('answers a fixed tunnelRoute with the DSN that `withSentry` initializes', a headers: { 'Content-Type': 'application/x-sentry-envelope' }, }); - expect(response.headers()['x-sentry-tunnel']).toBe('1'); expect(response.status()).toBe(403); }); From 183bdc0c5aed890cfad92c49de3bd3595176dcab Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 11:21:16 -0400 Subject: [PATCH 16/22] ref(tanstackstart-react): Pass the tunnel forwarder along with `tunnelRoute`, and use a static `tunnelRoute` as written --- .../src/server/tunnelRoute.ts | 7 ++++--- .../src/vite/tunnelRoute.ts | 11 ++++++++-- .../test/client/tunnelRoute.test.ts | 4 ++-- .../wrapServerEntryWithSentryTunnel.test.ts | 21 ++++++++++++------- .../test/vite/tunnelRoute.test.ts | 13 +++++++++--- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 7fad6dcd8223..479377c24e2e 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -2,6 +2,7 @@ import { URL_PATH } from '@sentry/conventions/attributes'; import type { ServerTunnelOptions } from '@sentry/core'; import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; import { + _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, handleTunnelRequestIfMatched, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; @@ -103,8 +104,8 @@ 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 its `tunnelRoute` and the base path Start routes on. * - * `tunnelRoute` enables the managed tunnel on every server client the same way `Sentry.init({ tunnel })` does, unless - * that `init` already enabled one, and the tunnel is then served from the client's config like in every other SDK. + * `tunnelRoute` enables the managed tunnel on every server client the same way `serveTunnelIntegration` does, unless + * that integration is set up too, and the tunnel is then served from the client's config like in every other SDK. */ export function wrapServerEntryWithSentryTunnel( entry: T, @@ -113,7 +114,7 @@ export function wrapServerEntryWithSentryTunnel( ): T { // Runs when the server entry loads, before any request. Requests still carry Start's base path here, so the tunnel // is served under it. - setManagedTunnelDefaults({ tunnel: tunnelRoute, basePath }); + setManagedTunnelDefaults({ tunnel: tunnelRoute, forward: forwardTunnelRequest, basePath }); // 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. diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index b68bd85369e5..3c1069f227ef 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,6 +1,9 @@ import type { ServerTunnelOptions } from '@sentry/core'; import { _INTERNAL_normalizeTunnelBasePath as normalizeTunnelBasePath } from '@sentry/core'; -import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; +import { + _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, + _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, +} from '@sentry/core/server'; import type { Plugin } from 'vite'; export type TunnelRouteOptions = @@ -165,7 +168,11 @@ export function makeServerEntryTunnelPlugin( // `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() { - setManagedTunnelDefaults({ tunnel: normalizedTunnelOptions, basePath: getBasePath() }); + setManagedTunnelDefaults({ + tunnel: normalizedTunnelOptions, + forward: forwardTunnelRequest, + basePath: getBasePath(), + }); }, load(id) { const normalizedId = normalizePath(id); diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts index 8c883d64581a..924c8a388d61 100644 --- a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -97,7 +97,7 @@ describe('applyTunnelRouteOption()', () => { expect(options.tunnel).toEqual({ basePath: '/app' }); }); - it('serves a static `tunnelRoute` under the base path too', async () => { + it('uses a static `tunnelRoute` as written, without the base path', async () => { vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__', '/app'); @@ -106,6 +106,6 @@ describe('applyTunnelRouteOption()', () => { const options: BrowserOptions = {}; applyTunnelRouteOption(options); - expect(options.tunnel).toEqual({ basePath: '/app', path: '/managed-tunnel' }); + expect(options.tunnel).toBe('/managed-tunnel'); }); }); diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts index 74f419ceff04..63824c5a74aa 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -1,6 +1,6 @@ import type { Client } from '@sentry/core'; import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; -import { _INTERNAL_resetManagedTunnelDefaults } from '@sentry/core/server'; +import { _INTERNAL_resetManagedTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { wrapServerEntryWithSentryTunnel } from '../../src/server/tunnelRoute'; @@ -13,9 +13,14 @@ function makeEnvelopeRequest(url: string): Request { return new Request(url, { method: 'POST', body, headers: { 'content-length': String(body.length) } }); } +/** Sets a client, with `serveTunnelIntegration` set up when `managedTunnel` is given. */ function setClient(managedTunnel: object | undefined): void { - const options = { dsn: DSN, _managedTunnel: managedTunnel, ignoreSpans: [] }; - getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); + const options = { dsn: DSN, ignoreSpans: [] }; + const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; + getCurrentScope().setClient(client); + if (managedTunnel) { + serveTunnelIntegration(managedTunnel).setup!(client); + } } describe('wrapServerEntryWithSentryTunnel', () => { @@ -33,7 +38,7 @@ describe('wrapServerEntryWithSentryTunnel', () => { vi.clearAllMocks(); }); - it('answers the tunnel path when `init` enabled the managed tunnel, without calling the app', async () => { + it('answers the tunnel path when `serveTunnelIntegration` is set up, without calling the app', async () => { setClient({}); const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(makeEnvelopeRequest(TUNNEL_URL)); @@ -51,7 +56,7 @@ describe('wrapServerEntryWithSentryTunnel', () => { expect(sentryFetch).not.toHaveBeenCalled(); }); - it('serves the tunnel with build-time options even when `init` did not enable it', async () => { + it('serves the tunnel with build-time options even without `serveTunnelIntegration`', async () => { setClient(undefined); const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}).fetch( @@ -91,7 +96,7 @@ describe('wrapServerEntryWithSentryTunnel', () => { expect(wrapServerEntryWithSentryTunnel(Worker as unknown as { fetch: () => Response })).toBe(Worker); }); - it('enables `tunnelRoute` on the client like `init` would, dropping its spans from the start', () => { + it('enables `tunnelRoute` on the client like `serveTunnelIntegration` would, dropping its spans from the start', () => { setClient(undefined); const options = getCurrentScope().getClient()!.getOptions(); @@ -105,7 +110,7 @@ describe('wrapServerEntryWithSentryTunnel', () => { ).toBe(true); }); - it('keeps the managed tunnel `init` enabled over `tunnelRoute`', async () => { + it('keeps the options of `serveTunnelIntegration` over `tunnelRoute`', async () => { setClient({ path: '/from-init' }); const options = getCurrentScope().getClient()!.getOptions(); @@ -127,7 +132,7 @@ describe('wrapServerEntryWithSentryTunnel', () => { expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('app'); }); - it('keeps a base path the server `init` set', async () => { + it('keeps a base path `serveTunnelIntegration` set', async () => { setClient({ basePath: '/custom' }); const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index e0f734de68a2..b2b750c45e98 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -1,5 +1,6 @@ import type { Client } from '@sentry/core'; import { getCurrentScope, getTunnelPath, makeDsn } from '@sentry/core'; +import { _INTERNAL_resetManagedTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; import { afterEach, describe, expect, it } from 'vitest'; import { @@ -154,11 +155,17 @@ describe('server entry tunnel vite plugin in `vite dev`', () => { afterEach(() => { getCurrentScope().setClient(undefined); + _INTERNAL_resetManagedTunnelDefaults(); }); + /** Sets a client, with `serveTunnelIntegration` set up when `managedTunnel` is given. */ 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); + const options: Record = { dsn: DSN, ignoreSpans: [] as unknown[] }; + const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; + getCurrentScope().setClient(client); + if (managedTunnel) { + serveTunnelIntegration(managedTunnel).setup!(client); + } return options; } @@ -173,7 +180,7 @@ describe('server entry tunnel vite plugin in `vite dev`', () => { expect(filter?.attributes['url.path']?.test(`/app${getTunnelPath(DSN)}`)).toBe(true); }); - it("serves the dev server's managed tunnel from `init` under the base path", () => { + it("serves the dev server's managed tunnel from `serveTunnelIntegration` under the base path", () => { const options = setClient({}); const plugin = makeServerEntryTunnelPlugin(undefined, () => '/app'); From 3c1bb08f92a696049bbbbca4e407ea67009b468c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 11:32:06 -0400 Subject: [PATCH 17/22] docs(tanstackstart-react): Point `tunnelRoute` at `serveTunnelIntegration` options --- packages/tanstackstart-react/src/vite/tunnelRoute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 3c1069f227ef..e27cb788cb21 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -34,7 +34,7 @@ function validateStaticPath(path: string): void { } /** - * Maps `tunnelRoute` onto the options `Sentry.init({ tunnel })` takes, so both are served the same way. + * Maps `tunnelRoute` onto the options `serveTunnelIntegration` takes, so both are served the same way. */ export function normalizeTunnelRouteOptions(options: TunnelRouteOptions): ServerTunnelOptions { if (options === true) { From 649ead6f44f21ff77fd2fad29144f6002684ce06 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 12:51:48 -0400 Subject: [PATCH 18/22] fix(tanstackstart-react): Resolve the SDK from the app when wrapping the default server entry The default entry lives in `@tanstack/react-start`, which does not depend on the SDK, so the bare import failed when the SDK is not hoisted to where it can see it, like with pnpm's global virtual store or Yarn PnP. --- .../src/vite/tunnelRoute.ts | 12 +++- .../test/vite/tunnelRoute.test.ts | 68 +++++++++++++------ 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index e27cb788cb21..5e6c2c272d1a 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -137,6 +137,7 @@ function getRouterBasePathFromViteBase(base: string | undefined): string | undef return base && !/^[a-z][a-z\d+.-]*:/i.test(base) && !base.startsWith('//') ? base : undefined; } +const SDK_PACKAGE = '@sentry/tanstackstart-react'; 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`. @@ -154,11 +155,13 @@ export function makeServerEntryTunnelPlugin( const normalizedTunnelOptions = tunnelRoute ? normalizeTunnelRouteOptions(tunnelRoute) : undefined; const tunnelOptions = normalizedTunnelOptions ? JSON.stringify(normalizedTunnelOptions) : 'undefined'; const serverEntryIds = new Set(); + let root = ''; return { name: 'sentry-tanstackstart-server-entry-tunnel', enforce: 'pre', configResolved(config) { + root = normalizePath(config.root ?? ''); const aliases = Array.isArray(config.resolve.alias) ? config.resolve.alias : []; const serverEntry = aliases.find(alias => alias.find === SERVER_ENTRY_ALIAS); if (serverEntry) { @@ -174,16 +177,21 @@ export function makeServerEntryTunnelPlugin( basePath: getBasePath(), }); }, - load(id) { + async load(id) { const normalizedId = normalizePath(id); if (!serverEntryIds.has(normalizedId) && !DEFAULT_SERVER_ENTRY.test(normalizedId)) { return null; } const original = JSON.stringify(`${id}${ORIGINAL_SERVER_ENTRY_QUERY}`); + // The default entry lives in `@tanstack/react-start`, which doesn't depend on this SDK, so resolving the import + // from there only works when the SDK is hoisted (not under Yarn PnP or pnpm without hoisting). The app depends + // on it, so a bundled SDK is resolved from the app. An external one keeps its name, which the output resolves. + const resolved = await this.resolve(SDK_PACKAGE, `${root}/package.json`, { skipSelf: true }); + const sdk = resolved && !resolved.external ? resolved.id : SDK_PACKAGE; return `import entry from ${original}; -import { wrapServerEntryWithSentryTunnel } from '@sentry/tanstackstart-react'; +import { wrapServerEntryWithSentryTunnel } from ${JSON.stringify(sdk)}; export * from ${original}; export default wrapServerEntryWithSentryTunnel(entry, ${tunnelOptions}, ${JSON.stringify(getBasePath())}); `; diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index b2b750c45e98..224a41d15ca5 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -2,7 +2,7 @@ import type { Client } from '@sentry/core'; import { getCurrentScope, getTunnelPath, makeDsn } from '@sentry/core'; import { _INTERNAL_resetManagedTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { makeServerEntryTunnelPlugin, makeTunnelBasePathPlugin, @@ -73,55 +73,83 @@ describe('tunnelRoute vite plugin', () => { describe('server entry tunnel vite plugin', () => { const USER_ENTRY = '/app/src/server.ts'; + const SDK_ID = '/app/node_modules/@sentry/tanstackstart-react/build/esm/index.server.js'; + function setup( tunnelRoute?: Parameters[0], basePath = '', - ): (id: string) => unknown { + resolve: (source: string, importer: string) => unknown = () => Promise.resolve({ id: SDK_ID, external: false }), + ): (id: string) => Promise { const plugin = makeServerEntryTunnelPlugin(tunnelRoute, () => basePath); const config = { + root: '/app', 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); + return id => + (plugin.load as Extract unknown>).call( + { resolve } as never, + id, + ) as Promise; } - it('wraps the app server entry and the default entry', () => { + it('wraps the app server entry and the default entry', async () => { 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( + expect(await load(USER_ENTRY)).toContain('export default wrapServerEntryWithSentryTunnel(entry, undefined, "");'); + expect(await load(`${USER_ENTRY}?sentry-tunnel-original`)).toBeNull(); + expect(await load('/app/node_modules/@tanstack/react-start/dist/default-entry/esm/server.js')).toContain( 'wrapServerEntryWithSentryTunnel', ); - expect(load('/app/src/routes/index.tsx')).toBeNull(); + expect( + await load('C:\\app\\node_modules\\@tanstack\\react-start\\dist\\plugin\\default-entry\\server.ts'), + ).toContain('wrapServerEntryWithSentryTunnel'); + expect(await load('/app/src/routes/index.tsx')).toBeNull(); }); - it('re-exports everything from the original server entry', () => { + it('re-exports everything from the original server entry', async () => { const load = setup(); - expect(load(USER_ENTRY)).toContain(`export * from "${USER_ENTRY}?sentry-tunnel-original";`); + expect(await load(USER_ENTRY)).toContain(`export * from "${USER_ENTRY}?sentry-tunnel-original";`); }); - 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( + it('passes the build-time options when `tunnelRoute` derives the path from the DSN', async () => { + expect(await setup(true)(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {}, "");'); + expect(await setup({ allowedDsns: ['https://public@o0.ingest.sentry.io/0'] })(USER_ENTRY)).toContain( 'wrapServerEntryWithSentryTunnel(entry, {"allowedDsns":["https://public@o0.ingest.sentry.io/0"]}, "");', ); }); - it('serves a static `tunnelRoute` path from the wrapper too', () => { - expect(setup('/monitor')(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {"path":"/monitor"}, "");'); + it('serves a static `tunnelRoute` path from the wrapper too', async () => { + expect(await setup('/monitor')(USER_ENTRY)).toContain( + 'wrapServerEntryWithSentryTunnel(entry, {"path":"/monitor"}, "");', + ); }); - it('passes the base path Start routes on to the wrapper', () => { - expect(setup(undefined, '/app')(USER_ENTRY)).toContain( + it('passes the base path Start routes on to the wrapper', async () => { + expect(await setup(undefined, '/app')(USER_ENTRY)).toContain( 'wrapServerEntryWithSentryTunnel(entry, undefined, "/app");', ); }); + + it('imports a bundled SDK from where the app resolves it, which also works without hoisting', async () => { + const resolve = vi.fn(() => Promise.resolve({ id: SDK_ID, external: false })); + const load = setup(undefined, '', resolve); + + expect(await load('/app/node_modules/@tanstack/react-start/dist/default-entry/esm/server.js')).toContain( + `import { wrapServerEntryWithSentryTunnel } from "${SDK_ID}";`, + ); + expect(resolve).toHaveBeenCalledWith('@sentry/tanstackstart-react', '/app/package.json', { skipSelf: true }); + }); + + it('imports an external or unresolved SDK by its name, so no build path ends up in the output', async () => { + const external = setup(undefined, '', () => Promise.resolve({ id: SDK_ID, external: true })); + const unresolved = setup(undefined, '', () => Promise.resolve(null)); + + expect(await external(USER_ENTRY)).toContain(`from "@sentry/tanstackstart-react";`); + expect(await unresolved(USER_ENTRY)).toContain(`from "@sentry/tanstackstart-react";`); + }); }); describe('tunnel base path vite plugin', () => { From 98fa30b07f7fcada1c61de2680e6fe7760c88ab2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 13:03:23 -0400 Subject: [PATCH 19/22] fix(tanstackstart-react): Keep the server entry's prototype when wrapping it for the tunnel --- .../src/server/tunnelRoute.ts | 18 ++++++---- .../wrapServerEntryWithSentryTunnel.test.ts | 33 +++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 479377c24e2e..040bf6df56ee 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -122,11 +122,17 @@ export function wrapServerEntryWithSentryTunnel( return entry; } - return { - ...entry, - // Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush. - fetch(request: Request, ...rest: unknown[]) { - return handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest); + // Keeps the prototype, so a class instance entry keeps its other handlers, like `scheduled` or `queue`. `fetch` is + // defined rather than assigned, which a read-only `fetch` on a frozen prototype would reject. + return Object.create(Object.getPrototypeOf(entry), { + ...Object.getOwnPropertyDescriptors(entry), + fetch: { + // Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush. + value: (request: Request, ...rest: unknown[]) => + handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest), + writable: true, + configurable: true, + enumerable: true, }, - }; + }) as T; } diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts index 63824c5a74aa..9b72154a937c 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -86,6 +86,39 @@ describe('wrapServerEntryWithSentryTunnel', () => { expect(appFetch).toHaveBeenCalledWith(request, env, ctx); }); + it("keeps a class instance entry's prototype handlers, like a Worker's `scheduled`", async () => { + setClient({}); + class Handler { + public fetch(_request: Request): Response { + return new Response('app'); + } + + public scheduled(): string { + return 'scheduled'; + } + } + + const wrapped = wrapServerEntryWithSentryTunnel(new Handler()); + + expect(wrapped.scheduled()).toBe('scheduled'); + expect(wrapped).toBeInstanceOf(Handler); + expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('forwarded'); + }); + + it('wraps an entry whose prototype is frozen', async () => { + setClient({}); + class Handler { + public fetch(_request: Request): Response { + return new Response('app'); + } + } + Object.freeze(Handler.prototype); + + const wrapped = wrapServerEntryWithSentryTunnel(new Handler()); + + expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('forwarded'); + }); + it('returns a class entry, like a `WorkerEntrypoint`, unchanged', () => { class Worker { public fetch(): Response { From 3826f0b591d6d40fa14f36b9625e80eca340ff41 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 13:05:35 -0400 Subject: [PATCH 20/22] ref(tanstackstart-react): Export the server entry wrapper as `_INTERNAL_` --- packages/tanstackstart-react/src/client/index.ts | 4 +++- packages/tanstackstart-react/src/index.types.ts | 2 +- packages/tanstackstart-react/src/server/index.ts | 2 +- packages/tanstackstart-react/src/server/tunnelRoute.ts | 2 ++ packages/tanstackstart-react/src/vite/tunnelRoute.ts | 2 +- packages/tanstackstart-react/test/vite/tunnelRoute.test.ts | 2 +- 6 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index 8e9def78a531..b157b4f31173 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -65,7 +65,9 @@ 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. + * + * @internal */ -export function wrapServerEntryWithSentryTunnel(entry: T, _options?: unknown): T { +export function _INTERNAL_wrapServerEntryWithSentryTunnel(entry: T, _options?: unknown): T { return entry; } diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 3e3e8fe690ef..9b81217a239a 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -49,4 +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; +export declare const _INTERNAL_wrapServerEntryWithSentryTunnel: typeof serverSdk._INTERNAL_wrapServerEntryWithSentryTunnel; diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index 2d96ca364495..cef85cd391c4 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -12,7 +12,7 @@ export { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } from '. export { createSentryTunnelRoute, registerSentryServerTunnelRoute, - wrapServerEntryWithSentryTunnel, + wrapServerEntryWithSentryTunnel as _INTERNAL_wrapServerEntryWithSentryTunnel, } from './tunnelRoute'; /** diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 040bf6df56ee..66ddeaeffe2e 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -106,6 +106,8 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) * * `tunnelRoute` enables the managed tunnel on every server client the same way `serveTunnelIntegration` does, unless * that integration is set up too, and the tunnel is then served from the client's config like in every other SDK. + * + * @internal Only the code the Vite plugin generates calls this, so its parameters can change at any time. */ export function wrapServerEntryWithSentryTunnel( entry: T, diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 5e6c2c272d1a..b0ddcba86632 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -191,7 +191,7 @@ export function makeServerEntryTunnelPlugin( const sdk = resolved && !resolved.external ? resolved.id : SDK_PACKAGE; return `import entry from ${original}; -import { wrapServerEntryWithSentryTunnel } from ${JSON.stringify(sdk)}; +import { _INTERNAL_wrapServerEntryWithSentryTunnel as wrapServerEntryWithSentryTunnel } from ${JSON.stringify(sdk)}; export * from ${original}; export default wrapServerEntryWithSentryTunnel(entry, ${tunnelOptions}, ${JSON.stringify(getBasePath())}); `; diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 224a41d15ca5..2d895e67f249 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -138,7 +138,7 @@ describe('server entry tunnel vite plugin', () => { const load = setup(undefined, '', resolve); expect(await load('/app/node_modules/@tanstack/react-start/dist/default-entry/esm/server.js')).toContain( - `import { wrapServerEntryWithSentryTunnel } from "${SDK_ID}";`, + `import { _INTERNAL_wrapServerEntryWithSentryTunnel as wrapServerEntryWithSentryTunnel } from "${SDK_ID}";`, ); expect(resolve).toHaveBeenCalledWith('@sentry/tanstackstart-react', '/app/package.json', { skipSelf: true }); }); From 9ad8395df930e1232843c6c4a0dd5a047e000f15 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 13:07:11 -0400 Subject: [PATCH 21/22] ref(tanstackstart-react): Require the base path getter of the server entry plugin --- packages/tanstackstart-react/src/vite/tunnelRoute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index b0ddcba86632..cb69c605b918 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -150,7 +150,7 @@ const DEFAULT_SERVER_ENTRY = /\/react-start\/dist\/(?:plugin\/)?default-entry\/( */ export function makeServerEntryTunnelPlugin( tunnelRoute: TunnelRouteOptions | undefined, - getBasePath: () => string = () => '', + getBasePath: () => string, ): Plugin { const normalizedTunnelOptions = tunnelRoute ? normalizeTunnelRouteOptions(tunnelRoute) : undefined; const tunnelOptions = normalizedTunnelOptions ? JSON.stringify(normalizedTunnelOptions) : 'undefined'; From 79f6c8a47538141516650a38b2d16162dbf87c97 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 13:21:43 -0400 Subject: [PATCH 22/22] fix(tanstackstart-react): Keep rejecting non-POST requests to a static `tunnelRoute` with a 405 Without it, Start renders the app for them and runs its root loaders, which #24615 fixed for the route based tunnel. --- .../tanstackstart-react/tests/tunnel.test.ts | 5 +++-- .../tanstackstart-react/src/server/tunnelRoute.ts | 12 ++++++++++-- .../server/wrapServerEntryWithSentryTunnel.test.ts | 13 +++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) 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 bd4268f80f9d..d42510f36173 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 @@ -61,12 +61,13 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa expect(errorEvent.transaction).toBe(`${basePath}/`); }); -test('Only answers POSTs on the managed tunnel path and leaves other methods to the app', async ({ request }) => { +test('Rejects non-POST requests to the managed tunnel route instead of rendering the app', async ({ request }) => { test.skip(tunnelRouteMode !== 'static', 'Requires a known managed tunnel path'); const response = await request.get('/monitor'); - expect(response.status()).toBe(404); + expect(response.status()).toBe(405); + expect(response.headers()['allow']).toBe('POST'); }); function pathnameMatchesTunnelRoute(pathname: string): boolean { diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 66ddeaeffe2e..cd7b8a7187dd 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -124,14 +124,22 @@ export function wrapServerEntryWithSentryTunnel( return entry; } + const fixedPath = tunnelRoute?.path?.replace(/\/$/, ''); + // Keeps the prototype, so a class instance entry keeps its other handlers, like `scheduled` or `queue`. `fetch` is // defined rather than assigned, which a read-only `fetch` on a frozen prototype would reject. return Object.create(Object.getPrototypeOf(entry), { ...Object.getOwnPropertyDescriptors(entry), fetch: { // Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush. - value: (request: Request, ...rest: unknown[]) => - handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest), + value: (request: Request, ...rest: unknown[]) => { + // Start would render the app for any other method on this path, running its root loaders. + if (fixedPath && request.method !== 'POST' && new URL(request.url).pathname.replace(/\/$/, '') === fixedPath) { + return new Response(null, { status: 405, headers: { Allow: 'POST' } }); + } + + return handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest); + }, writable: true, configurable: true, enumerable: true, diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts index 9b72154a937c..63d2e5dc3587 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -66,6 +66,19 @@ describe('wrapServerEntryWithSentryTunnel', () => { expect(await response.text()).toBe('forwarded'); }); + it('rejects other methods on a fixed `tunnelRoute` path instead of rendering the app', async () => { + setClient(undefined); + const entry: { fetch: (request: Request) => Response } = { fetch: appFetch }; + const wrapped = wrapServerEntryWithSentryTunnel(entry, { path: '/monitor' }); + + const response = await wrapped.fetch(new Request('http://localhost:3000/monitor')); + + expect(response.status).toBe(405); + expect(response.headers.get('allow')).toBe('POST'); + expect(appFetch).not.toHaveBeenCalled(); + expect(await (await wrapped.fetch(new Request('http://localhost:3000/other'))).text()).toBe('app'); + }); + it('passes other requests and their options to the app', async () => { setClient({}); const request = makeEnvelopeRequest('http://localhost:3000/api/users');