Skip to content

Commit 02a533b

Browse files
committed
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.
1 parent e1da135 commit 02a533b

12 files changed

Lines changed: 190 additions & 4 deletions

File tree

‎dev-packages/e2e-tests/test-applications/solidstart/app.config.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,10 @@ import { defineConfig } from '@solidjs/start/config';
44
export default defineConfig(
55
withSentry({
66
middleware: './src/middleware.ts',
7+
vite: {
8+
define: {
9+
__E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL),
10+
},
11+
},
712
}),
813
);

‎dev-packages/e2e-tests/test-applications/solidstart/package.json‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"test:prod": "TEST_ENV=production playwright test",
1111
"test:build": "pnpm install && pnpm build",
1212
"test:assert": "pnpm test:prod && pnpm test:dev",
13-
"test:dev": "TEST_ENV=development playwright test db"
13+
"test:dev": "TEST_ENV=development playwright test db",
14+
"test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build",
15+
"test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel"
1416
},
1517
"type": "module",
1618
"dependencies": {
@@ -36,5 +38,14 @@
3638
},
3739
"volta": {
3840
"extends": "../../package.json"
41+
},
42+
"sentryTest": {
43+
"variants": [
44+
{
45+
"build-command": "pnpm test:build:tunnel",
46+
"assert-command": "pnpm test:assert:tunnel",
47+
"label": "solidstart (tunnel)"
48+
}
49+
]
3950
}
4051
}

‎dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import * as Sentry from '@sentry/solidstart';
33
import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter';
44
import { StartClient, mount } from '@solidjs/start/client';
55

