Skip to content

Commit 1d827f5

Browse files
committed
Refactor TanStack Start tunnel route handling
1 parent ee3da8d commit 1d827f5

File tree

8 files changed

+156
-56
lines changed

8 files changed

+156
-56
lines changed

dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99
"test": "playwright test",
1010
"clean": "npx rimraf node_modules pnpm-lock.yaml",
1111
"test:build": "pnpm install && pnpm build",
12-
"test:build:tunnel-generated": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic pnpm build",
13-
"test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm build",
12+
"test:build:tunnel-generated": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build",
13+
"test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build",
1414
"test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build",
1515
"test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build",
1616
"test:assert:proxy": "pnpm test",
1717
"test:assert": "pnpm test:assert:proxy && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic pnpm build && pnpm test:assert:tunnel-generated && E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm build && pnpm test:assert:tunnel-static && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build && pnpm test:assert:tunnel-custom",
18-
"test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic pnpm test",
19-
"test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm test",
18+
"test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test",
19+
"test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test",
2020
"test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test"
2121
},
2222
"dependencies": {

dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ const appTunnel = useManagedTunnelRoute
2121

2222
const tunnelRoute =
2323
tunnelRouteMode === "dynamic"
24-
? { allowedDsns: [appDsn], tunnel: true as const }
24+
? true
2525
: tunnelRouteMode === "static"
26-
? { allowedDsns: [appDsn], tunnel: "/monitor" }
26+
? "/monitor"
2727
: undefined;
2828

2929
export default defineConfig({

packages/tanstackstart-react/src/server/tunnelRoute.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { handleTunnelRequest } from '@sentry/core';
1+
import { dsnToString, getClient, handleTunnelRequest } from '@sentry/core';
22

33
export interface CreateSentryTunnelRouteOptions {
4-
allowedDsns: string[];
4+
allowedDsns?: string[];
55
}
66

77
type SentryTunnelRouteHandlerContext = {
@@ -33,9 +33,27 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions)
3333
return {
3434
handlers: {
3535
POST: async ({ request }) => {
36+
const allowedDsnsFromOptions =
37+
options.allowedDsns && options.allowedDsns.length > 0 ? options.allowedDsns : undefined;
38+
39+
const allowedDsns =
40+
allowedDsnsFromOptions ??
41+
(() => {
42+
const client = getClient();
43+
const dsn = client?.getDsn();
44+
return dsn ? [dsnToString(dsn)] : undefined;
45+
})();
46+
47+
if (!allowedDsns) {
48+
return new Response(
49+
'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.',
50+
{ status: 500 },
51+
);
52+
}
53+
3654
return handleTunnelRequest({
3755
request,
38-
allowedDsns: options.allowedDsns,
56+
allowedDsns,
3957
});
4058
},
4159
},

packages/tanstackstart-react/src/vite/sentryTanstackStart.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,13 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase {
2626
* Configures a framework-managed same-origin tunnel route for Sentry envelopes.
2727
*
2828
* This creates a TanStack Start server route backed by `createSentryTunnelRoute()` and applies the resulting path
29-
* as the default `tunnel` option on the client. Use `tunnel: true` to generate an opaque route path per dev session
30-
* or production build, or provide a static absolute path string to control the route name yourself.
29+
* as the default `tunnel` option on the client.
30+
*
31+
* You can pass:
32+
* - `true` to generate an opaque route path per dev session or production build.
33+
* - `'/custom-path'` to use a fixed static route path.
34+
* - `{ allowedDsns, path }` for full control. If `allowedDsns` is omitted or empty, the tunnel route derives the DSN
35+
* from the active server Sentry client at runtime.
3136
*
3237
* If you also pass `tunnel` to `Sentry.init()`, that explicit runtime option wins and a warning is emitted because
3338
* the managed tunnel route is being bypassed.

packages/tanstackstart-react/src/vite/sourceMaps.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ export function makeAddSentryVitePlugin(options: BuildTimeOptionsBase): Plugin[]
6565
assets: sourcemaps?.assets,
6666
disable: sourcemaps?.disable,
6767
ignore: sourcemaps?.ignore,
68-
rewriteSources: sourcemaps?.rewriteSources,
68+
// BuildTimeOptionsBase types can lag behind bundler plugin options in some local setups.
69+
// Keep runtime support while staying resilient to type version skew.
70+
rewriteSources: (sourcemaps as unknown as { rewriteSources?: unknown } | undefined)?.rewriteSources as never,
6971
filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise,
7072
},
7173
telemetry: telemetry ?? true,

packages/tanstackstart-react/src/vite/tunnelRoute.ts

Lines changed: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
11
import type { Plugin } from "vite";
22

3-
export interface TunnelRouteOptions {
4-
/**
5-
* A list of DSNs that are allowed to use the managed tunnel route.
6-
*/
7-
allowedDsns: string[];
8-
9-
/**
10-
* Controls the public route path used by the managed tunnel route.
11-
*
12-
* - `true` generates an opaque path once per dev session or production build.
13-
* - `'/custom-path'` uses a fixed absolute route path.
14-
*
15-
* @default true
16-
*/
17-
tunnel?: true | string;
18-
}
3+
export type TunnelRouteOptions =
4+
| true
5+
| string
6+
| {
7+
/**
8+
* A list of DSNs that are allowed to use the managed tunnel route.
9+
*
10+
* If omitted or empty, the tunnel route will derive the allowed DSN from the active server Sentry SDK at runtime.
11+
*/
12+
allowedDsns?: string[];
13+
14+
/**
15+
* Controls the public route path used by the managed tunnel route.
16+
*
17+
* If omitted, an opaque path is generated once per dev session or production build.
18+
*/
19+
path?: string;
20+
};
1921

2022
const MANAGED_TUNNEL_ROUTE_IMPORT = "SentryManagedTunnelRouteImport";
2123
const MANAGED_TUNNEL_ROUTE_NAME = "SentryManagedTunnelRoute";
@@ -48,27 +50,45 @@ export function resolveTunnelRoute(tunnel: true | string): string {
4850
return resolvedTunnelRoute;
4951
}
5052

51-
function validateTunnelRouteOptions(options: TunnelRouteOptions): string {
52-
if (options.allowedDsns.length === 0) {
53+
type NormalizedTunnelRouteOptions = {
54+
resolvedPath: string;
55+
allowedDsns: string[] | undefined;
56+
};
57+
58+
function validateStaticPath(path: string): void {
59+
if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
5360
throw new Error(
54-
"[@sentry/tanstackstart-react] `sentryTanstackStart({ tunnelRoute })` requires at least one allowed DSN.",
61+
"[@sentry/tanstackstart-react] `tunnelRoute` static paths must start with `/` and must not contain query or hash segments.",
5562
);
5663
}
64+
}
65+
66+
function normalizeTunnelRouteOptions(
67+
options: TunnelRouteOptions,
68+
): NormalizedTunnelRouteOptions {
69+
if (options === true) {
70+
return { resolvedPath: resolveTunnelRoute(true), allowedDsns: undefined };
71+
}
5772

58-
const tunnelRoute = options.tunnel ?? true;
73+
if (typeof options === "string") {
74+
validateStaticPath(options);
75+
return {
76+
resolvedPath: resolveTunnelRoute(options),
77+
allowedDsns: undefined,
78+
};
79+
}
5980

60-
if (
61-
typeof tunnelRoute === "string" &&
62-
(!tunnelRoute.startsWith("/") ||
63-
tunnelRoute.includes("?") ||
64-
tunnelRoute.includes("#"))
65-
) {
66-
throw new Error(
67-
"[@sentry/tanstackstart-react] `tunnelRoute.tunnel` must be `true` or an absolute route path starting with `/` and without query or hash segments.",
68-
);
81+
const allowedDsns =
82+
options.allowedDsns && options.allowedDsns.length > 0
83+
? options.allowedDsns
84+
: undefined;
85+
const path = options.path;
86+
87+
if (path) {
88+
validateStaticPath(path);
6989
}
7090

71-
return resolveTunnelRoute(tunnelRoute);
91+
return { resolvedPath: resolveTunnelRoute(path ?? true), allowedDsns };
7292
}
7393

7494
function hasRouteConflict(
@@ -146,9 +166,12 @@ export function makeTunnelRoutePlugin(
146166
options: TunnelRouteOptions,
147167
debug?: boolean,
148168
): Plugin {
149-
const resolvedTunnelRoute = validateTunnelRouteOptions(options);
169+
const normalized = normalizeTunnelRouteOptions(options);
170+
const resolvedTunnelRoute = normalized.resolvedPath;
150171
const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute);
151-
const serializedAllowedDsns = JSON.stringify(options.allowedDsns);
172+
const serializedAllowedDsns = normalized.allowedDsns
173+
? JSON.stringify(normalized.allowedDsns)
174+
: undefined;
152175

153176
if (debug) {
154177
// eslint-disable-next-line no-console
@@ -184,9 +207,7 @@ export const Route = createFileRoute(${serializedTunnelRoute})({
184207
handlers: {
185208
async POST({ request }) {
186209
const Sentry = await import('@sentry/tanstackstart-react');
187-
return Sentry.createSentryTunnelRoute({
188-
allowedDsns: ${serializedAllowedDsns},
189-
}).handlers.POST({ request });
210+
return Sentry.createSentryTunnelRoute(${serializedAllowedDsns ? `{ allowedDsns: ${serializedAllowedDsns} }` : `{}`}).handlers.POST({ request });
190211
},
191212
},
192213
},

packages/tanstackstart-react/test/server/tunnelRoute.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { afterEach, describe, expect, it, vi } from 'vitest';
22

33
const handleTunnelRequestSpy = vi.fn();
4+
const getClientSpy = vi.fn();
45

56
vi.mock('@sentry/core', async importOriginal => {
67
const original = await importOriginal();
78
return {
89
...original,
910
handleTunnelRequest: (...args: unknown[]) => handleTunnelRequestSpy(...args),
11+
getClient: (...args: unknown[]) => getClientSpy(...args),
1012
};
1113
});
1214

@@ -45,4 +47,46 @@ describe('createSentryTunnelRoute', () => {
4547
expect(options.allowedDsns).toBe(allowedDsns);
4648
expect(result).toBe(response);
4749
});
50+
51+
it('derives the allowed DSN from the active server Sentry client when allowedDsns is omitted', async () => {
52+
const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' });
53+
const response = new Response('ok', { status: 200 });
54+
55+
getClientSpy.mockReturnValueOnce({
56+
getDsn: () => ({
57+
protocol: 'http',
58+
publicKey: 'public',
59+
pass: '',
60+
host: 'localhost',
61+
port: '3031',
62+
path: '',
63+
projectId: '1337',
64+
}),
65+
});
66+
handleTunnelRequestSpy.mockResolvedValueOnce(response);
67+
68+
const route = createSentryTunnelRoute({});
69+
const result = await route.handlers.POST({ request });
70+
71+
expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1);
72+
const [options] = handleTunnelRequestSpy.mock.calls[0]!;
73+
expect(options).toEqual({
74+
request,
75+
allowedDsns: ['http://public@localhost:3031/1337'],
76+
});
77+
expect(result).toBe(response);
78+
});
79+
80+
it('returns 500 when allowedDsns is omitted and no active server Sentry client DSN exists', async () => {
81+
const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' });
82+
83+
getClientSpy.mockReturnValueOnce(undefined);
84+
85+
const route = createSentryTunnelRoute({});
86+
const result = await route.handlers.POST({ request });
87+
88+
expect(handleTunnelRequestSpy).not.toHaveBeenCalled();
89+
expect(result.status).toBe(500);
90+
await expect(result.text()).resolves.toContain('Tunnel route requires Sentry server SDK initialized with a DSN');
91+
});
4892
});

packages/tanstackstart-react/test/vite/tunnelRoute.test.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,15 @@ describe('tunnelRoute vite plugin', () => {
6161
expect(resolveTunnelRoute('/monitor')).toBe('/monitor');
6262
});
6363

64-
it('rejects empty allowedDsns', () => {
65-
expect(() => makeTunnelRoutePlugin({ allowedDsns: [] })).toThrow(
66-
'`sentryTanstackStart({ tunnelRoute })` requires at least one allowed DSN',
67-
);
68-
});
69-
7064
it('rejects invalid static tunnel routes', () => {
71-
expect(() => makeTunnelRoutePlugin({ allowedDsns: ['https://public@o0.ingest.sentry.io/0'], tunnel: 'monitor' })).toThrow(
72-
'`tunnelRoute.tunnel` must be `true` or an absolute route path',
65+
expect(() => makeTunnelRoutePlugin('monitor')).toThrow(
66+
'static paths must start with `/` and must not contain query or hash segments',
67+
);
68+
expect(() => makeTunnelRoutePlugin('/monitor?x=1')).toThrow(
69+
'static paths must start with `/` and must not contain query or hash segments',
70+
);
71+
expect(() => makeTunnelRoutePlugin({ path: 'monitor' })).toThrow(
72+
'static paths must start with `/` and must not contain query or hash segments',
7373
);
7474
});
7575

@@ -102,7 +102,7 @@ describe('tunnelRoute vite plugin', () => {
102102
it('loads a virtual managed tunnel route module for a static tunnel path', async () => {
103103
const plugin = makeTunnelRoutePlugin({
104104
allowedDsns: ['http://public@localhost:3031/1337'],
105-
tunnel: '/monitor',
105+
path: '/monitor',
106106
});
107107

108108
expect(plugin.config && plugin.config()).toEqual({
@@ -121,4 +121,14 @@ describe('tunnelRoute vite plugin', () => {
121121
expect(virtualRouteModule).toContain('createFileRoute("/monitor")');
122122
expect(virtualRouteModule).toContain('allowedDsns: ["http://public@localhost:3031/1337"]');
123123
});
124+
125+
it('omits allowedDsns from the virtual managed tunnel route module when not provided', async () => {
126+
const plugin = makeTunnelRoutePlugin('/monitor');
127+
128+
const virtualRouteModule =
129+
plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route'));
130+
131+
expect(virtualRouteModule).toContain('createFileRoute("/monitor")');
132+
expect(virtualRouteModule).toContain('createSentryTunnelRoute({})');
133+
});
124134
});

0 commit comments

Comments
 (0)