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 d42510f36173..b845965f5caf 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,15 +61,6 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa expect(errorEvent.transaction).toBe(`${basePath}/`); }); -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(405); - expect(response.headers()['allow']).toBe('POST'); -}); - function pathnameMatchesTunnelRoute(pathname: string): boolean { return typeof expectedTunnelPathMatcher === 'string' ? pathname === expectedTunnelPathMatcher diff --git a/docs/repeated-init.md b/docs/repeated-init.md index a8f6445f0ffc..f584225a858b 100644 --- a/docs/repeated-init.md +++ b/docs/repeated-init.md @@ -37,7 +37,7 @@ When `init()` replaces a client, nothing closes the old one: | Entry point | Repeated call | | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `initAndBind` (browser and its wrappers, Deno), Node `_init`, Vercel Edge | Warns, then replaces the client. Returns the new client. | -| Next.js server, Remix server, Hono Node | Keeps the first client and returns it. Logs in debug mode only. | +| Next.js server, Remix server, Astro server, Hono Node | Keeps the first client and returns it. Logs in debug mode only. | | Hono Bun and Deno | Keeps the first client and returns it. Warns with its own text. | | Nuxt server | Keeps the first client and returns it. Logs that a `--import` preload is no longer needed. | | Cloudflare (default) | Keeps the first client of the isolate and returns it, unless that client is closed or closing. `cacheClient: false` makes a new client on each call, with no warning. | diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 0b39040d198b..36c6421b72d7 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -198,7 +198,9 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { // This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire) // and also serves as the fallback for non-Cloudflare adapters in production. if (serverInitSnippet) { - updateConfig({ vite: { plugins: [makeServerInitVitePlugin(serverInitSnippet)] } }); + updateConfig({ + vite: { plugins: [makeServerInitVitePlugin(serverInitSnippet, { injectIntoEntry: !isCloudflare })] }, + }); injectScript('page-ssr', SERVER_INIT_IMPORT); } diff --git a/packages/astro/src/integration/serverInit.ts b/packages/astro/src/integration/serverInit.ts index 111ad3c06b42..830ba6c20f46 100644 --- a/packages/astro/src/integration/serverInit.ts +++ b/packages/astro/src/integration/serverInit.ts @@ -14,12 +14,18 @@ export const SERVER_INIT_IMPORT = `import "${SERVER_INIT_MODULE}";\n`; /** * Serves the server init as one module that SSR pages and the Sentry middleware both import, so it runs once, on - * whichever request comes first. With only the `page-ssr` script, a request that renders no SSR page, like a tunnel - * request from a prerendered page on a cold server, would find no client. + * whichever request comes first. With `injectIntoEntry`, a build also imports it from the server entry, so the SDK is + * set up before the server takes its first request: a tunnel request from a prerendered page on a cold server renders + * no SSR page and would otherwise reach the app before the runtime answers tunnel requests. */ -export function makeServerInitVitePlugin(serverInitSnippet: string): VitePlugin { +export function makeServerInitVitePlugin(serverInitSnippet: string, { injectIntoEntry = false } = {}): VitePlugin { + let isBuild = false; + return { name: 'sentry-astro-server-init', + configResolved: config => { + isBuild = config.command === 'build'; + }, resolveId: id => (id === SERVER_INIT_MODULE || id === SENTRY_MIDDLEWARE_MODULE ? `\0${id}` : null), load: id => { if (id === `\0${SERVER_INIT_MODULE}`) { @@ -30,5 +36,13 @@ export function makeServerInitVitePlugin(serverInitSnippet: string): VitePlugin ? `${SERVER_INIT_IMPORT}export { onRequest } from "@sentry/astro/middleware";\n` : null; }, + transform(code, id, options) { + // The dev server's module graph has no server entry, and an import added to what it marks as one hangs it. + if (!injectIntoEntry || !isBuild || !options?.ssr || !this.getModuleInfo(id)?.isEntry) { + return null; + } + + return { code: `${SERVER_INIT_IMPORT}${code}`, map: null }; + }, }; } diff --git a/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts index 98910a1db2fd..98fc2d44e198 100644 --- a/packages/astro/src/server/sdk.ts +++ b/packages/astro/src/server/sdk.ts @@ -1,8 +1,9 @@ import { SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import { HTTP_SERVER } from '@sentry/conventions/op'; -import { applySdkMetadata } from '@sentry/core'; +import { _INTERNAL_getActiveClient, applySdkMetadata, debug } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; +import { DEBUG_BUILD } from '../debug-build'; declare const EdgeRuntime: string | undefined; declare const Deno: unknown; @@ -19,6 +20,15 @@ export function init(options: NodeOptions): NodeClient | undefined { return undefined; } + // The integration imports the server config from the server entry and from SSR pages, and a `node --import` + // preload of it is supported too. The first init wins. + const existingClient = _INTERNAL_getActiveClient(); + if (existingClient) { + DEBUG_BUILD && debug.log('SDK already initialized'); + + return existingClient; + } + const opts = { ...options, }; diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index d6a4f13898b1..5f5827812e5e 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,7 +1,4 @@ -import { - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_setTunnelDefaults as setTunnelDefaults, -} from '@sentry/core/server'; +import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; import { definePlugin } from 'nitro'; import { useRuntimeConfig } from 'nitro/runtime-config'; // @ts-expect-error - This is a virtual module @@ -13,13 +10,8 @@ export default definePlugin(nitroApp => { nitroApp.hooks.hook('error', captureErrorHook); instrumentTasks(tasks); - // Nitro runs the app's `middleware/` before anything a module registers, so an auth middleware there would block - // the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes first, - // before route rules and middleware. Requests still carry Nitro's `baseURL` here, so the tunnel is served under it, - // unless a framework serves the app under another prefix: `@sentry/solidstart` sets `sentryTunnelBasePath` to Vite's - // `base`. + // The runtime answers managed tunnel requests, under Nitro's `baseURL` unless a framework serves the app under + // another prefix: `@sentry/solidstart` sets `sentryTunnelBasePath` to Vite's `base`. const runtimeConfig = useRuntimeConfig() as { app?: { baseURL?: string }; sentryTunnelBasePath?: string }; setTunnelDefaults({ basePath: runtimeConfig.sentryTunnelBasePath || runtimeConfig.app?.baseURL }); - const appFetch = nitroApp.fetch; - nitroApp.fetch = request => handleServedTunnelRequest(request) ?? appFetch(request); }); diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts index da950ea3ea4f..51aed67bffed 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -1,33 +1,12 @@ -import { - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, - _INTERNAL_setTunnelDefaults as setTunnelDefaults, -} from '@sentry/core/server'; -import { eventHandler, toWebRequest } from 'h3'; +import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; import type { NitroAppPlugin } from 'nitropack'; import { useRuntimeConfig } from 'nitropack/runtime'; /** - * Answers managed tunnel requests on Nuxt v3 and v4 (Nitro v2), from the first layer of the h3 app. That runs - * before route rules and the app's `server/middleware`. + * Serves the managed tunnel under Nuxt's `app.baseURL` on Nuxt v3 and v4 (Nitro v2). The runtime answers the + * requests. */ -export default (nitroApp => { - // The first layer sees the path before Nitro strips `app.baseURL`, so the tunnel is served under it. +export default (() => { // `useRuntimeConfig` also picks up a `NUXT_APP_BASE_URL` set at runtime, the same value the browser gets. setTunnelDefaults({ basePath: useRuntimeConfig().app?.baseURL }); - - // Not the `request` hook: h3 still runs the first layer after a hook answered, and with a `headers` route rule - // that layer throws for writing headers on the sent response. - nitroApp.h3App.stack.unshift({ - route: '', - handler: eventHandler(event => { - // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. - if (!isServedTunnelRequest({ method: event.method, url: event.path })) { - return undefined; - } - - // A returned `Response` is sent and ends the request, while `undefined` hands it to the next layer. - return handleServedTunnelRequest(toWebRequest(event)); - }), - }); }) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts index 9a63b6fc7d68..611466e662df 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -1,20 +1,11 @@ -import { - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_setTunnelDefaults as setTunnelDefaults, -} from '@sentry/core/server'; +import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; import { useRuntimeConfig } from 'nitro/runtime-config'; -import type { NitroAppPlugin, NitroApp } from 'nitro/types'; +import type { NitroAppPlugin } from 'nitro/types'; /** - * Answers managed tunnel requests on Nuxt v5+ (Nitro v3+). + * Serves the managed tunnel under Nuxt's `app.baseURL` on Nuxt v5+ (Nitro v3+). The runtime answers the requests. */ -export default ((nitroApp: NitroApp) => { - // Nitro runs the app's `server/middleware` before anything a module registers, so an auth middleware there would - // block the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes - // first, before route rules and middleware. - // Requests still carry Nuxt's `app.baseURL` here, so the tunnel is served under it. `useRuntimeConfig` also picks - // up a `NUXT_APP_BASE_URL` set at runtime. +export default (() => { + // `useRuntimeConfig` also picks up a `NUXT_APP_BASE_URL` set at runtime, the same value the browser gets. setTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); - const appFetch = nitroApp.fetch; - nitroApp.fetch = request => handleServedTunnelRequest(request) ?? appFetch(request); }) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index 0a7f358f1182..7401a6c1596d 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -120,7 +120,7 @@ function setupDeprecatedInjectMode( } /** - * Adds the plugin that answers the managed tunnel. The Cloudflare setup has no server config file, so it skips + * Adds the plugin that sets the managed tunnel's base path. The Cloudflare setup has no server config file, so it skips * `setupNitroServer` and adds this on its own. */ export function addTunnelPlugin(serverApi: ServerApi): void { diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index 481d2169cf07..f28bb1d4924e 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -1,7 +1,7 @@ export * from '../client'; export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest'; -export { createSentryTunnelRoutes } from '../server/tunnelRoute'; +export { setTunnelBasePath as _INTERNAL_setTunnelBasePath } from '../server/tunnelRoute'; export { createSentryServerInstrumentation, type CreateSentryServerInstrumentationOptions, diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index 71d37c6ca114..06f1e335410e 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -9,7 +9,7 @@ export { createSentryHandleRequest, type SentryHandleRequestOptions } from './cr export type { SentryWebStreamHandleRequestOptions } from './handleRequestTypes'; export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError'; export { getMetaTagTransformer } from './getMetaTagTransformer'; -export { createSentryTunnelRoutes } from './tunnelRoute'; +export { setTunnelBasePath as _INTERNAL_setTunnelBasePath } from './tunnelRoute'; // React Router instrumentation API support export { diff --git a/packages/react-router/src/server/tunnelRoute.ts b/packages/react-router/src/server/tunnelRoute.ts index 7b8541f40d2a..1d10aa356f19 100644 --- a/packages/react-router/src/server/tunnelRoute.ts +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -1,70 +1,12 @@ -import { debug, getClient } from '@sentry/core'; -import { - _INTERNAL_getTunnelPaths as getTunnelPaths, - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_setTunnelDefaults as setTunnelDefaults, -} from '@sentry/core/server'; -import { DEBUG_BUILD } from '../common/debug-build'; - -// Never matches a request, for when the managed tunnel is off. -const DISABLED_TUNNEL_PATH = '/__sentry-tunnel-disabled__'; +import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; /** - * The resource routes that serve the managed tunnel, one per tunnel path. `sentryReactRouter()` adds them to the - * server build, since React Router has no request hook to answer from and a route matches only one path. Not meant - * to be used directly. + * Serves the managed tunnel under the app's `basename`, which React Router leaves in the request URL. + * `sentryReactRouter()` calls it from the server build. * - * @param basename The app's `basename`, which React Router matches the routes under but leaves in the request URL. + * @param basename The app's `basename`. */ -export function createSentryTunnelRoutes(basename?: string): Record> { +export function setTunnelBasePath(basename?: string): void { // Runs when the server build loads. Clients created later, e.g. per request on Cloudflare, start with the basename. setTunnelDefaults({ basePath: basename }); - - // `init` runs from `--import` before the server build loads, so the client and its tunnel paths are usually known. - if (!getClient()) { - return { 'sentry-tunnel': makeTunnelRoute('sentry-tunnel', getLazyTunnelPath) }; - } - - return Object.fromEntries( - getTunnelPaths().map((path, index) => { - const id = `sentry-tunnel-${index}`; - - return [id, makeTunnelRoute(id, () => path)]; - }), - ); -} - -/** - * The path of the single route added when `init` runs after the server build loads, e.g. per request on Cloudflare. - * React Router reads it on its first request and keeps it until the server restarts. - */ -function getLazyTunnelPath(): string { - if (!getClient()) { - DEBUG_BUILD && debug.warn('The managed tunnel stays off until restart: a request came in before `Sentry.init`.'); - return DISABLED_TUNNEL_PATH; - } - - const [path, ...otherPaths] = getTunnelPaths(); - if (otherPaths.length) { - DEBUG_BUILD && debug.warn(`Sentry.init ran after the server build loaded, so only ${path} is served.`); - } - - return path ?? DISABLED_TUNNEL_PATH; -} - -function makeTunnelRoute(id: string, getPath: () => string): Record { - return { - id, - parentId: undefined, - index: undefined, - caseSensitive: undefined, - get path(): string { - return getPath(); - }, - module: { - action: ({ request }: { request: Request }) => - handleServedTunnelRequest(request) ?? new Response(null, { status: 404 }), - loader: () => new Response(null, { status: 405, headers: { Allow: 'POST' } }), - }, - }; } diff --git a/packages/react-router/src/vite/makeTunnelBasePathPlugin.ts b/packages/react-router/src/vite/makeTunnelBasePathPlugin.ts new file mode 100644 index 000000000000..d74fcf4b7fe9 --- /dev/null +++ b/packages/react-router/src/vite/makeTunnelBasePathPlugin.ts @@ -0,0 +1,28 @@ +import type { Plugin } from 'vite'; + +const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build'; + +/** + * Passes the app's `basename` from the server build to the managed tunnel, which serves under it. Only applies when + * `serveTunnelIntegration` is set up. + */ +export function makeTunnelBasePathPlugin(): Plugin { + return { + name: 'sentry-react-router-tunnel-base-path', + enforce: 'post', + + transform(code, id, options) { + if (!options?.ssr || !id.includes(SERVER_BUILD_MODULE_ID)) { + return null; + } + + return { + code: `${code} +import { _INTERNAL_setTunnelBasePath as __sentrySetTunnelBasePath } from '@sentry/react-router'; +__sentrySetTunnelBasePath(typeof basename === 'string' ? basename : undefined); +`, + map: null, + }; + }, + }; +} diff --git a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts deleted file mode 100644 index 5d2c12cb244c..000000000000 --- a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Plugin } from 'vite'; - -const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build'; - -/** - * Adds the managed tunnel routes to the server build. They only serve requests when `serveTunnelIntegration` is set - * up. The server build exports the app's `basename`, which the route needs to match the request URL. - */ -export function makeTunnelRoutePlugin(): Plugin { - return { - name: 'sentry-react-router-tunnel-route', - enforce: 'post', - - transform(code, id, options) { - if (!options?.ssr || !id.includes(SERVER_BUILD_MODULE_ID)) { - return null; - } - - return { - code: `${code} -import { createSentryTunnelRoutes as __sentryCreateTunnelRoutes } from '@sentry/react-router'; -if (typeof routes === 'object' && routes) { - Object.assign(routes, __sentryCreateTunnelRoutes(typeof basename === 'string' ? basename : undefined)); -} -`, - map: null, - }; - }, - }; -} diff --git a/packages/react-router/src/vite/plugin.ts b/packages/react-router/src/vite/plugin.ts index 2992b9a5bbc0..ced7b61048ec 100644 --- a/packages/react-router/src/vite/plugin.ts +++ b/packages/react-router/src/vite/plugin.ts @@ -4,7 +4,7 @@ import { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin'; import { makeCustomSentryVitePlugins } from './makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from './makeEnableSourceMapsPlugin'; import { makeServerBuildCapturePlugin } from './makeServerBuildCapturePlugin'; -import { makeTunnelRoutePlugin } from './makeTunnelRoutePlugin'; +import { makeTunnelBasePathPlugin } from './makeTunnelBasePathPlugin'; import type { SentryReactRouterBuildOptions } from './types'; /** @@ -22,7 +22,7 @@ export async function sentryReactRouter( plugins.push(makeConfigInjectorPlugin(options)); plugins.push(makeServerBuildCapturePlugin()); - plugins.push(makeTunnelRoutePlugin()); + plugins.push(makeTunnelBasePathPlugin()); if (process.env.NODE_ENV !== 'development' && viteConfig.command === 'build' && viteConfig.mode !== 'development') { plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation })); diff --git a/packages/react-router/test/server/tunnelRoute.test.ts b/packages/react-router/test/server/tunnelRoute.test.ts deleted file mode 100644 index 23c5739458a5..000000000000 --- a/packages/react-router/test/server/tunnelRoute.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Client } from '@sentry/core'; -import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; -import { _INTERNAL_resetTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createSentryTunnelRoutes } from '../../src/server/tunnelRoute'; - -const DSN = 'https://public@o1.ingest.sentry.io/1337'; - -type TunnelRoute = { - path: string; - parentId: undefined; - module: { action: (args: { request: Request }) => Promise | Response; loader: () => Response }; -}; - -function firstRoute(basename?: string): TunnelRoute { - return Object.values(createSentryTunnelRoutes(basename))[0] as TunnelRoute; -} - -/** Sets a client, with `serveTunnelIntegration` set up when `managedTunnel` is given. */ -function setClient(managedTunnel: object | undefined): void { - const options = { dsn: DSN }; - const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; - getCurrentScope().setClient(client); - if (managedTunnel) { - serveTunnelIntegration(managedTunnel).setup!(client); - } -} - -const ENVELOPE = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; - -describe('createSentryTunnelRoutes', () => { - afterEach(() => { - getCurrentScope().setClient(undefined); - _INTERNAL_resetTunnelDefaults(); - vi.unstubAllGlobals(); - }); - - it('is a top-level route at the tunnel path when `serveTunnelIntegration` is set up', () => { - setClient({}); - const route = firstRoute(); - - expect(route.parentId).toBeUndefined(); - expect(route.path).toBe(getTunnelPath(DSN)); - }); - - it('adds no route when the tunnel is off, and a path that never matches when Sentry is not initialized yet', () => { - const route = firstRoute(); - const pathWithoutClient = route.path; - setClient(undefined); - - expect(pathWithoutClient).toBe('/__sentry-tunnel-disabled__'); - expect(route.path).toBe('/__sentry-tunnel-disabled__'); - expect(createSentryTunnelRoutes()).toEqual({}); - }); - - it('forwards tunnel POSTs and rejects other methods', async () => { - setClient({}); - vi.stubGlobal( - 'fetch', - vi.fn(() => Promise.resolve(new Response('forwarded'))), - ); - const route = firstRoute(); - const request = new Request(`http://localhost${getTunnelPath(DSN)}`, { - method: 'POST', - body: ENVELOPE, - headers: { 'content-length': String(ENVELOPE.length) }, - }); - - expect(await (await route.module.action({ request })).text()).toBe('forwarded'); - expect(route.module.loader().status).toBe(405); - }); - - it('serves the tunnel under `basename`, which React Router leaves in the request URL', async () => { - setClient({}); - vi.stubGlobal( - 'fetch', - vi.fn(() => Promise.resolve(new Response('forwarded'))), - ); - const route = firstRoute('/app/'); - const request = new Request(`http://localhost/app${getTunnelPath(DSN)}`, { - method: 'POST', - body: ENVELOPE, - headers: { 'content-length': String(ENVELOPE.length) }, - }); - - // React Router matches the route under `basename` itself, so the path stays relative to it. - expect(route.path).toBe(getTunnelPath(DSN)); - expect(await (await route.module.action({ request })).text()).toBe('forwarded'); - }); -}); diff --git a/packages/react-router/test/vite/makeTunnelBasePathPlugin.test.ts b/packages/react-router/test/vite/makeTunnelBasePathPlugin.test.ts new file mode 100644 index 000000000000..9925ca578ccc --- /dev/null +++ b/packages/react-router/test/vite/makeTunnelBasePathPlugin.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { makeTunnelBasePathPlugin } from '../../src/vite/makeTunnelBasePathPlugin'; + +type Transform = (code: string, id: string, options?: { ssr?: boolean }) => { code: string } | null; + +describe('makeTunnelBasePathPlugin', () => { + const transform = makeTunnelBasePathPlugin().transform as unknown as Transform; + + it('passes the basename of the SSR server build to the tunnel', () => { + const result = transform('export const basename = "/app";', '\0virtual:react-router/server-build', { ssr: true }); + + expect(result?.code).toContain("__sentrySetTunnelBasePath(typeof basename === 'string' ? basename : undefined);"); + expect(result?.code).toContain("from '@sentry/react-router'"); + }); + + it('leaves other modules and the client build alone', () => { + expect(transform('', '\0virtual:react-router/server-build', { ssr: false })).toBeNull(); + expect(transform('', '/app/root.tsx', { ssr: true })).toBeNull(); + }); +}); diff --git a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts deleted file mode 100644 index d520315a07bc..000000000000 --- a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { makeTunnelRoutePlugin } from '../../src/vite/makeTunnelRoutePlugin'; - -type Transform = (code: string, id: string, options?: { ssr?: boolean }) => { code: string } | null; - -describe('makeTunnelRoutePlugin', () => { - const transform = makeTunnelRoutePlugin().transform as unknown as Transform; - - it('adds the tunnel route to the SSR server build', () => { - const result = transform('export const routes = {};', '\0virtual:react-router/server-build', { ssr: true }); - - expect(result?.code).toContain( - " Object.assign(routes, __sentryCreateTunnelRoutes(typeof basename === 'string' ? basename : undefined));", - ); - expect(result?.code).toContain("from '@sentry/react-router'"); - }); - - it('only adds the route when the server build declares `routes`, so a build without it still loads', () => { - const result = transform('export const routes = {};', '\0virtual:react-router/server-build', { ssr: true }); - - expect(result?.code).toContain("if (typeof routes === 'object' && routes) {"); - }); - - it('leaves other modules and the client build alone', () => { - expect(transform('', '\0virtual:react-router/server-build', { ssr: false })).toBeNull(); - expect(transform('', '/app/root.tsx', { ssr: true })).toBeNull(); - }); -}); diff --git a/packages/react-router/test/vite/plugin.test.ts b/packages/react-router/test/vite/plugin.test.ts index 5c33619821df..b2c9774027d6 100644 --- a/packages/react-router/test/vite/plugin.test.ts +++ b/packages/react-router/test/vite/plugin.test.ts @@ -3,7 +3,7 @@ import { makeConfigInjectorPlugin } from '../../src/vite/makeConfigInjectorPlugi import { makeCustomSentryVitePlugins } from '../../src/vite/makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from '../../src/vite/makeEnableSourceMapsPlugin'; import { makeServerBuildCapturePlugin } from '../../src/vite/makeServerBuildCapturePlugin'; -import { makeTunnelRoutePlugin } from '../../src/vite/makeTunnelRoutePlugin'; +import { makeTunnelBasePathPlugin } from '../../src/vite/makeTunnelBasePathPlugin'; import { sentryReactRouter } from '../../src/vite/plugin'; vi.spyOn(console, 'log').mockImplementation(() => { @@ -17,7 +17,7 @@ vi.mock('../../src/vite/makeCustomSentryVitePlugins'); vi.mock('../../src/vite/makeEnableSourceMapsPlugin'); vi.mock('../../src/vite/makeConfigInjectorPlugin'); vi.mock('../../src/vite/makeServerBuildCapturePlugin'); -vi.mock('../../src/vite/makeTunnelRoutePlugin'); +vi.mock('../../src/vite/makeTunnelBasePathPlugin'); // Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in). // Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant. @@ -33,7 +33,7 @@ describe('sentryReactRouter', () => { const mockSourceMapsPlugin = { name: 'source-maps-plugin' }; const mockConfigInjectorPlugin = { name: 'sentry-config-injector' }; const mockServerBuildCapturePlugin = { name: 'sentry-react-router-server-build-capture' }; - const mockTunnelRoutePlugin = { name: 'sentry-react-router-tunnel-route' }; + const mockTunnelBasePathPlugin = { name: 'sentry-react-router-tunnel-base-path' }; const mockOrchestrionPlugin = { name: 'sentry-orchestrion-vite' }; beforeEach(() => { @@ -42,7 +42,7 @@ describe('sentryReactRouter', () => { vi.mocked(makeEnableSourceMapsPlugin).mockReturnValue(mockSourceMapsPlugin); vi.mocked(makeConfigInjectorPlugin).mockReturnValue(mockConfigInjectorPlugin); vi.mocked(makeServerBuildCapturePlugin).mockReturnValue(mockServerBuildCapturePlugin); - vi.mocked(makeTunnelRoutePlugin).mockReturnValue(mockTunnelRoutePlugin); + vi.mocked(makeTunnelBasePathPlugin).mockReturnValue(mockTunnelBasePathPlugin); }); afterEach(() => { @@ -55,7 +55,7 @@ describe('sentryReactRouter', () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelBasePathPlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); @@ -65,7 +65,7 @@ describe('sentryReactRouter', () => { it('should return config injector plugin when not in build mode', async () => { const result = await sentryReactRouter({}, { command: 'serve', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelBasePathPlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -73,7 +73,7 @@ describe('sentryReactRouter', () => { it('should return config injector plugin in development build mode', async () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'development' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelBasePathPlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -87,7 +87,7 @@ describe('sentryReactRouter', () => { expect(result).toEqual([ mockConfigInjectorPlugin, mockServerBuildCapturePlugin, - mockTunnelRoutePlugin, + mockTunnelBasePathPlugin, mockOrchestrionPlugin, mockSourceMapsPlugin, ...mockPlugins, diff --git a/packages/remix/src/v3/server/instrument.ts b/packages/remix/src/v3/server/instrument.ts index f63578e2f0f9..0094d56e7594 100644 --- a/packages/remix/src/v3/server/instrument.ts +++ b/packages/remix/src/v3/server/instrument.ts @@ -1,7 +1,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { createMultiMatcher } from 'remix/route-pattern/match'; import { consoleSandbox } from '@sentry/core'; -import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server'; import type { OrchestrionChannelContext } from '@sentry/server-utils'; import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; @@ -59,7 +58,7 @@ function subscribeToCreateRouter(): void { } /** - * Report errors from any fetch handler, router or not, and answer managed tunnel requests before it runs. + * Report errors from any fetch handler, router or not. * The middleware only sees failures inside a router; this is the only hook that covers a plain handler. * No abort guard is needed, because the listener drops aborted requests before calling `onError`. */ @@ -72,7 +71,6 @@ function subscribeToCreateRequestListener(): void { start(data) { try { injectOnError(ensureOptions(data.arguments, 1)); - wrapFetchHandler(data.arguments); } catch { // Ignored on purpose. } @@ -84,21 +82,6 @@ function subscribeToCreateRequestListener(): void { }); } -/** - * Answers managed tunnel requests before the app's fetch handler, so code the app runs in front of its router never - * sees them. The router middleware still answers them for apps served some other way. - */ -function wrapFetchHandler(args: unknown[]): void { - const handler = args[0]; - if (typeof handler !== 'function') { - return; - } - - args[0] = function sentryFetchHandler(this: unknown, request: Request, ...rest: unknown[]): unknown { - return handleServedTunnelRequest(request) ?? handler.call(this, request, ...rest); - }; -} - function injectOnError(raw: Record | undefined): void { if (!raw || markInjected(raw)) { return; diff --git a/packages/remix/test/v3/instrument.test.ts b/packages/remix/test/v3/instrument.test.ts index 85133e8b3ecd..cf22efc91609 100644 --- a/packages/remix/test/v3/instrument.test.ts +++ b/packages/remix/test/v3/instrument.test.ts @@ -144,33 +144,3 @@ describe('the createRequestListener error hook', () => { expect(args[1]).toEqual(expect.objectContaining({ onError: expect.any(Function) })); }); }); - -describe('the createRequestListener tunnel', () => { - beforeAll(() => { - instrumentRemixV3(); - }); - - it("answers a managed tunnel request without calling the app's handler", async () => { - const appHandler = vi.fn(() => new Response('app')); - const args: unknown[] = [appHandler]; - - publishCreateRequestListener(args); - - expect(await (args[0] as (request: Request) => unknown)(new Request(TUNNEL_URL, { method: 'POST' }))).toBe( - tunnelResponse, - ); - expect(appHandler).not.toHaveBeenCalled(); - }); - - it("hands every other request to the app's handler", () => { - const appResponse = new Response('app'); - const appHandler = vi.fn(() => appResponse); - const args: unknown[] = [appHandler]; - const request = new Request('http://localhost/page'); - - publishCreateRequestListener(args); - - expect((args[0] as (request: Request, client: unknown) => unknown)(request, 'client')).toBe(appResponse); - expect(appHandler).toHaveBeenCalledWith(request, 'client'); - }); -}); diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts index d423c8839fe0..857dfa8f66af 100644 --- a/packages/solidstart/src/nitro/tunnelPlugin.ts +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -1,37 +1,15 @@ import { GLOBAL_OBJ } from '@sentry/core'; -import { - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, - _INTERNAL_setTunnelDefaults as setTunnelDefaults, -} from '@sentry/core/server'; -import { eventHandler, toWebRequest } from 'h3'; +import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; import type { NitroAppPlugin } from 'nitropack'; import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; /** - * Answers managed tunnel requests on SolidStart v1 (Nitro v2), from the first layer of the h3 app. That runs before - * route rules, the app's middleware and routing. `withSentry` registers it. + * Serves the managed tunnel under the app's base path on SolidStart v1 (Nitro v2). The runtime answers the requests. + * `withSentry` registers it. */ -export default (nitroApp => { - // The first layer sees the path before Nitro strips `server.baseURL`, so the tunnel is served under it. The server - // bundle's banner sets the global before this plugin runs. +export default (() => { + // The server bundle's banner sets the global before this plugin runs. setTunnelDefaults({ basePath: (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL], }); - - // Not the `request` hook: h3 still runs the first layer after a hook answered, so route rules would act on the - // sent response. - nitroApp.h3App.stack.unshift({ - route: '', - handler: eventHandler(event => { - // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. - if (!isServedTunnelRequest({ method: event.method, url: event.path })) { - return undefined; - } - - // `toWebRequest` also reads the body of the mock requests serverless presets pass through `localFetch`, which - // aren't Node streams. A returned `Response` is sent and ends the request, `undefined` hands it on. - return handleServedTunnelRequest(toWebRequest(event)); - }), - }); }) satisfies NitroAppPlugin; diff --git a/packages/solidstart/test/nitro/tunnelPlugin.test.ts b/packages/solidstart/test/nitro/tunnelPlugin.test.ts deleted file mode 100644 index 4baea8a5a12f..000000000000 --- a/packages/solidstart/test/nitro/tunnelPlugin.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Client } from '@sentry/core'; -import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; -import { _INTERNAL_resetTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; -import { createApp, eventHandler, setHeaders, toNodeListener } from 'h3'; -import { fetchNodeRequestHandler } from 'node-mock-http'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import tunnelPlugin from '../../src/nitro/tunnelPlugin'; - -const DSN = 'https://public@o1.ingest.sentry.io/1337'; - -type RequestHook = (event: unknown) => Promise; - -/** - * Calls an h3 app wired like Nitro v2's: the `request` hook runs in `onRequest`, and the route rules layer, here - * with a `headers` rule, comes before the app. Requests come in the way serverless presets pass them through - * `localFetch`, with node-mock-http's request, which isn't a Node stream. - */ -function fetchApp(url: string, init: RequestInit): Promise { - const requestHooks: RequestHook[] = []; - const app = createApp({ - onRequest: async event => { - for (const hook of requestHooks) { - await hook(event); - } - }, - onError: error => onError(error), - }); - app.use(eventHandler(event => setHeaders(event, { 'x-route-rule': '1' }))); - app.use(eventHandler(() => 'app')); - (tunnelPlugin as (nitroApp: unknown) => void)({ - h3App: app, - hooks: { hook: (name: string, fn: RequestHook) => name === 'request' && requestHooks.push(fn) }, - }); - - return fetchNodeRequestHandler(toNodeListener(app), `http://localhost${url}`, init); -} - -const onError = vi.fn(); - -describe('SolidStart v1 tunnel plugin', () => { - afterEach(() => { - getCurrentScope().setClient(undefined); - _INTERNAL_resetTunnelDefaults(); - vi.unstubAllGlobals(); - vi.clearAllMocks(); - }); - - it('answers tunnel requests before route rules and the app with `serveTunnelIntegration`', async () => { - const options = { dsn: DSN }; - const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; - getCurrentScope().setClient(client); - serveTunnelIntegration().setup!(client); - const sentryFetch = vi.fn((_url: string, _init: RequestInit) => Promise.resolve(new Response('forwarded'))); - vi.stubGlobal('fetch', sentryFetch); - const envelope = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; - - const response = await fetchApp(getTunnelPath(DSN)!, { - method: 'POST', - body: envelope, - headers: { 'content-length': String(envelope.length) }, - }); - - expect(await response.text()).toBe('forwarded'); - expect(await new Response(sentryFetch.mock.lastCall![1].body).text()).toBe(envelope); - // A route rule that ran after the answer would fail to set headers on the sent response. - expect(response.headers.get('x-route-rule')).toBeNull(); - expect(onError).not.toHaveBeenCalled(); - }); - - it('serves no tunnel without `serveTunnelIntegration`', async () => { - const options = { dsn: DSN }; - getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); - const envelope = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; - - const response = await fetchApp(getTunnelPath(DSN)!, { - method: 'POST', - body: envelope, - headers: { 'content-length': String(envelope.length) }, - }); - - expect(await response.text()).toBe('app'); - }); - - it('leaves every other request to the app', async () => { - const response = await fetchApp('/api/users', { method: 'POST', body: 'x' }); - - expect(await response.text()).toBe('app'); - expect(response.headers.get('x-route-rule')).toBe('1'); - }); -}); diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index b157b4f31173..ad7946b233b4 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -64,10 +64,8 @@ export function createSentryTunnelRoute(_options: CreateSentryTunnelRouteOptions 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. + * No-op stub for client-side builds. The server entry that calls it only runs on the server. * * @internal */ -export function _INTERNAL_wrapServerEntryWithSentryTunnel(entry: T, _options?: unknown): T { - return entry; -} +export function _INTERNAL_setTunnelRoute(_tunnelRoute?: unknown, _basePath?: string): void {} diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 9b81217a239a..b06807027a1c 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 _INTERNAL_wrapServerEntryWithSentryTunnel: typeof serverSdk._INTERNAL_wrapServerEntryWithSentryTunnel; +export declare const _INTERNAL_setTunnelRoute: typeof serverSdk._INTERNAL_setTunnelRoute; diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index cef85cd391c4..b537fd9cfd01 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 as _INTERNAL_wrapServerEntryWithSentryTunnel, + setTunnelRoute as _INTERNAL_setTunnelRoute, } from './tunnelRoute'; /** diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index cd0c4a0e1389..b1f87d3751fd 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -3,11 +3,8 @@ import type { ServerTunnelOptions } from '@sentry/core'; import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; import { _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, _INTERNAL_setTunnelDefaults as setTunnelDefaults, } from '@sentry/core/server'; -import type { ServerEntry } from './wrapFetchWithSentry'; const registeredTunnelRoutePaths = new Set(); @@ -99,46 +96,15 @@ 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. + * Enables the managed tunnel when the server entry loads, which the runtime then answers. The `sentryTanstackStart` + * Vite plugin calls this from the app's server entry, with its `tunnelRoute` and the base path Start routes on. * * `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, - 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 - // is served under it. +export function setTunnelRoute(tunnelRoute?: ServerTunnelOptions, basePath?: string): void { + // Requests still carry Start's base path, so the tunnel is served under it. setTunnelDefaults({ 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. - if (typeof entry === 'function') { - return entry; - } - - // 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[]) => { - // Start would render the app for any other method on a tunnel path, running its root loaders. - if (request.method !== 'POST' && isServedTunnelRequest({ method: 'POST', url: request.url })) { - return new Response(null, { status: 405, headers: { Allow: 'POST' } }); - } - - return handleServedTunnelRequest(request) ?? entry.fetch(request, ...rest); - }, - writable: true, - configurable: true, - enumerable: true, - }, - }) as T; } diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index f91c61e0d2e2..cc280e24b514 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -67,7 +67,7 @@ function hasRouteConflict(source: string, tunnelPath: string): boolean { } /** - * Tells the client SDK which tunnel to use. The server entry wrapper serves it. + * Tells the client SDK which tunnel to use. The runtime serves it. */ export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boolean): Plugin { const { path } = normalizeTunnelRouteOptions(options); @@ -82,7 +82,7 @@ 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 + // The runtime 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)) { @@ -99,7 +99,7 @@ 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 + * Reads the base path TanStack Start routes on and hands it to the client SDK and the server entry, 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. */ @@ -144,9 +144,8 @@ const ORIGINAL_SERVER_ENTRY_QUERY = '?sentry-tunnel-original'; const DEFAULT_SERVER_ENTRY = /\/react-start\/dist\/(?:plugin\/)?default-entry\/(?:esm\/)?server\.[jt]s$/; /** - * Wraps the TanStack Start server entry so tunnel requests are answered before Start's own request middleware and - * routing. It does nothing unless the managed tunnel is enabled, either here via `tunnelRoute` or at runtime via - * `Sentry.init({ tunnel: true })`. + * Enables `tunnelRoute` when the TanStack Start server entry loads, under the base path Start routes on. The runtime + * then answers the tunnel requests before Start's own request middleware and routing. */ export function makeServerEntryTunnelPlugin( tunnelRoute: TunnelRouteOptions | undefined, @@ -190,10 +189,10 @@ export function makeServerEntryTunnelPlugin( 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 { _INTERNAL_wrapServerEntryWithSentryTunnel as wrapServerEntryWithSentryTunnel } from ${JSON.stringify(sdk)}; + return `import { _INTERNAL_setTunnelRoute as setTunnelRoute } from ${JSON.stringify(sdk)}; export * from ${original}; -export default wrapServerEntryWithSentryTunnel(entry, ${tunnelOptions}, ${JSON.stringify(getBasePath())}); +export { default } from ${original}; +setTunnelRoute(${tunnelOptions}, ${JSON.stringify(getBasePath())}); `; }, }; diff --git a/packages/tanstackstart-react/test/server/setTunnelRoute.test.ts b/packages/tanstackstart-react/test/server/setTunnelRoute.test.ts new file mode 100644 index 000000000000..e8575146b42a --- /dev/null +++ b/packages/tanstackstart-react/test/server/setTunnelRoute.test.ts @@ -0,0 +1,38 @@ +import type { Client } from '@sentry/core'; +import { getCurrentScope, getTunnelPath, makeDsn } from '@sentry/core'; +import { _INTERNAL_resetTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; +import { afterEach, describe, expect, it } from 'vitest'; +import { setTunnelRoute } from '../../src/server/tunnelRoute'; + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; + +/** Sets a client, with `serveTunnelIntegration` set up when `managedTunnel` is given. */ +function setClient(managedTunnel: object | undefined): void { + 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('setTunnelRoute', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + _INTERNAL_resetTunnelDefaults(); + }); + + it('enables `tunnelRoute` on the client like `serveTunnelIntegration` would, dropping its spans from the start', () => { + setClient(undefined); + const options = getCurrentScope().getClient()!.getOptions(); + + setTunnelRoute({}); + + expect(options._managedTunnel).toEqual({}); + expect( + (options.ignoreSpans![0] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test( + getTunnelPath(DSN)!, + ), + ).toBe(true); + }); +}); diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts deleted file mode 100644 index 7d3fa6d030d3..000000000000 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { Client } from '@sentry/core'; -import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; -import { _INTERNAL_resetTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; -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 { - const body = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; - - 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, ignoreSpans: [] }; - const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; - getCurrentScope().setClient(client); - if (managedTunnel) { - serveTunnelIntegration(managedTunnel).setup!(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); - _INTERNAL_resetTunnelDefaults(); - vi.unstubAllGlobals(); - vi.clearAllMocks(); - }); - - 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)); - - 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 without `serveTunnelIntegration`', async () => { - setClient(undefined); - - const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}).fetch( - makeEnvelopeRequest(TUNNEL_URL), - ); - - 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('rejects other methods on the DSN-derived tunnel path too', async () => { - setClient({}); - const entry: { fetch: (request: Request) => Response } = { fetch: appFetch }; - - const response = await wrapServerEntryWithSentryTunnel(entry).fetch(new Request(TUNNEL_URL)); - - expect(response.status).toBe(405); - expect(response.headers.get('allow')).toBe('POST'); - expect(appFetch).not.toHaveBeenCalled(); - }); - - it('passes other requests and their options to the app', async () => { - setClient({}); - const request = makeEnvelopeRequest('http://localhost:3000/api/users'); - - await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(request, { context: 1 }); - - expect(appFetch).toHaveBeenCalledWith(request, { context: 1 }); - }); - - it("passes every argument to the app, like a Cloudflare Worker's `env` and `ctx`", async () => { - setClient({}); - const request = makeEnvelopeRequest('http://localhost:3000/api/users'); - const env = { E2E_TEST_DSN: DSN }; - const ctx = { waitUntil: vi.fn(), passThroughOnException: vi.fn() }; - - await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(request, env, ctx); - - expect(appFetch).toHaveBeenCalledWith(request, env, ctx); - }); - - it("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 { - return new Response('app'); - } - } - - expect(wrapServerEntryWithSentryTunnel(Worker as unknown as { fetch: () => Response })).toBe(Worker); - }); - - it('enables `tunnelRoute` on the client like `serveTunnelIntegration` would, dropping its spans from the start', () => { - setClient(undefined); - const options = getCurrentScope().getClient()!.getOptions(); - - wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}); - - expect(options._managedTunnel).toEqual({}); - expect( - (options.ignoreSpans![0] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test( - getTunnelPath(DSN)!, - ), - ).toBe(true); - }); - - it('keeps the options of `serveTunnelIntegration` 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'); - - 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 `serveTunnelIntegration` 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/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 20008ede700b..110b1ccae40b 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -94,17 +94,17 @@ describe('server entry tunnel vite plugin', () => { ) as Promise; } - it('wraps the app server entry and the default entry', async () => { + it('sets the tunnel route from the app server entry and the default entry', async () => { const load = setup(); - expect(await load(USER_ENTRY)).toContain('export default wrapServerEntryWithSentryTunnel(entry, undefined, "");'); + expect(await load(USER_ENTRY)).toContain('setTunnelRoute(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', + 'setTunnelRoute(', ); expect( await load('C:\\app\\node_modules\\@tanstack\\react-start\\dist\\plugin\\default-entry\\server.ts'), - ).toContain('wrapServerEntryWithSentryTunnel'); + ).toContain('setTunnelRoute('); expect(await load('/app/src/routes/index.tsx')).toBeNull(); }); @@ -112,25 +112,22 @@ describe('server entry tunnel vite plugin', () => { const load = setup(); expect(await load(USER_ENTRY)).toContain(`export * from "${USER_ENTRY}?sentry-tunnel-original";`); + expect(await load(USER_ENTRY)).toContain(`export { default } from "${USER_ENTRY}?sentry-tunnel-original";`); }); 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(true)(USER_ENTRY)).toContain('setTunnelRoute({}, "");'); expect(await setup({ allowedDsns: ['https://public@o0.ingest.sentry.io/0'] })(USER_ENTRY)).toContain( - 'wrapServerEntryWithSentryTunnel(entry, {"allowedDsns":["https://public@o0.ingest.sentry.io/0"]}, "");', + 'setTunnelRoute({"allowedDsns":["https://public@o0.ingest.sentry.io/0"]}, "");', ); }); - it('serves a static `tunnelRoute` path from the wrapper too', async () => { - expect(await setup('/monitor')(USER_ENTRY)).toContain( - 'wrapServerEntryWithSentryTunnel(entry, {"path":"/monitor"}, "");', - ); + it('passes a static `tunnelRoute` path too', async () => { + expect(await setup('/monitor')(USER_ENTRY)).toContain('setTunnelRoute({"path":"/monitor"}, "");'); }); - 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('passes the base path Start routes on', async () => { + expect(await setup(undefined, '/app')(USER_ENTRY)).toContain('setTunnelRoute(undefined, "/app");'); }); it('imports a bundled SDK from where the app resolves it, which also works without hoisting', async () => { @@ -138,7 +135,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 { _INTERNAL_wrapServerEntryWithSentryTunnel as wrapServerEntryWithSentryTunnel } from "${SDK_ID}";`, + `import { _INTERNAL_setTunnelRoute as setTunnelRoute } from "${SDK_ID}";`, ); expect(resolve).toHaveBeenCalledWith('@sentry/tanstackstart-react', '/app/package.json', { skipSelf: true }); });