6+
declare const __E2E_TEST_TUNNEL__: boolean;
7+
68
Sentry.init({
79
// We can't use env variables here, seems like they are stripped
810
// out in production builds.
9-
dsn: 'https://public@dsn.ingest.sentry.io/1337',
11+
dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337',
1012
environment: 'qa', // dynamic sampling bias to keep transactions
1113
integrations: [solidRouterBrowserTracingIntegration()],
12-
tunnel: 'http://localhost:3031/', // proxy server
14+
tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server
1315
// Performance Monitoring
1416
tracesSampleRate: 1.0, // Capture 100% of the transactions
1517
debug: !!import.meta.env.DEBUG,

‎dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ Sentry.init({
44
dsn: process.env.E2E_TEST_DSN,
55
environment: 'qa', // dynamic sampling bias to keep transactions
66
tracesSampleRate: 1.0, // Capture 100% of the transactions
7-
tunnel: 'http://localhost:3031/', // proxy server
7+
tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server
88
debug: !!process.env.DEBUG,
99
});

‎dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,14 @@ import { sentryBeforeResponseMiddleware } from '@sentry/solidstart';
22
import { createMiddleware } from '@solidjs/start/middleware';
33

44
export default createMiddleware({
5+
onRequest: [
6+
// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one.
7+
event => {
8+
const request = event.request;
9+
if (process.env.E2E_TEST_TUNNEL && request.method === 'POST' && request.headers.get('x-auth') !== 'ok') {
10+
return new Response('blocked by user auth', { status: 401 });
11+
}
12+
},
13+
],
514
onBeforeResponse: [sentryBeforeResponseMiddleware()],
615
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect, test } from '@playwright/test';
2+
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
3+
4+
test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant');
5+
6+
const APP_NAME = 'solidstart';
7+
const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/;
8+
9+
test('answers the managed tunnel before the app middleware', async ({ page }) => {
10+
// The variant's auth middleware rejects every other POST.
11+
expect((await page.request.post('/', { data: 'x' })).status()).toBe(401);
12+
13+
const tunnelServerSpanPromise = waitForStreamedSpan(
14+
APP_NAME,
15+
span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')),
16+
);
17+
const tunnelResponsePromise = page.waitForResponse(
18+
response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname),
19+
);
20+
21+
await page.goto('/');
22+
const tunnelResponse = await tunnelResponsePromise;
23+
24+
expect(tunnelResponse.status()).toBe(200);
25+
expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin);
26+
27+
// Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span
28+
// would arrive first.
29+
const anchorSpanPromise = waitForStreamedSpan(
30+
APP_NAME,
31+
span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/users/6',
32+
);
33+
await page.request.get('/users/6');
34+
35+
const winner = await Promise.race([
36+
tunnelServerSpanPromise.then(() => 'tunnel' as const),
37+
anchorSpanPromise.then(() => 'anchor' as const),
38+
]);
39+
expect(winner).toBe('anchor');
40+
});

‎packages/solidstart/rollup.npm.config.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default makeNPMConfigVariants(
1313
'src/client/solidrouter.ts',
1414
'src/server/solidrouter.ts',
1515
'src/vite/index.ts',
16+
'src/nitro/tunnelPlugin.ts',
1617
],
1718
// prevent this internal code from ending up in our built package (this doesn't happen automatically because
1819
// the name doesn't match an SDK dependency)

‎packages/solidstart/src/config/types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type RollupConfig = {
99
export type SolidStartInlineConfig = NonNullable<Parameters<typeof defineConfig>[0]>;
1010

1111
export type SolidStartInlineServerConfig = {
12+
plugins?: string[];
1213
hooks?: {
1314
close?: () => unknown;
1415
'rollup:before'?: (nitro: Nitro) => unknown;

‎packages/solidstart/src/config/withSentry.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { fileURLToPath } from 'node:url';
12
import { debug } from '@sentry/core';
23
import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config';
34
import {
@@ -116,6 +117,15 @@ export function withSentry(
116117
externals,
117118
commonJS,
118119
modules: [...existingModules, sentryNitroModule],
120+
// Through `server.plugins`, since vinxi's dev server ignores plugins that Nitro modules add.
121+
plugins: [...(server.plugins || []), getTunnelPluginPath()],
119122
},
120123
};
121124
}
125+
126+
function getTunnelPluginPath(): string {
127+
const url = new URL('../nitro/tunnelPlugin.js', import.meta.url);
128+
129+
// `import.meta.url` is a file URL in the built package, but not in every test environment.
130+
return url.protocol === 'file:' ? fileURLToPath(url) : url.pathname;
131+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Readable } from 'node:stream';
2+
import { handleTunnelRequestIfMatched, isTunnelRequest } from '@sentry/core';
3+
import type { NitroAppPlugin } from 'nitropack';
4+
5+
/**
6+
* Answers managed tunnel requests on SolidStart v1 (Nitro v2). The `request` hook runs before the app's middleware
7+
* and routing. `withSentry` registers it.
8+
*/
9+
export default (nitroApp => {
10+
nitroApp.hooks.hook('request', async event => {
11+
const { req, res } = event.node;
12+
// Checked on the method and path first, so no other request's body gets wrapped in a web `Request`.
13+
if (!isTunnelRequest(req)) {
14+
return;
15+
}
16+
17+
const headers = new Headers();
18+
for (const [name, value] of Object.entries(req.headers)) {
19+
if (value !== undefined) {
20+
headers.set(name, Array.isArray(value) ? value.join(', ') : value);
21+
}
22+
}
23+
24+
const tunnelResponse = await handleTunnelRequestIfMatched(
25+
new Request(`http://${req.headers.host}${req.url}`, {
26+
method: req.method,
27+
headers,
28+
body: Readable.toWeb(req) as ReadableStream,
29+
// Required by Node's `fetch` for a streamed body.
30+
duplex: 'half',
31+
} as RequestInit),
32+
);
33+
if (!tunnelResponse) {
34+
return;
35+
}
36+
37+
// Writing the response marks the event as handled, so Nitro stops here.
38+
res.statusCode = tunnelResponse.status;
39+
tunnelResponse.headers.forEach((value, name) => res.setHeader(name, value));
40+
res.end(Buffer.from(await tunnelResponse.arrayBuffer()));
41+
});
42+
}) satisfies NitroAppPlugin;

0 commit comments

Comments
 (0)