diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs index 53b80d309a5b..ff6290e461a4 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/nitro'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : process.env.E2E_TEST_DSN, + tunnel: process.env.E2E_TEST_TUNNEL ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1, }); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json index 82836a613593..cf6241cf5ff0 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -9,7 +9,10 @@ "clean": "npx rimraf node_modules pnpm-lock.yaml .output", "test": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "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 playwright test tunnel", + "test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel" }, "dependencies": { "@sentry/browser": "latest || *", @@ -26,5 +29,19 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nitro-3 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "nitro-3 (tunnel-basepath-dev)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs index 31f2b913b58b..62bc93244137 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs @@ -1,7 +1,10 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; const config = getPlaywrightConfig({ - startCommand: `pnpm start`, + startCommand: + process.env.TEST_ENV === 'development' + ? `PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' pnpm exec vite dev --port 3030` + : `pnpm start`, }); export default config; diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts index 92d8f80c3756..0120ae6ce24e 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts @@ -1,6 +1,11 @@ -import { defineHandler, getQuery, setResponseHeader } from 'nitro/h3'; +import { defineHandler, getQuery, HTTPError, setResponseHeader } from 'nitro/h3'; export default defineHandler(event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.req.method === 'POST' && event.req.headers.get('x-auth') !== 'ok') { + throw new HTTPError({ status: 401, statusText: 'blocked by user auth' }); + } + setResponseHeader(event, 'x-sentry-test-middleware', 'executed'); const query = getQuery(event); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts index d27d0ba1763a..d09a43e411ed 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts @@ -1,10 +1,18 @@ import * as Sentry from '@sentry/browser'; +declare const __E2E_TEST_TUNNEL__: boolean; +declare const __E2E_TEST_BASE_PATH__: string; + // Let's us test trace propagation Sentry.init({ environment: 'qa', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tunnel: 'http://localhost:3031/', // proxy server + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', + // Plain Nitro has no client SDK to pass `baseURL` on, so the browser names it. The server picks it up itself. + tunnel: __E2E_TEST_TUNNEL__ + ? __E2E_TEST_BASE_PATH__ + ? { basePath: __E2E_TEST_BASE_PATH__ } + : true + : 'http://localhost:3031/', // proxy server integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts new file mode 100644 index 000000000000..64f71280d8c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from '@playwright/test'; +import { + isServerSpanAt, + makeClientReportEnvelope, + waitForError, + watchForTunnelSpan, +} from '@sentry-internal/test-utils'; +import { getTunnelPath } from '@sentry/core'; +import { connect } from 'node:net'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +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 auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); + + const raceTunnelSpan = watchForTunnelSpan('nitro-3', 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}/api/test-transaction`), () => + page.request.get(`${BASE_PATH}/api/test-transaction`), + ), + ).toBe('anchor'); +}); + +test('answers a request to the managed tunnel itself', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + const response = await request.post(`${BASE_PATH}${getTunnelPath(dsn)}`, { + data: makeClientReportEnvelope(dsn), + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(200); +}); + +test('does not report a client that drops the connection before the envelope header as an error', async ({ + request, +}) => { + const dsn = 'http://public@localhost:3031/1337'; + const firstErrorPromise = waitForError('nitro-3', () => true); + + // Part of the envelope header line, without its newline, then the connection drops. + await new Promise(resolve => { + const socket = connect(3030, 'localhost', () => { + socket.write( + `POST ${BASE_PATH}${getTunnelPath(dsn)} HTTP/1.1\r\nHost: localhost:3030\r\nContent-Type: application/x-sentry-envelope\r\nContent-Length: 1000000\r\n\r\n`, + ); + socket.write(JSON.stringify({ dsn, padding: 'x'.repeat(1000) }).slice(0, 500)); + setTimeout(() => { + socket.destroy(); + resolve(); + }, 300); + }); + }); + + // Errors are sent in order, so one from the dropped request would arrive before this one. + await request.get(`${BASE_PATH}/api/test-error`); + + const firstError = await firstErrorPromise; + expect(firstError.exception?.values?.[0]?.value).toBe('This is a test error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts index d488f8298777..b7c6de66d47c 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts @@ -3,12 +3,17 @@ import { nitro } from 'nitro/vite'; import { defineConfig } from 'vite'; export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + __E2E_TEST_BASE_PATH__: JSON.stringify(process.env.E2E_TEST_BASE_PATH ?? ''), + }, plugins: [ nitro( // FIXME: Nitro plugin has a type issue // @ts-expect-error withSentryConfig({ serverDir: './server', + ...(process.env.E2E_TEST_BASE_PATH && { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` }), }), ), ], diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts index 63f6e2e2633b..b83483ac0363 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts @@ -2,6 +2,8 @@ export default defineNuxtConfig({ compatibilityDate: '2025-06-06', modules: ['@sentry/nuxt/module'], + // The tunnel variant serves the app, and so the tunnel, under a base URL. + ...(process.env.E2E_TEST_TUNNEL && { app: { baseURL: '/app/' } }), nitro: { preset: 'cloudflare_module', cloudflare: { @@ -14,5 +16,9 @@ export default defineNuxtConfig({ alias: { 'readable-stream': 'node:stream', }, + // The tunnel variant serves the managed tunnel. + replace: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json index e58f8c4efe55..8b337c500493 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json @@ -8,7 +8,9 @@ "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030 --log-level=$(test $CI && echo 'none' || echo 'log')", "test": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test tunnel" }, "dependencies": { "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", @@ -23,5 +25,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nuxt-4-cloudflare (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts new file mode 100644 index 000000000000..cee7601e363b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts @@ -0,0 +1,4 @@ +import { defineEventHandler } from '#imports'; + +// A plain route for tests to request after the tunnel traffic. +export default defineEventHandler(() => 'ok'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts index faa56e1db7ef..794c67678b48 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts @@ -1,10 +1,13 @@ import { defineNitroPlugin } from '#imports'; import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins'; +declare const __E2E_TEST_TUNNEL__: boolean; + export default defineNitroPlugin( sentryCloudflareNitroPlugin({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel forwards to the DSN host, which is the event proxy in the tunnel variant. + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server }), ); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts new file mode 100644 index 000000000000..51e9df40bff3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from '@playwright/test'; +import { isServerSpanAt, makeClientReportEnvelope, watchForTunnelSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +// The isolate's first tunnel request is the one a late span filter would let through, so this stays the only test. +test('answers the managed tunnel set up by `sentryCloudflareNitroPlugin`, without a span of it', async ({ + request, +}) => { + const dsn = 'http://public@localhost:3031/1337'; + const raceTunnelSpan = watchForTunnelSpan('nuxt-4-cloudflare', isServerSpanAt(/^\/app\/qpo967/)); + + // The path the SDK derives from `dsn`. + const response = await request.post('/app/qpo967', { + data: makeClientReportEnvelope(dsn), + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(200); + expect(await raceTunnelSpan(isServerSpanAt('/app/api/anchor'), () => request.get('/app/api/anchor'))).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index c7acd2b12328..4f456ce2efdf 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -1,4 +1,6 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineNuxtConfig({ compatibilityDate: '2025-06-06', imports: { autoImport: false }, @@ -10,14 +12,21 @@ export default defineNuxtConfig({ '/rendering-modes/swr-cached-page': { swr: true }, '/rendering-modes/swr-1h-cached-page': { swr: 3600 }, '/rendering-modes/pre-rendered-page': { prerender: true }, + // A headers rule on every path, like a security headers setup, also covers the tunnel's path. + ...(tunnelVariant && { '/**': { headers: { 'x-e2e-route-rule': '1' } } }), }, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { app: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], runtimeConfig: { public: { sentry: { - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel variant forwards to the test proxy, so the DSN has to point there. + dsn: tunnelVariant ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', }, + e2eTunnel: tunnelVariant, }, }, nitro: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 92fa2a41d87b..1b467cf5d7e6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -16,7 +16,10 @@ "test:build": "pnpm install && node ./scripts/build-with-prerender-event-sink.mjs", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && node ./scripts/build-with-prerender-event-sink.mjs", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:prod:import": "TEST_ENV=production-import playwright test" + "test:prod:import": "TEST_ENV=production-import playwright test", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app node ./scripts/build-with-prerender-event-sink.mjs", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=development playwright test tunnel" }, "//": "Pin ioredis to 5.10.1 because that's the last version before it publishes its own diagnostics channels", "dependencies": { @@ -45,6 +48,16 @@ "build-command": "pnpm test:build", "assert-command": "pnpm test:prod:import", "label": "nuxt-4 (--import compat)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nuxt-4 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "nuxt-4 (tunnel-basepath-dev)" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts index 7b5d97ff0b09..64c786d5f7e8 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts @@ -3,7 +3,7 @@ import { usePinia, useRuntimeConfig } from '#imports'; Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: useRuntimeConfig().public.e2eTunnel ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, integrations: [ Sentry.piniaIntegration(usePinia(), { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts index acce04cc9d04..1e29b95f161a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', 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 // Opt into the Sentry OpenTelemetry tracer provider in the "(tracer provider)" e2e variant. // Leaving it `undefined` otherwise keeps the SDK's default (no provider). enableOpenTelemetrySetup: process.env.E2E_TEST_OTEL_SETUP === 'true' ? true : undefined, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts index 6dcd9a075589..471d1d32d7a4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts @@ -1,6 +1,11 @@ -import { defineEventHandler, setHeader, getQuery } from '#imports'; +import { createError, defineEventHandler, getHeader, setHeader, getQuery } from '#imports'; export default defineEventHandler(async event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.method === 'POST' && getHeader(event, 'x-auth') !== 'ok') { + throw createError({ statusCode: 401, statusMessage: 'blocked by user auth' }); + } + // Check if we should throw an error const query = getQuery(event); if (query.throwError === 'true') { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts new file mode 100644 index 000000000000..85a6f1953a9a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test'; +import { + isServerSpanAt, + makeClientReportEnvelope, + waitForError, + watchForTunnelSpan, +} from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +test.beforeAll(async ({ request }) => { + // `nuxt dev` opens the port before it's ready and answers 503 until then, and Playwright only waits for the port. + await expect + .poll(async () => (await request.get(`${process.env.E2E_TEST_BASE_PATH ?? ''}/`)).status(), { timeout: 30_000 }) + .not.toBe(503); +}); + +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 auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); + + const raceTunnelSpan = watchForTunnelSpan('nuxt-4', 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}/api/test-param/123`), () => + page.request.get(`${BASE_PATH}/api/test-param/123`), + ), + ).toBe('anchor'); +}); + +test('answers the managed tunnel once, without errors from route rules that run after it', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + // The path the SDK derives from `dsn`. + const tunnelPath = '/qpo967'; + const tunnelErrorPromise = waitForError( + 'nuxt-4', + event => !!event.exception?.values?.[0]?.value?.includes('Cannot set headers'), + ); + const anchorErrorPromise = waitForError( + 'nuxt-4', + event => event.exception?.values?.[0]?.value === 'Nuxt 4 Server error', + ); + + const response = await request.post(`${BASE_PATH}${tunnelPath}`, { + data: makeClientReportEnvelope(dsn), + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(200); + + // Errors are sent in order, so one from the tunnel request would arrive before this later one. + await request.get(`${BASE_PATH}/api/server-error`); + + const winner = await Promise.race([ + tunnelErrorPromise.then(() => 'tunnel' as const), + anchorErrorPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts index bdef334cfa88..8016834d1167 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts @@ -1,4 +1,6 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineNuxtConfig({ compatibilityDate: '2025-07-15', imports: { autoImport: false }, @@ -12,12 +14,17 @@ export default defineNuxtConfig({ '/rendering-modes/pre-rendered-page': { prerender: true }, }, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { app: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], runtimeConfig: { public: { sentry: { - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel variant forwards to the test proxy, so the DSN has to point there. + dsn: tunnelVariant ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', }, + e2eTunnel: tunnelVariant, }, }, nitro: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json index ef7d5c1fbc3c..af98a6c5d4a3 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -15,7 +15,10 @@ "test:dev": "TEST_ENV=development playwright test environment", "test:build": "pnpm install && pnpm build", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitro@npm:nitro-nightly@latest && pnpm install --force && pnpm build", - "test:assert": "pnpm test:prod && pnpm test:dev" + "test:assert": "pnpm test:prod && pnpm test:dev", + "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 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 TEST_ENV=development playwright test tunnel" }, "//": [ "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", @@ -38,7 +41,19 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, "sentryTest": { - "optional": true + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nuxt-5 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev", + "label": "nuxt-5 (tunnel-dev)" + } + ] }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts index 900df0811faa..f5a30830107b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts @@ -3,7 +3,7 @@ import { /* usePinia,*/ useRuntimeConfig } from '#imports'; Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: useRuntimeConfig().public.e2eTunnel ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, integrations: [ /* Sentry.piniaIntegration(usePinia(), { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts index 58d541b5228b..ae05c60355bb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', 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 // Several tests trigger the same server error one after another, which Dedupe would drop. integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts index 7216e9fc7560..62d2eebd07e0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts @@ -1,7 +1,12 @@ import { defineHandler } from 'nitro'; -import { getQuery } from 'nitro/h3'; +import { getQuery, HTTPError } from 'nitro/h3'; export default defineHandler(async event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.req.method === 'POST' && event.req.headers.get('x-auth') !== 'ok') { + throw new HTTPError({ status: 401, statusText: 'blocked by user auth' }); + } + // Check if we should throw an error const query = getQuery(event); if (query.throwError === 'true') { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts new file mode 100644 index 000000000000..51e8a022c623 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts @@ -0,0 +1,33 @@ +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'); + +test.beforeAll(async ({ request }) => { + // `nuxt dev` opens the port before it's ready and answers 503 until then, and Playwright only waits for the port. + await expect + .poll(async () => (await request.get(`${process.env.E2E_TEST_BASE_PATH ?? ''}/`)).status(), { timeout: 30_000 }) + .not.toBe(503); +}); + +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 auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); + + const raceTunnelSpan = watchForTunnelSpan('nuxt-5', 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}/api/test-param/123`), () => + page.request.get(`${BASE_PATH}/api/test-param/123`), + ), + ).toBe('anchor'); +}); diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index bbb9a8bf0014..2598fe61b886 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -17,7 +17,7 @@ export { getSpanOp, } from './event-proxy-server'; export type { SerializedStreamedSpan } from '@sentry/core'; -export { isServerSpanAt, watchForTunnelSpan } from './tunnel'; +export { isServerSpanAt, makeClientReportEnvelope, watchForTunnelSpan } from './tunnel'; export { findAbsolutePathImports, diff --git a/dev-packages/test-utils/src/tunnel.ts b/dev-packages/test-utils/src/tunnel.ts index 8c52955d385c..e0497174e357 100644 --- a/dev-packages/test-utils/src/tunnel.ts +++ b/dev-packages/test-utils/src/tunnel.ts @@ -35,3 +35,10 @@ export function watchForTunnelSpan( ]); }; } + +/** + * An envelope with an empty client report, which Sentry accepts without creating anything. + */ +export function makeClientReportEnvelope(dsn: string): string { + return `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`; +} diff --git a/packages/nitro/src/module.ts b/packages/nitro/src/module.ts index 1a4e5b0478d1..51f76c39eae3 100644 --- a/packages/nitro/src/module.ts +++ b/packages/nitro/src/module.ts @@ -1,3 +1,4 @@ +import { setManagedTunnelDefaults } from '@sentry/core/server'; import type { NitroModule } from 'nitro/types'; import type { SentryNitroOptions } from './config'; import { instrumentServer } from './instruments/instrumentServer'; @@ -12,6 +13,12 @@ export function createNitroModule(sentryOptions?: SentryNitroOptions, sentryEnab setup: nitro => { instrumentServer(nitro); setupSourceMaps(nitro, sentryOptions, sentryEnabledSourcemaps); + + // In dev, Vite's server takes each request in this process before Nitro handles it, and its `http.server` span + // comes from the SDK `--import` set up here. The runtime plugin only reaches the client where Nitro runs. + if (nitro.options.dev) { + setManagedTunnelDefaults({ basePath: nitro.options.baseURL }); + } }, }; } diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index f018d3eb6869..b9b096162d1a 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,6 +1,15 @@ +import { handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; import { definePlugin } from 'nitro'; +import { useRuntimeConfig } from 'nitro/runtime-config'; import { captureErrorHook } from '../hooks/captureErrorHook'; export default definePlugin(nitroApp => { nitroApp.hooks.hook('error', captureErrorHook); + + // Nitro runs the app's `middleware/` before anything a module registers, so an auth middleware there would block + // the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes first, + // before route rules and middleware. Requests still carry Nitro's `baseURL` here, so the tunnel is served under it. + setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); + const appFetch = nitroApp.fetch; + nitroApp.fetch = request => handleTunnelRequestIfMatched(request) ?? appFetch(request); }); diff --git a/packages/nitro/test/module.test.ts b/packages/nitro/test/module.test.ts new file mode 100644 index 000000000000..f967e66da9bb --- /dev/null +++ b/packages/nitro/test/module.test.ts @@ -0,0 +1,42 @@ +import type { Client } from '@sentry/core'; +import { getCurrentScope, makeDsn } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createNitroModule } from '../src/module'; + +vi.mock('../src/instruments/instrumentServer', () => ({ instrumentServer: vi.fn() })); +vi.mock('../src/sourceMaps', () => ({ setupSourceMaps: vi.fn() })); + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; + +function setClient(managedTunnel: object | undefined): Record { + const options = { dsn: DSN, _managedTunnel: managedTunnel }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); + + return options; +} + +function setup(options: { dev: boolean; baseURL: string }): void { + (createNitroModule().setup as (nitro: unknown) => void)({ options }); +} + +describe('createNitroModule', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it("serves the dev server's managed tunnel under `baseURL`", () => { + const options = setClient({}); + + setup({ dev: true, baseURL: '/app/' }); + + expect(options._managedTunnel).toEqual({ basePath: '/app' }); + }); + + it('leaves the client alone in a build, where the runtime plugin applies the base path', () => { + const options = setClient({}); + + setup({ dev: false, baseURL: '/app/' }); + + expect(options._managedTunnel).toEqual({}); + }); +}); diff --git a/packages/nuxt/src/client/sdk.ts b/packages/nuxt/src/client/sdk.ts index 76428543a716..4aa7891645b8 100644 --- a/packages/nuxt/src/client/sdk.ts +++ b/packages/nuxt/src/client/sdk.ts @@ -1,6 +1,7 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowser } from '@sentry/browser'; import type { Client } from '@sentry/core'; import { applySdkMetadata, DEFAULT_ENVIRONMENT, DEV_ENVIRONMENT } from '@sentry/core'; +import { applyNuxtTunnelBasePath } from '../common/tunnel'; import type { SentryNuxtClientOptions } from '../common/types'; /** @@ -17,6 +18,7 @@ export function init(options: SentryNuxtClientOptions): Client | undefined { ...options, }; + applyNuxtTunnelBasePath(sentryOptions); applySdkMetadata(sentryOptions, 'nuxt', ['nuxt', 'vue']); return initBrowser(sentryOptions); diff --git a/packages/nuxt/src/common/tunnel.ts b/packages/nuxt/src/common/tunnel.ts new file mode 100644 index 000000000000..a99ab4b95d50 --- /dev/null +++ b/packages/nuxt/src/common/tunnel.ts @@ -0,0 +1,15 @@ +import type { Options } from '@sentry/core'; +import { applyTunnelDefaults, GLOBAL_OBJ } from '@sentry/core'; + +/** Set by the generated client config plugin to the app's runtime `app.baseURL`. */ +export const NUXT_TUNNEL_BASE_PATH_GLOBAL = '__sentry_nuxt_base_path'; + +/** + * Serves the managed tunnel under the app's `app.baseURL`, which a proxy in front of the app may be the only path + * forwarded to it. It's read from runtime config, so a `NUXT_APP_BASE_URL` set at runtime is honoured too. + * A `basePath` the user set wins. + */ +export function applyNuxtTunnelBasePath(options: Pick): void { + const basePath = (GLOBAL_OBJ as { [NUXT_TUNNEL_BASE_PATH_GLOBAL]?: string })[NUXT_TUNNEL_BASE_PATH_GLOBAL]; + options.tunnel = applyTunnelDefaults(options.tunnel, { basePath }); +} diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 10f22891ac2d..5d23d675f3fc 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -3,8 +3,9 @@ import { addPlugin, addPluginTemplate, addTemplate, addVitePlugin, createResolve import type {} from '@nuxt/nitro-server'; import * as path from 'path'; +import { NUXT_TUNNEL_BASE_PATH_GLOBAL } from './common/tunnel'; import type { SentryNuxtModuleOptions } from './common/types'; -import { setupNitroServer } from './vite/nitroServer'; +import { addTunnelPlugin, setupNitroServer } from './vite/nitroServer'; import { setupOrchestrion } from './vite/orchestrion'; import { setupSourceMaps } from './vite/sourceMaps'; import { findDefaultSdkInitFile, resolveServerApi } from './vite/utils'; @@ -55,11 +56,12 @@ export default defineNuxtModule({ // Dynamic import of config file to wrap it within a Nuxt context (here: defineNuxtPlugin) // Makes it possible to call useRuntimeConfig() in the user-defined sentry config file getContents: () => ` - import { defineNuxtPlugin } from "#imports"; + import { defineNuxtPlugin, useRuntimeConfig } from "#imports"; export default defineNuxtPlugin({ name: 'sentry-client-config', async setup() { + globalThis.${NUXT_TUNNEL_BASE_PATH_GLOBAL} = useRuntimeConfig().app.baseURL; await import("${buildDirResolver.resolve(`/${clientConfigFile}`)}") } });`, @@ -91,6 +93,9 @@ export default defineNuxtModule({ } else { addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector-legacy.server'), mode: 'server' }); } + } else { + // The Cloudflare setup initializes the SDK from `sentryCloudflareNitroPlugin`, so it needs the tunnel too. + addTunnelPlugin(serverApi); } if (clientConfigFile || serverConfigFile) { diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts new file mode 100644 index 000000000000..fca620bf1a3d --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -0,0 +1,29 @@ +import { handleTunnelRequestIfMatched, isTunnelRequest, setManagedTunnelDefaults } from '@sentry/core/server'; +import { eventHandler, toWebRequest } from 'h3'; +import type { NitroAppPlugin } from 'nitropack'; +import { useRuntimeConfig } from 'nitropack/runtime'; + +/** + * Answers managed tunnel requests on Nuxt v3 and v4 (Nitro v2), from the first layer of the h3 app. That runs + * before route rules and the app's `server/middleware`. + */ +export default (nitroApp => { + // The first layer sees the path before Nitro strips `app.baseURL`, so the tunnel is served under it. + // `useRuntimeConfig` also picks up a `NUXT_APP_BASE_URL` set at runtime, the same value the browser gets. + setManagedTunnelDefaults({ basePath: useRuntimeConfig().app?.baseURL }); + + // Not the `request` hook: h3 still runs the first layer after a hook answered, and with a `headers` route rule + // that layer throws for writing headers on the sent response. + nitroApp.h3App.stack.unshift({ + route: '', + handler: eventHandler(event => { + // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. + if (!isTunnelRequest({ method: event.method, url: event.path })) { + return undefined; + } + + // A returned `Response` is sent and ends the request, while `undefined` hands it to the next layer. + return handleTunnelRequestIfMatched(toWebRequest(event)); + }), + }); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts new file mode 100644 index 000000000000..f84440a06781 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -0,0 +1,17 @@ +import { handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; +import { useRuntimeConfig } from 'nitro/runtime-config'; +import type { NitroAppPlugin, NitroApp } from 'nitro/types'; + +/** + * Answers managed tunnel requests on Nuxt v5+ (Nitro v3+). + */ +export default ((nitroApp: NitroApp) => { + // Nitro runs the app's `server/middleware` before anything a module registers, so an auth middleware there would + // block the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes + // first, before route rules and middleware. + // Requests still carry Nuxt's `app.baseURL` here, so the tunnel is served under it. `useRuntimeConfig` also picks + // up a `NUXT_APP_BASE_URL` set at runtime. + setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); + const appFetch = nitroApp.fetch; + nitroApp.fetch = request => handleTunnelRequestIfMatched(request) ?? appFetch(request); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index 500e94f1f8f6..a67c5d9f8c52 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -55,6 +55,8 @@ export function setupNitroServer( addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name-legacy.server')); } + addTunnelPlugin(serverApi); + addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); // Preps the middleware instrumentation module. @@ -123,3 +125,20 @@ function setupDeprecatedInjectMode( } } } + +/** + * Adds the plugin that answers the managed tunnel. The Cloudflare setup has no server config file, so it skips + * `setupNitroServer` and adds this on its own. + */ +export function addTunnelPlugin(serverApi: ServerApi): void { + if (serverApi === 'nuxt') { + return; + } + + const moduleDirResolver = createResolver(import.meta.url); + addNitroPlugin( + moduleDirResolver.resolve( + serverApi === 'nitro3' ? './runtime/plugins/tunnel.server' : './runtime/plugins/tunnel-legacy.server', + ), + ); +} diff --git a/packages/nuxt/test/common/tunnel.test.ts b/packages/nuxt/test/common/tunnel.test.ts new file mode 100644 index 000000000000..b5076d2cc083 --- /dev/null +++ b/packages/nuxt/test/common/tunnel.test.ts @@ -0,0 +1,29 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { applyNuxtTunnelBasePath, NUXT_TUNNEL_BASE_PATH_GLOBAL } from '../../src/common/tunnel'; + +const globalWithBasePath = GLOBAL_OBJ as { [NUXT_TUNNEL_BASE_PATH_GLOBAL]?: string }; + +describe('applyNuxtTunnelBasePath', () => { + afterEach(() => { + delete globalWithBasePath.__sentry_nuxt_base_path; + }); + + it('serves the managed tunnel under `app.baseURL`', () => { + globalWithBasePath[NUXT_TUNNEL_BASE_PATH_GLOBAL] = '/app/'; + const options: Parameters[0] = { tunnel: true }; + + applyNuxtTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/app' }); + }); + + it('serves from the root for the default base URL', () => { + globalWithBasePath[NUXT_TUNNEL_BASE_PATH_GLOBAL] = '/'; + const options: Parameters[0] = { tunnel: true }; + + applyNuxtTunnelBasePath(options); + + expect(options.tunnel).toBe(true); + }); +}); diff --git a/packages/nuxt/test/vite/nitroServer.test.ts b/packages/nuxt/test/vite/nitroServer.test.ts index a1ab7e7d40e2..0839d8375f99 100644 --- a/packages/nuxt/test/vite/nitroServer.test.ts +++ b/packages/nuxt/test/vite/nitroServer.test.ts @@ -1,6 +1,6 @@ import type { Nuxt } from '@nuxt/schema'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { setupNitroServer } from '../../src/vite/nitroServer'; +import { addTunnelPlugin, setupNitroServer } from '../../src/vite/nitroServer'; const addNitroPluginMock = vi.hoisted(() => vi.fn()); const addServerImportsMock = vi.hoisted(() => vi.fn()); @@ -65,25 +65,37 @@ describe('setupNitroServer', () => { serverApi: 'nitro2', handler: 'handler-legacy.server', updateRouteName: 'update-route-name-legacy.server', + tunnel: 'tunnel-legacy.server', storage: 'storage-legacy.server', }, { serverApi: 'nitro3', handler: 'handler.server', updateRouteName: 'update-route-name.server', + tunnel: 'tunnel.server', storage: 'storage.server', }, - ] as const)('registers the $handler plugins on $serverApi', ({ serverApi, handler, updateRouteName, storage }) => { - const { nuxt } = createFakeNuxt(); + ] as const)( + 'registers the $handler plugins on $serverApi', + ({ serverApi, handler, updateRouteName, tunnel, storage }) => { + const { nuxt } = createFakeNuxt(); + + setupNitroServer(nuxt, serverApi, SERVER_CONFIG, MODULE_OPTIONS); + + expect(addNitroPluginMock).toHaveBeenCalledTimes(6); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(1, `${APP_ROOT}/.nuxt/sentry-server-config-plugin.mjs`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(2, `./runtime/plugins/${handler}`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(3, `./runtime/plugins/${updateRouteName}`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(4, `./runtime/plugins/${tunnel}`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(5, './runtime/plugins/sentry.server'); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(6, `./runtime/plugins/${storage}`); + }, + ); - setupNitroServer(nuxt, serverApi, SERVER_CONFIG, MODULE_OPTIONS); + it('adds no tunnel plugin to a host without Nitro', () => { + addTunnelPlugin('nuxt'); - expect(addNitroPluginMock).toHaveBeenCalledTimes(5); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(1, `${APP_ROOT}/.nuxt/sentry-server-config-plugin.mjs`); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(2, `./runtime/plugins/${handler}`); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(3, `./runtime/plugins/${updateRouteName}`); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(4, './runtime/plugins/sentry.server'); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(5, `./runtime/plugins/${storage}`); + expect(addNitroPluginMock).not.toHaveBeenCalled(); }); it('instruments the middleware and adds the config shim once Nitro initializes', () => {