Skip to content

Commit 1310ba7

Browse files
chargomeclaude
andauthored
test(react-router)!: Add orchestrion based cloudflare app (#23063)
Adds a new `react-router-8-cloudflare` e2e app. ref: #22632 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d59b1d1 commit 1310ba7

20 files changed

Lines changed: 418 additions & 0 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.js
7+
8+
# testing
9+
/coverage
10+
11+
# production
12+
/build
13+
14+
# misc
15+
.DS_Store
16+
.env.local
17+
.env.development.local
18+
.env.test.local
19+
.env.production.local
20+
21+
npm-debug.log*
22+
yarn-debug.log*
23+
yarn-error.log*
24+
25+
/test-results/
26+
/playwright-report/
27+
/playwright/.cache/
28+
29+
!*.d.ts
30+
31+
# react router
32+
.react-router
33+
34+
# cloudflare
35+
.wrangler
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as Sentry from '@sentry/react-router';
2+
import { startTransition, StrictMode } from 'react';
3+
import { hydrateRoot } from 'react-dom/client';
4+
import { HydratedRouter } from 'react-router/dom';
5+
6+
Sentry.init({
7+
traceLifecycle: 'static',
8+
environment: 'qa', // dynamic sampling bias to keep transactions
9+
dsn: 'https://username@domain/123',
10+
tunnel: `http://localhost:3031/`, // proxy server
11+
integrations: [Sentry.reactRouterTracingIntegration()],
12+
tracesSampleRate: 1.0,
13+
tracePropagationTargets: [/^\//],
14+
});
15+
16+
startTransition(() => {
17+
hydrateRoot(
18+
document,
19+
<StrictMode>
20+
<HydratedRouter onError={Sentry.sentryOnError} />
21+
</StrictMode>,
22+
);
23+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as Sentry from '@sentry/react-router/cloudflare';
2+
import { isbot } from 'isbot';
3+
import { renderToReadableStream } from 'react-dom/server';
4+
import { type EntryContext, type HandleErrorFunction, ServerRouter } from 'react-router';
5+
6+
// workerd has no `renderToPipeableStream`, so this renders to a web stream instead.
7+
async function handleRequest(
8+
request: Request,
9+
responseStatusCode: number,
10+
responseHeaders: Headers,
11+
routerContext: EntryContext,
12+
): Promise<Response> {
13+
let shellRendered = false;
14+
const userAgent = request.headers.get('user-agent');
15+
16+
const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, {
17+
signal: request.signal,
18+
onError(error: unknown) {
19+
responseStatusCode = 500;
20+
// Errors thrown after the shell has flushed can't change the status code, so surface them.
21+
if (shellRendered) {
22+
// eslint-disable-next-line no-console
23+
console.error(error);
24+
}
25+
},
26+
});
27+
shellRendered = true;
28+
29+
// Bots need complete markup rather than a streamed shell.
30+
if (userAgent && isbot(userAgent)) {
31+
await body.allReady;
32+
}
33+
34+
responseHeaders.set('Content-Type', 'text/html');
35+
36+
return new Response(Sentry.injectTraceMetaTags(body), {
37+
headers: responseHeaders,
38+
status: responseStatusCode,
39+
});
40+
}
41+
42+
export const handleError: HandleErrorFunction = (error, { request }) => {
43+
// React Router aborts interrupted requests, don't report those.
44+
if (!request.signal.aborted) {
45+
Sentry.captureException(error);
46+
// eslint-disable-next-line no-console
47+
console.error(error);
48+
}
49+
};
50+
51+
export default Sentry.wrapSentryHandleRequest(handleRequest);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
2+
3+
export function Layout({ children }: { children: React.ReactNode }) {
4+
return (
5+
<html lang="en">
6+
<head>
7+
<meta charSet="utf-8" />
8+
<meta name="viewport" content="width=device-width, initial-scale=1" />
9+
<Meta />
10+
<Links />
11+
</head>
12+
<body>
13+
{children}
14+
<ScrollRestoration />
15+
<Scripts />
16+
</body>
17+
</html>
18+
);
19+
}
20+
21+
export default function App() {
22+
return <Outlet />;
23+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { index, prefix, route, type RouteConfig } from '@react-router/dev/routes';
2+
3+
export default [
4+
index('routes/home.tsx'),
5+
...prefix('performance', [route('db-mysql', 'routes/performance/db-mysql.tsx')]),
6+
] satisfies RouteConfig;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Link } from 'react-router';
2+
3+
export default function Home() {
4+
return (
5+
<div>
6+
<h1>react-router-8-cloudflare</h1>
7+
<Link to="/performance/db-mysql">db-mysql</Link>
8+
</div>
9+
);
10+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import mysql from 'mysql';
2+
import type { Route } from './+types/db-mysql';
3+
4+
// These queries produce `db` spans from the build-time orchestrion transform alone — workerd can't
5+
// monkey-patch requires, so there's no OTel hook involved.
6+
export async function loader(): Promise<{ status: string }> {
7+
// Connect inside the loader: workerd forbids I/O in global scope.
8+
const connection = mysql.createConnection({
9+
host: '127.0.0.1',
10+
port: 3306,
11+
user: 'root',
12+
password: 'docker',
13+
});
14+
15+
// Swallow socket-level errors so they don't fail the request for reasons unrelated to the spans.
16+
connection.on('error', () => {
17+
// no-op
18+
});
19+
20+
try {
21+
// The nested query runs in a fresh async context (mysql dispatches callbacks from its socket
22+
// handler), so it only lands on this transaction if the subscriber restored the parent span.
23+
await new Promise<void>((resolve, reject) => {
24+
connection.query('SELECT 1 + 1 AS solution', err1 => {
25+
if (err1) return reject(err1);
26+
connection.query('SELECT NOW()', err2 => {
27+
if (err2) return reject(err2);
28+
resolve();
29+
});
30+
});
31+
});
32+
return { status: 'ok' };
33+
} finally {
34+
connection.end();
35+
}
36+
}
37+
38+
export default function DbMysql(_props: Route.ComponentProps) {
39+
return <div>db-mysql</div>;
40+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
services:
2+
db:
3+
image: mysql:8.0
4+
restart: always
5+
container_name: e2e-tests-react-router-8-cloudflare-mysql
6+
# The `mysql` 2.x driver doesn't speak MySQL 8's default
7+
# `caching_sha2_password` auth, so force the legacy plugin.
8+
command: ['--default-authentication-plugin=mysql_native_password']
9+
ports:
10+
- '3306:3306'
11+
environment:
12+
MYSQL_ROOT_PASSWORD: docker
13+
healthcheck:
14+
test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -pdocker']
15+
interval: 2s
16+
timeout: 3s
17+
retries: 30
18+
start_period: 10s
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { execSync } from 'child_process';
2+
import { dirname } from 'path';
3+
import { fileURLToPath } from 'url';
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url));
6+
7+
export default async function globalSetup() {
8+
// `--wait` blocks until the healthcheck passes, so the first request can connect.
9+
execSync('docker compose up -d --wait', {
10+
cwd: __dirname,
11+
stdio: 'inherit',
12+
});
13+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { execSync } from 'child_process';
2+
import { dirname } from 'path';
3+
import { fileURLToPath } from 'url';
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url));
6+
7+
export default async function globalTeardown() {
8+
execSync('docker compose down --volumes', {
9+
cwd: __dirname,
10+
stdio: 'inherit',
11+
});
12+
}

0 commit comments

Comments
 (0)