From 3ee376c39156d87610a604803faee8af849bb8aa Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:13:18 -0400 Subject: [PATCH 1/4] 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 c3359c077d1b..68b3fab7a2af 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -5,7 +5,7 @@ import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; -import { makeTunnelRoutePlugin } from './tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; /** * Build-time options for the Sentry TanStack Start SDK. @@ -40,7 +40,7 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * as the default `tunnel` option on the client. * * You can pass: - * - `true` to generate an opaque route path per dev session or production build. + * - `true` to serve the path derived from the DSN, the same one `Sentry.init({ tunnel: true })` uses. * - `'/custom-path'` to use a fixed static route path. * - `{ allowedDsns, path }` for full control. If `allowedDsns` is omitted or empty, the tunnel route derives the DSN * from the active server Sentry client at runtime. @@ -76,7 +76,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 0715873cdfa5..7d56837f0499 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -2,7 +2,7 @@ import type { Plugin } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeAutoInstrumentMiddlewarePlugin } from '../../src/vite/autoInstrumentMiddleware'; import { sentryTanstackStart, type SentryTanstackStartOptions } from '../../src/vite/sentryTanstackStart'; -import { makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; const mockSourceMapsConfigPlugin: Plugin = { name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', @@ -35,6 +35,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', @@ -67,6 +73,7 @@ vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ vi.mock('../../src/vite/tunnelRoute', () => ({ makeTunnelRoutePlugin: vi.fn(() => mockTunnelRoutePlugin), + makeServerEntryTunnelPlugin: vi.fn(() => mockServerEntryTunnelPlugin), })); describe('sentryTanstackStart()', () => { @@ -85,6 +92,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -97,7 +105,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', () => { @@ -108,7 +116,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', () => { @@ -119,6 +127,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -133,6 +142,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -147,6 +157,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -161,6 +172,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -176,6 +188,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -191,6 +204,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -222,6 +236,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockTunnelRoutePlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -242,6 +257,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 0dfa032a121127a671d83c15e99c0dff96b88577 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:57:05 -0400 Subject: [PATCH 2/4] 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 3b7947f69386..1211fbe38090 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -14,13 +14,15 @@ "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", "test:build:tunnel-object": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest nitro@latest vite@latest @vitejs/plugin-react-swc@latest vite-tsconfig-paths@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test && TEST_ENV=development pnpm playwright test db-drivers", "test:assert": "pnpm test:assert:proxy", "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test", - "test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test" + "test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test tunnel" }, "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { @@ -70,6 +72,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 68b3fab7a2af..bebffcee875b 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -5,7 +5,7 @@ import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; -import { makeServerEntryTunnelPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelBasePathPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; /** * Build-time options for the Sentry TanStack Start SDK. @@ -76,7 +76,12 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - const plugins: Plugin[] = [makeRoutePatternPlugin(), 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 7d56837f0499..02ffa14e8dd1 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -41,6 +41,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', @@ -74,6 +78,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()', () => { @@ -92,6 +97,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -105,7 +111,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', () => { @@ -116,7 +122,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', () => { @@ -127,6 +138,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -142,6 +154,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -157,6 +170,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -172,6 +186,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -188,6 +203,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -204,6 +220,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -236,6 +253,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockTunnelRoutePlugin, mockOrchestrionPlugin, @@ -257,7 +275,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 f8a9833729c1c86d3e98ea4ff2a0887bf37cb52d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 14:50:04 -0400 Subject: [PATCH 3/4] 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 1211fbe38090..08174f015374 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -22,7 +22,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": { @@ -77,6 +79,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 d4c180848c66106e258e034a6bbd23c9f538cbff Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 17:01:49 -0400 Subject: [PATCH 4/4] 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`.