diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json index 9fd694db92d4..04b5c40c783a 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json @@ -9,7 +9,10 @@ "start": "HOST=localhost PORT=3030 node .output/server/index.mjs", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod" + "test:assert": "pnpm test:prod", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "type": "module", "dependencies": { @@ -38,6 +41,18 @@ "node": ">=24" }, "sentryTest": { - "optional": true + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "solidstart-2 (tunnel)" + }, + { + "label": "solidstart-2 (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs index b52ff06a5105..2d40cab81f7e 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs @@ -1,7 +1,7 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; const config = getPlaywrightConfig({ - startCommand: 'pnpm start', + startCommand: process.env.TEST_ENV === 'development' ? 'pnpm exec vite dev --port 3030' : 'pnpm start', port: 3030, }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts index 812abe8255fb..b28336bff817 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts @@ -7,7 +7,7 @@ export default definePlugin(() => { 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-2/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx index 11087fbb5918..ce51cefc79f3 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx @@ -3,13 +3,16 @@ 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', + // The tunnel variant forwards to the test proxy, so the DSN has to point there. + 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-2/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/tests/tunnel.test.ts new file mode 100644 index 000000000000..92b0892e5925 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/tests/tunnel.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'solidstart-2'; +const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; + +test('answers the managed tunnel and drops its server span', async ({ page }) => { + const tunnelServerSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/users/6', + ); + await page.request.get('/users/6'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts index d3ffd97997a9..28c43c71e6e1 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts @@ -4,6 +4,9 @@ import { nitro } from 'nitro/vite'; import { defineConfig } from 'vite'; export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, plugins: [ solidStart({ appRoot: './src', 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 ac2b8c393432..118efe66bc77 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -10,7 +10,13 @@ "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": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "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-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development 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 +36,35 @@ "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", + "assert-command": "pnpm test:assert:tunnel", + "label": "solidstart (tunnel)" + }, + { + "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-dev", + "label": "solidstart (tunnel-dev)" + }, + { + "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..3ff1081fff07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } 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 tunnelServerSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + APP_NAME, + span => + getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/users/6`, + ); + await page.request.get(`${BASE_PATH}/users/6`); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/packages/solidstart/rollup.npm.config.mjs b/packages/solidstart/rollup.npm.config.mjs index d28b29896b10..9b296fb352ae 100644 --- a/packages/solidstart/rollup.npm.config.mjs +++ b/packages/solidstart/rollup.npm.config.mjs @@ -13,6 +13,7 @@ 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) 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..09fbf034b1eb --- /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 ? { basePath } : undefined); +} 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..edb892a5fcdc --- /dev/null +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -0,0 +1,60 @@ +import { Readable } from 'node:stream'; +import { + getClient, + GLOBAL_OBJ, + handleTunnelRequestIfMatched, + isTunnelRequest, + setManagedTunnelBasePath, +} from '@sentry/core'; +import type { NitroAppPlugin } from 'nitropack'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; + +/** + * Answers managed tunnel requests on SolidStart v1 (Nitro v2). The `request` hook runs before the app's middleware + * and routing. `withSentry` registers it. + */ +export default (nitroApp => { + // The hook sees the path before Nitro strips `server.baseURL`, so the tunnel is served under it. `init` may run + // after this plugin, so this is repeated for the first requests after it too. + const applyBaseURL = (): void => + setManagedTunnelBasePath( + getClient(), + (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL], + ); + applyBaseURL(); + + nitroApp.hooks.hook('request', async event => { + applyBaseURL(); + + const { req, res } = event.node; + // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. + if (!isTunnelRequest(req)) { + return; + } + + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (value !== undefined) { + headers.set(name, Array.isArray(value) ? value.join(', ') : value); + } + } + + const tunnelResponse = await handleTunnelRequestIfMatched( + new Request(`http://${req.headers.host}${req.url}`, { + method: req.method, + headers, + body: Readable.toWeb(req) as ReadableStream, + // Required by Node's `fetch` for a streamed body. + duplex: 'half', + } as RequestInit), + ); + if (!tunnelResponse) { + return; + } + + // Writing the response marks the event as handled, so Nitro stops here. + res.statusCode = tunnelResponse.status; + tunnelResponse.headers.forEach((value, name) => res.setHeader(name, value)); + res.end(Buffer.from(await tunnelResponse.arrayBuffer())); + }); +}) 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..a3d7168b0958 --- /dev/null +++ b/packages/solidstart/test/common/tunnel.test.ts @@ -0,0 +1,28 @@ +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, unless the user set one', () => { + vi.stubGlobal('__SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__', '/app'); + const managed: Parameters[0] = { tunnel: true }; + const own: Parameters[0] = { tunnel: { basePath: '/custom' } }; + + applyTunnelBasePath(managed); + applyTunnelBasePath(own); + + expect(managed.tunnel).toEqual({ basePath: '/app' }); + expect(own.tunnel).toEqual({ basePath: '/custom' }); + }); + + 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..eac0ed370ce4 --- /dev/null +++ b/packages/solidstart/test/nitro/tunnelPlugin.test.ts @@ -0,0 +1,55 @@ +import { Readable } from 'node:stream'; +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +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; + +function setupPlugin(): RequestHook { + let requestHook: RequestHook | undefined; + (tunnelPlugin as (nitroApp: unknown) => void)({ + hooks: { hook: (name: string, fn: RequestHook) => name === 'request' && (requestHook = fn) }, + }); + + return requestHook!; +} + +function makeEvent(method: string, url: string, body = ''): { event: unknown; res: { end: ReturnType } } { + const req = Object.assign(Readable.from([Buffer.from(body)]), { method, url, headers: { host: 'localhost' } }); + const res = { statusCode: 200, setHeader: vi.fn(), end: vi.fn() }; + + return { event: { node: { req, res } }, res }; +} + +describe('SolidStart v1 tunnel plugin', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + vi.unstubAllGlobals(); + }); + + it('answers tunnel requests from the request hook when `init` enabled the managed tunnel', async () => { + const options = { dsn: DSN, _managedTunnel: {} }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const envelope = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; + const { event, res } = makeEvent('POST', getTunnelPath(DSN)!, envelope); + + await setupPlugin()(event); + + expect(res.end).toHaveBeenCalledWith(Buffer.from('forwarded')); + }); + + it('leaves every other request to the app', async () => { + const { event, res } = makeEvent('POST', '/api/users'); + + await setupPlugin()(event); + + expect(res.end).not.toHaveBeenCalled(); + }); +}); 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; });