diff --git a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts index 71061cf25d96..7486f6ecf58c 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts @@ -4,5 +4,12 @@ import { defineConfig } from '@solidjs/start/config'; export default defineConfig( withSentry({ middleware: './src/middleware.ts', + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { server: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), + vite: { + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, + }, }), ); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs b/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs index f75de066068a..c543b273ad5d 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs +++ b/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs @@ -6,6 +6,6 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server debug: !!process.env.DEBUG, }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index 5567434a3c0e..23f334a04ee1 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -10,7 +10,10 @@ "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:dev": "TEST_ENV=development playwright test db" + "test:dev": "TEST_ENV=development playwright test db", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "type": "module", "dependencies": { @@ -30,11 +33,25 @@ "jsdom": "^24.0.0", "solid-js": "1.9.5", "typescript": "^5.4.5", - "vinxi": "^0.4.0", - "vite": "^5.4.11", + "vinxi": "^0.5.11", + "vite": "^6.4.4", "vite-plugin-solid": "^2.11.6" }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "solidstart (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "solidstart (tunnel-basepath-dev)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx index 11087fbb5918..a92e54089aed 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx @@ -3,13 +3,15 @@ import * as Sentry from '@sentry/solidstart'; import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter'; import { StartClient, mount } from '@solidjs/start/client'; +declare const __E2E_TEST_TUNNEL__: boolean; + Sentry.init({ // We can't use env variables here, seems like they are stripped // out in production builds. - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', environment: 'qa', // dynamic sampling bias to keep transactions integrations: [solidRouterBrowserTracingIntegration()], - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server // Performance Monitoring tracesSampleRate: 1.0, // Capture 100% of the transactions debug: !!import.meta.env.DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts index 3dd5d8933b7b..1e1ef4a28d61 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts @@ -4,6 +4,6 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server debug: !!process.env.DEBUG, }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts b/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts index 88123a035fb6..9dee5a5d3eb8 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts @@ -2,5 +2,14 @@ import { sentryBeforeResponseMiddleware } from '@sentry/solidstart'; import { createMiddleware } from '@solidjs/start/middleware'; export default createMiddleware({ + onRequest: [ + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + event => { + const request = event.request; + if (process.env.E2E_TEST_TUNNEL && request.method === 'POST' && request.headers.get('x-auth') !== 'ok') { + return new Response('blocked by user auth', { status: 401 }); + } + }, + ], onBeforeResponse: [sentryBeforeResponseMiddleware()], }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts new file mode 100644 index 000000000000..adf43c0d4f0c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { isServerSpanAt, watchForTunnelSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'solidstart'; +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the app middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/`, { data: 'x' })).status()).toBe(401); + + const raceTunnelSpan = watchForTunnelSpan(APP_NAME, isServerSpanAt(TUNNEL_PATH)); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + + expect((await tunnelResponsePromise).status()).toBe(200); + expect( + await raceTunnelSpan(isServerSpanAt(`${BASE_PATH}/users/6`), () => page.request.get(`${BASE_PATH}/users/6`)), + ).toBe('anchor'); +}); diff --git a/packages/solidstart/rollup.npm.config.mjs b/packages/solidstart/rollup.npm.config.mjs index d28b29896b10..994c3cbe8fa7 100644 --- a/packages/solidstart/rollup.npm.config.mjs +++ b/packages/solidstart/rollup.npm.config.mjs @@ -13,11 +13,12 @@ export default makeNPMConfigVariants( 'src/client/solidrouter.ts', 'src/server/solidrouter.ts', 'src/vite/index.ts', + 'src/nitro/tunnelPlugin.ts', ], // prevent this internal code from ending up in our built package (this doesn't happen automatically because // the name doesn't match an SDK dependency) packageSpecificConfig: { - external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter'], + external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter', 'h3'], output: { dynamicImportInCjs: true, }, diff --git a/packages/solidstart/src/client/sdk.ts b/packages/solidstart/src/client/sdk.ts index 06ee8f092094..7473f329d11c 100644 --- a/packages/solidstart/src/client/sdk.ts +++ b/packages/solidstart/src/client/sdk.ts @@ -1,6 +1,7 @@ import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { BrowserOptions } from '@sentry/solid'; +import { applyTunnelBasePath } from '../common/tunnel'; import { browserTracingIntegration, getDefaultIntegrations as getDefaultSolidIntegrations, @@ -19,6 +20,7 @@ export function init(options: BrowserOptions): Client | undefined { ...options, }; + applyTunnelBasePath(opts); applySdkMetadata(opts, 'solidstart', ['solidstart', 'solid']); return initSolidSDK(opts); diff --git a/packages/solidstart/src/common/tunnel.ts b/packages/solidstart/src/common/tunnel.ts new file mode 100644 index 000000000000..23d10c19e1bc --- /dev/null +++ b/packages/solidstart/src/common/tunnel.ts @@ -0,0 +1,17 @@ +import type { Options } from '@sentry/core'; +import { applyTunnelDefaults } from '@sentry/core'; + +/** Set on the server by `withSentry` to the app's `server.baseURL`. */ +export const TUNNEL_BASE_PATH_GLOBAL = '__sentry_solidstart_base_path'; + +declare const __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__: string | undefined; + +/** + * Serves the browser's managed tunnel under the app's `server.baseURL`, which `withSentry` defines at build time. + * A `basePath` the user set wins. + */ +export function applyTunnelBasePath(options: Pick): void { + const basePath = + typeof __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__ !== 'undefined' ? __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__ : ''; + options.tunnel = applyTunnelDefaults(options.tunnel, { basePath }); +} diff --git a/packages/solidstart/src/config/types.ts b/packages/solidstart/src/config/types.ts index 14e814c78195..d9c13ae76ebb 100644 --- a/packages/solidstart/src/config/types.ts +++ b/packages/solidstart/src/config/types.ts @@ -9,6 +9,7 @@ export type RollupConfig = { export type SolidStartInlineConfig = NonNullable[0]>; export type SolidStartInlineServerConfig = { + plugins?: string[]; hooks?: { close?: () => unknown; 'rollup:before'?: (nitro: Nitro) => unknown; diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts index e4b0f6d08b49..973bf6c78666 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -1,4 +1,5 @@ -import { debug } from '@sentry/core'; +import { fileURLToPath } from 'node:url'; +import { debug, GLOBAL_OBJ, normalizeTunnelBasePath } from '@sentry/core'; import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; import { commonJSInteropOptions, @@ -6,6 +7,7 @@ import { sentryOrchestrionPlugin, } from '@sentry/server-utils/orchestrion/rollup'; import type { Nitro, NitroConfig } from 'nitropack'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; import { addSentryPluginToVite } from '../vite/sentrySolidStartVite'; import type { SentrySolidStartPluginOptions } from '../vite/types'; import { @@ -46,11 +48,21 @@ export function withSentry( }; const server = (solidStartConfig.server || {}) as SolidStartInlineServerConfig; + // Requests reach the tunnel with `server.baseURL` still in front, so both sides serve it under that. + const tunnelBasePath = normalizeTunnelBasePath( + (server as SolidStartInlineServerConfig & { baseURL?: string }).baseURL, + ); + // vinxi's dev server runs Nitro in this process, so the tunnel plugin can read it from here in dev. + if (tunnelBasePath) { + (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL] = tunnelBasePath; + } + const viteConfig = solidStartConfig.vite; const vite = typeof viteConfig === 'function' - ? (...args: Parameters) => addSentryPluginToVite(viteConfig(...args), sentryPluginOptions) - : addSentryPluginToVite(viteConfig, sentryPluginOptions); + ? (...args: Parameters) => + addSentryPluginToVite(viteConfig(...args), sentryPluginOptions, tunnelBasePath) + : addSentryPluginToVite(viteConfig, sentryPluginOptions, tunnelBasePath); const addBuildTimeInstrumentation = sentryPluginOptions.buildTimeInstrumentation !== false; @@ -61,6 +73,14 @@ export function withSentry( // but at runtime it holds resolved plugin objects we can push onto, so narrow to our own shape. const sentryRollupConfig = rollupConfig as unknown as RollupConfig; + if (tunnelBasePath) { + // The built server runs in another process, so the tunnel plugin reads the base path from the bundle. + sentryRollupConfig.plugins.push({ + name: 'sentry-solidstart-tunnel-base-path', + banner: `globalThis.${TUNNEL_BASE_PATH_GLOBAL} = ${JSON.stringify(tunnelBasePath)};`, + }); + } + if (addBuildTimeInstrumentation) { sentryRollupConfig.plugins.push( sentryOrchestrionPlugin({ buildTimeInstrumentation: sentryPluginOptions.buildTimeInstrumentation }), @@ -116,6 +136,15 @@ export function withSentry( externals, commonJS, modules: [...existingModules, sentryNitroModule], + // Through `server.plugins`, since vinxi's dev server ignores plugins that Nitro modules add. + plugins: [...(server.plugins || []), getTunnelPluginPath()], }, }; } + +function getTunnelPluginPath(): string { + const url = new URL('../nitro/tunnelPlugin.js', import.meta.url); + + // `import.meta.url` is a file URL in the built package, but not in every test environment. + return url.protocol === 'file:' ? fileURLToPath(url) : url.pathname; +} diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts new file mode 100644 index 000000000000..017fd3004e78 --- /dev/null +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -0,0 +1,33 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { handleTunnelRequestIfMatched, isTunnelRequest, setManagedTunnelDefaults } from '@sentry/core/server'; +import { eventHandler, toWebRequest } from 'h3'; +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. + */ +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. + setManagedTunnelDefaults({ + 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 (!isTunnelRequest({ 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 handleTunnelRequestIfMatched(toWebRequest(event)); + }), + }); +}) satisfies NitroAppPlugin; diff --git a/packages/solidstart/src/vite/sentrySolidStartVite.ts b/packages/solidstart/src/vite/sentrySolidStartVite.ts index 8bb0a55741d1..61b884c29d2e 100644 --- a/packages/solidstart/src/vite/sentrySolidStartVite.ts +++ b/packages/solidstart/src/vite/sentrySolidStartVite.ts @@ -7,10 +7,22 @@ import type { SentrySolidStartPluginOptions } from './types'; /** * Various Sentry vite plugins to be used for SolidStart. */ -export const sentrySolidStartVite = (options: SentrySolidStartPluginOptions = {}, viteConfig: UserConfig): Plugin[] => { +export const sentrySolidStartVite = ( + options: SentrySolidStartPluginOptions = {}, + viteConfig: UserConfig, + tunnelBasePath = '', +): Plugin[] => { warnOnRemovedBuildOptions(options, ['unstable_sentryVitePluginOptions']); - const sentryPlugins: Plugin[] = []; + const sentryPlugins: Plugin[] = [ + { + name: 'sentry-solidstart-tunnel-base-path', + // Serves the browser's managed tunnel under `server.baseURL`, like every other route of the app. + config: () => ({ + define: { __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__: JSON.stringify(tunnelBasePath) }, + }), + }, + ]; if (options.autoInjectServerSentry !== 'experimental_dynamic-import') { sentryPlugins.push(makeBuildInstrumentationFilePlugin(options)); @@ -31,9 +43,13 @@ export const sentrySolidStartVite = (options: SentrySolidStartPluginOptions = {} /** * Helper to add the Sentry SolidStart vite plugin to a vite config. */ -export const addSentryPluginToVite = (config: UserConfig = {}, options: SentrySolidStartPluginOptions): UserConfig => { +export const addSentryPluginToVite = ( + config: UserConfig = {}, + options: SentrySolidStartPluginOptions, + tunnelBasePath?: string, +): UserConfig => { const plugins = Array.isArray(config.plugins) ? [...config.plugins] : []; - plugins.unshift(sentrySolidStartVite(options, config)); + plugins.unshift(sentrySolidStartVite(options, config, tunnelBasePath)); return { ...config, diff --git a/packages/solidstart/test/common/tunnel.test.ts b/packages/solidstart/test/common/tunnel.test.ts new file mode 100644 index 000000000000..25263d12d950 --- /dev/null +++ b/packages/solidstart/test/common/tunnel.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { applyTunnelBasePath } from '../../src/common/tunnel'; + +describe('applyTunnelBasePath', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('serves the managed tunnel under the defined base path', () => { + vi.stubGlobal('__SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__', '/app'); + const options: Parameters[0] = { tunnel: true }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/app' }); + }); + + it('leaves the tunnel alone without a base path', () => { + const options: Parameters[0] = { tunnel: true }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toBe(true); + }); +}); diff --git a/packages/solidstart/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts index 3afba804811d..c4c056319b55 100644 --- a/packages/solidstart/test/config/withSentry.test.ts +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -78,6 +78,16 @@ describe('withSentry()', () => { expect(addInstrumentationFileToBuildMock).toHaveBeenCalledWith(nitroOptions); }); + it('registers the managed tunnel plugin through server.plugins and keeps user plugins', () => { + const config = withSentry({ server: { plugins: ['./user-plugin.ts'] } }, {}); + + expect((config?.server as { plugins?: string[] }).plugins).toEqual([ + './user-plugin.ts', + // Vitest serves the source file, the built package the transpiled one. + expect.stringMatching(/\/nitro\/tunnelPlugin\.[jt]s$/), + ]); + }); + it('does not override user-defined hooks in server.hooks', () => { const config = withSentry(solidStartConfig, {}); expect(config?.server.hooks?.['rollup:before']).toBe(userDefinedNitroRollupBeforeHookMock); @@ -116,6 +126,7 @@ describe('withSentry()', () => { }); const names = config?.vite.plugins.flat().map((plugin: Plugin) => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', @@ -139,6 +150,7 @@ describe('withSentry()', () => { const names = config?.vite.plugins.flat().map((plugin: Plugin) => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', @@ -166,6 +178,7 @@ describe('withSentry()', () => { .plugins.flat() .map((plugin: Plugin) => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', diff --git a/packages/solidstart/test/nitro/tunnelPlugin.test.ts b/packages/solidstart/test/nitro/tunnelPlugin.test.ts new file mode 100644 index 000000000000..8cc1ffe894ab --- /dev/null +++ b/packages/solidstart/test/nitro/tunnelPlugin.test.ts @@ -0,0 +1,72 @@ +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +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); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('answers tunnel requests before route rules and the app when `init` enabled the managed tunnel', async () => { + const options = { dsn: DSN, _managedTunnel: {} }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as 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('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/solidstart/test/vite/sentrySolidStartVite.test.ts b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts index 545638f9e3dc..db2382efdc45 100644 --- a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts +++ b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts @@ -26,6 +26,7 @@ describe('sentrySolidStartVite()', () => { const plugins = getSentrySolidStartVitePlugins(); const names = plugins.map(plugin => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', @@ -35,7 +36,7 @@ describe('sentrySolidStartVite()', () => { it("returns only build-instrumentation-file plugin if source maps upload isn't enabled", () => { const plugins = getSentrySolidStartVitePlugins({ sourcemaps: { disable: true } }); const names = plugins.map(plugin => plugin.name); - expect(names).toEqual(['sentry-solidstart-build-instrumentation-file']); + expect(names).toEqual(['sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file']); }); it('returns only build-instrumentation-file plugin if `NODE_ENV` is development', async () => { @@ -44,7 +45,7 @@ describe('sentrySolidStartVite()', () => { const plugins = getSentrySolidStartVitePlugins({ sourcemaps: { disable: false } }); const names = plugins.map(plugin => plugin.name); - expect(names).toEqual(['sentry-solidstart-build-instrumentation-file']); + expect(names).toEqual(['sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file']); process.env.NODE_ENV = previousEnv; });