From cc7f0a3021bedfc050b31cb4f48e2d7561e312cd Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 13:35:14 -0400 Subject: [PATCH 1/7] feat(solidstart): Serve the managed tunnel on SolidStart v1 `withSentry` registers a Nitro v2 plugin that answers tunnel requests from the `request` hook, before the app's middleware. It goes through `server.plugins` because vinxi's dev server ignores plugins that Nitro modules add. --- .../solidstart/app.config.ts | 5 ++ .../test-applications/solidstart/package.json | 13 ++++- .../solidstart/src/entry-client.tsx | 6 +- .../solidstart/src/instrument.server.ts | 2 +- .../solidstart/src/middleware.ts | 9 +++ .../solidstart/tests/tunnel.test.ts | 40 ++++++++++++++ packages/solidstart/rollup.npm.config.mjs | 1 + packages/solidstart/src/config/types.ts | 1 + packages/solidstart/src/config/withSentry.ts | 10 ++++ packages/solidstart/src/nitro/tunnelPlugin.ts | 42 ++++++++++++++ .../solidstart/test/config/withSentry.test.ts | 10 ++++ .../test/nitro/tunnelPlugin.test.ts | 55 +++++++++++++++++++ 12 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts create mode 100644 packages/solidstart/src/nitro/tunnelPlugin.ts create mode 100644 packages/solidstart/test/nitro/tunnelPlugin.test.ts 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..671f25fe0fa5 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,10 @@ import { defineConfig } from '@solidjs/start/config'; export default defineConfig( withSentry({ middleware: './src/middleware.ts', + vite: { + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, + }, }), ); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index ac2b8c393432..6ba22eba8161 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -10,7 +10,9 @@ "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:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" }, "type": "module", "dependencies": { @@ -36,5 +38,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "solidstart (tunnel)" + } + ] } } 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..c07a1ab5f709 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts @@ -0,0 +1,40 @@ +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 TUNNEL_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('/', { 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('/'); + 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/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/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..27354d4f790a 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import { debug } from '@sentry/core'; import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; import { @@ -116,6 +117,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..cb84fc9abfeb --- /dev/null +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -0,0 +1,42 @@ +import { Readable } from 'node:stream'; +import { handleTunnelRequestIfMatched, isTunnelRequest } from '@sentry/core'; +import type { NitroAppPlugin } from 'nitropack'; + +/** + * 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 => { + nitroApp.hooks.hook('request', async event => { + 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/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts index 3afba804811d..74b00a2d2e6b 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); 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(); + }); +}); From b9ef081e55b6746e6ecb309b0f40c48d319a4c81 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 13:16:37 -0400 Subject: [PATCH 2/7] fix(solidstart): Serve the managed tunnel under `server.baseURL` --- .../solidstart/app.config.ts | 2 + .../test-applications/solidstart/package.json | 9 ++++- .../solidstart/tests/tunnel.test.ts | 12 +++--- packages/solidstart/src/client/sdk.ts | 2 + packages/solidstart/src/common/tunnel.ts | 24 ++++++++++++ packages/solidstart/src/config/withSentry.ts | 23 +++++++++-- packages/solidstart/src/nitro/tunnelPlugin.ts | 20 +++++++++- .../src/vite/sentrySolidStartVite.ts | 24 ++++++++++-- .../solidstart/test/common/tunnel.test.ts | 38 +++++++++++++++++++ .../solidstart/test/config/withSentry.test.ts | 3 ++ .../test/vite/sentrySolidStartVite.test.ts | 5 ++- 11 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 packages/solidstart/src/common/tunnel.ts create mode 100644 packages/solidstart/test/common/tunnel.test.ts 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 671f25fe0fa5..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,6 +4,8 @@ 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/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index 6ba22eba8161..8fd0ffc0edda 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -12,7 +12,9 @@ "test:assert": "pnpm test:prod && pnpm test:dev", "test:dev": "TEST_ENV=development playwright test db", "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: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" }, "type": "module", "dependencies": { @@ -45,6 +47,11 @@ "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)" } ] } 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 index c07a1ab5f709..3ff1081fff07 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts @@ -4,11 +4,12 @@ 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 TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; +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('/', { data: 'x' })).status()).toBe(401); + expect((await page.request.post(`${BASE_PATH}/`, { data: 'x' })).status()).toBe(401); const tunnelServerSpanPromise = waitForStreamedSpan( APP_NAME, @@ -18,7 +19,7 @@ test('answers the managed tunnel before the app middleware', async ({ page }) => response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), ); - await page.goto('/'); + await page.goto(`${BASE_PATH}/`); const tunnelResponse = await tunnelResponsePromise; expect(tunnelResponse.status()).toBe(200); @@ -28,9 +29,10 @@ test('answers the managed tunnel before the app middleware', async ({ page }) => // would arrive first. const anchorSpanPromise = waitForStreamedSpan( APP_NAME, - span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/users/6', + span => + getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/users/6`, ); - await page.request.get('/users/6'); + await page.request.get(`${BASE_PATH}/users/6`); const winner = await Promise.race([ tunnelServerSpanPromise.then(() => 'tunnel' as const), 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..1f02ed1f6189 --- /dev/null +++ b/packages/solidstart/src/common/tunnel.ts @@ -0,0 +1,24 @@ +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; + +/** + * Normalizes SolidStart's `server.baseURL` to the base path the managed tunnel is served under. + */ +export function toTunnelBasePath(baseURL: string | undefined): string { + return (baseURL ?? '').replace(/^\.?\/+|\/+$/g, '').replace(/^(?=.)/, '/'); +} + +/** + * 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/withSentry.ts b/packages/solidstart/src/config/withSentry.ts index 27354d4f790a..b95e2c840b11 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from 'node:url'; -import { debug } from '@sentry/core'; +import { debug, GLOBAL_OBJ } from '@sentry/core'; import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; import { commonJSInteropOptions, @@ -7,6 +7,7 @@ import { sentryOrchestrionPlugin, } from '@sentry/server-utils/orchestrion/rollup'; import type { Nitro, NitroConfig } from 'nitropack'; +import { TUNNEL_BASE_PATH_GLOBAL, toTunnelBasePath } from '../common/tunnel'; import { addSentryPluginToVite } from '../vite/sentrySolidStartVite'; import type { SentrySolidStartPluginOptions } from '../vite/types'; import { @@ -47,11 +48,19 @@ 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 = toTunnelBasePath((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; @@ -62,6 +71,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 }), diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts index cb84fc9abfeb..edb892a5fcdc 100644 --- a/packages/solidstart/src/nitro/tunnelPlugin.ts +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -1,13 +1,31 @@ import { Readable } from 'node:stream'; -import { handleTunnelRequestIfMatched, isTunnelRequest } from '@sentry/core'; +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)) { 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..68c1fd2e6938 --- /dev/null +++ b/packages/solidstart/test/common/tunnel.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { applyTunnelBasePath, toTunnelBasePath } from '../../src/common/tunnel'; + +describe('toTunnelBasePath', () => { + it('turns `server.baseURL` into a base path, and the root into none', () => { + expect(toTunnelBasePath('/app/')).toBe('/app'); + expect(toTunnelBasePath('app')).toBe('/app'); + expect(toTunnelBasePath('/')).toBe(''); + expect(toTunnelBasePath('./')).toBe(''); + expect(toTunnelBasePath(undefined)).toBe(''); + }); +}); + +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 74b00a2d2e6b..c4c056319b55 100644 --- a/packages/solidstart/test/config/withSentry.test.ts +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -126,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', @@ -149,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', @@ -176,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/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; }); From 914ef3a8a077dbe63077b7c1804b518e03a4379d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 13:18:17 -0400 Subject: [PATCH 3/7] test(solidstart): Check the managed tunnel on SolidStart 2 --- .../solidstart-2/package.json | 13 ++++++- .../solidstart-2/server/plugins/sentry.ts | 2 +- .../solidstart-2/src/entry-client.tsx | 7 +++- .../solidstart-2/tests/tunnel.test.ts | 37 +++++++++++++++++++ .../solidstart-2/vite.config.ts | 3 ++ 5 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/solidstart-2/tests/tunnel.test.ts 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..005782087ec5 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,9 @@ "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" }, "type": "module", "dependencies": { @@ -38,6 +40,13 @@ "node": ">=24" }, "sentryTest": { - "optional": true + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "solidstart-2 (tunnel)" + } + ] } } 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', From dc6d61aa5bd99b5c11cf1aa08f6f093e5aba5aee Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 14:53:32 -0400 Subject: [PATCH 4/7] test(solidstart): Check the managed tunnel under the SolidStart 2 dev server too --- .../e2e-tests/test-applications/solidstart-2/package.json | 8 +++++++- .../test-applications/solidstart-2/playwright.config.mjs | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) 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 005782087ec5..04b5c40c783a 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json @@ -11,7 +11,8 @@ "test:build": "pnpm install && pnpm build", "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": "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": { @@ -46,6 +47,11 @@ "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, }); From 7e8fde04cce8fda78abc162c306a45818dcc9ed8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 15:09:06 -0400 Subject: [PATCH 5/7] test(solidstart): Check the managed tunnel under `vinxi dev` too --- .../solidstart/instrument.server.mjs | 2 +- .../test-applications/solidstart/package.json | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) 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 8fd0ffc0edda..118efe66bc77 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -14,7 +14,9 @@ "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-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": { @@ -34,8 +36,8 @@ "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": { @@ -52,6 +54,16 @@ "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)" } ] } From 46ac961b63f40727b6b30e65a0f5bacb644decf2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 17:01:31 -0400 Subject: [PATCH 6/7] fix(solidstart): Normalize the tunnel base path with the core util --- packages/solidstart/src/common/tunnel.ts | 7 ------- packages/solidstart/src/config/withSentry.ts | 8 +++++--- packages/solidstart/test/common/tunnel.test.ts | 12 +----------- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/packages/solidstart/src/common/tunnel.ts b/packages/solidstart/src/common/tunnel.ts index 1f02ed1f6189..09fbf034b1eb 100644 --- a/packages/solidstart/src/common/tunnel.ts +++ b/packages/solidstart/src/common/tunnel.ts @@ -6,13 +6,6 @@ export const TUNNEL_BASE_PATH_GLOBAL = '__sentry_solidstart_base_path'; declare const __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__: string | undefined; -/** - * Normalizes SolidStart's `server.baseURL` to the base path the managed tunnel is served under. - */ -export function toTunnelBasePath(baseURL: string | undefined): string { - return (baseURL ?? '').replace(/^\.?\/+|\/+$/g, '').replace(/^(?=.)/, '/'); -} - /** * Serves the browser's managed tunnel under the app's `server.baseURL`, which `withSentry` defines at build time. * A `basePath` the user set wins. diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts index b95e2c840b11..973bf6c78666 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from 'node:url'; -import { debug, GLOBAL_OBJ } from '@sentry/core'; +import { debug, GLOBAL_OBJ, normalizeTunnelBasePath } from '@sentry/core'; import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; import { commonJSInteropOptions, @@ -7,7 +7,7 @@ import { sentryOrchestrionPlugin, } from '@sentry/server-utils/orchestrion/rollup'; import type { Nitro, NitroConfig } from 'nitropack'; -import { TUNNEL_BASE_PATH_GLOBAL, toTunnelBasePath } from '../common/tunnel'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; import { addSentryPluginToVite } from '../vite/sentrySolidStartVite'; import type { SentrySolidStartPluginOptions } from '../vite/types'; import { @@ -49,7 +49,9 @@ 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 = toTunnelBasePath((server as SolidStartInlineServerConfig & { baseURL?: string }).baseURL); + 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; diff --git a/packages/solidstart/test/common/tunnel.test.ts b/packages/solidstart/test/common/tunnel.test.ts index 68c1fd2e6938..a3d7168b0958 100644 --- a/packages/solidstart/test/common/tunnel.test.ts +++ b/packages/solidstart/test/common/tunnel.test.ts @@ -1,15 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { applyTunnelBasePath, toTunnelBasePath } from '../../src/common/tunnel'; - -describe('toTunnelBasePath', () => { - it('turns `server.baseURL` into a base path, and the root into none', () => { - expect(toTunnelBasePath('/app/')).toBe('/app'); - expect(toTunnelBasePath('app')).toBe('/app'); - expect(toTunnelBasePath('/')).toBe(''); - expect(toTunnelBasePath('./')).toBe(''); - expect(toTunnelBasePath(undefined)).toBe(''); - }); -}); +import { applyTunnelBasePath } from '../../src/common/tunnel'; describe('applyTunnelBasePath', () => { afterEach(() => { From e04a1eb62dfdbbdaefd3a3b829020b55a14d9b95 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 17 Oct 2026 21:22:44 -0400 Subject: [PATCH 7/7] fix(solidstart): Read the tunnel request with h3 so serverless presets' mock requests work --- packages/solidstart/rollup.npm.config.mjs | 2 +- packages/solidstart/src/nitro/tunnelPlugin.ts | 34 ++++------------- .../test/nitro/tunnelPlugin.test.ts | 38 +++++++++---------- 3 files changed, 26 insertions(+), 48 deletions(-) diff --git a/packages/solidstart/rollup.npm.config.mjs b/packages/solidstart/rollup.npm.config.mjs index 9b296fb352ae..994c3cbe8fa7 100644 --- a/packages/solidstart/rollup.npm.config.mjs +++ b/packages/solidstart/rollup.npm.config.mjs @@ -18,7 +18,7 @@ export default makeNPMConfigVariants( // 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/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts index edb892a5fcdc..b6ed40b376d9 100644 --- a/packages/solidstart/src/nitro/tunnelPlugin.ts +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -1,4 +1,3 @@ -import { Readable } from 'node:stream'; import { getClient, GLOBAL_OBJ, @@ -6,6 +5,7 @@ import { isTunnelRequest, setManagedTunnelBasePath, } from '@sentry/core'; +import { sendWebResponse, toWebRequest } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; @@ -26,35 +26,17 @@ export default (nitroApp => { 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)) { + if (!isTunnelRequest({ method: event.method, url: event.path })) { 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); - } + // `toWebRequest` also reads the body of the mock requests serverless presets pass through `localFetch`, which + // aren't Node streams. + const tunnelResponse = handleTunnelRequestIfMatched(toWebRequest(event)); + if (tunnelResponse) { + // Sending the response marks the event as handled, so Nitro stops here. + await sendWebResponse(event, await tunnelResponse); } - - 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/test/nitro/tunnelPlugin.test.ts b/packages/solidstart/test/nitro/tunnelPlugin.test.ts index eac0ed370ce4..070c574263d3 100644 --- a/packages/solidstart/test/nitro/tunnelPlugin.test.ts +++ b/packages/solidstart/test/nitro/tunnelPlugin.test.ts @@ -1,6 +1,7 @@ -import { Readable } from 'node:stream'; import type { Client } from '@sentry/core'; import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { createApp, eventHandler, toNodeListener } from 'h3'; +import { fetchNodeRequestHandler } from 'node-mock-http'; import { afterEach, describe, expect, it, vi } from 'vitest'; import tunnelPlugin from '../../src/nitro/tunnelPlugin'; @@ -8,20 +9,19 @@ const DSN = 'https://public@o1.ingest.sentry.io/1337'; type RequestHook = (event: unknown) => Promise; -function setupPlugin(): RequestHook { +/** + * Calls an h3 app running the plugin's `request` hook the way Nitro v2's `localFetch` does, with node-mock-http's + * request, which isn't a Node stream. Serverless presets pass every request through this. + */ +function fetchApp(url: string, init: RequestInit): Promise { let requestHook: RequestHook | undefined; (tunnelPlugin as (nitroApp: unknown) => void)({ hooks: { hook: (name: string, fn: RequestHook) => name === 'request' && (requestHook = fn) }, }); + const app = createApp({ onRequest: event => requestHook!(event) }); + app.use(eventHandler(() => 'app')); - 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 }; + return fetchNodeRequestHandler(toNodeListener(app), `http://localhost${url}`, init); } describe('SolidStart v1 tunnel plugin', () => { @@ -33,23 +33,19 @@ describe('SolidStart v1 tunnel plugin', () => { 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 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 { event, res } = makeEvent('POST', getTunnelPath(DSN)!, envelope); - await setupPlugin()(event); + const response = await fetchApp(getTunnelPath(DSN)!, { method: 'POST', body: envelope }); - expect(res.end).toHaveBeenCalledWith(Buffer.from('forwarded')); + expect(await response.text()).toBe('forwarded'); + expect(await new Response(sentryFetch.mock.lastCall![1].body).text()).toBe(envelope); }); it('leaves every other request to the app', async () => { - const { event, res } = makeEvent('POST', '/api/users'); - - await setupPlugin()(event); + const response = await fetchApp('/api/users', { method: 'POST', body: 'x' }); - expect(res.end).not.toHaveBeenCalled(); + expect(await response.text()).toBe('app'); }); });