Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,44 +1,13 @@
import { captureException } from '@sentry/cloudflare';
import * as Sentry from '@sentry/react-router/cloudflare';
import { isbot } from 'isbot';
import { renderToReadableStream } from 'react-dom/server';
import { type EntryContext, type HandleErrorFunction, ServerRouter } from 'react-router';
import { ServerRouter, type HandleErrorFunction } from 'react-router';

async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
): Promise<Response> {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))));

const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} nonce={nonce} />, {
nonce,
signal: request.signal,
onError(error: unknown) {
responseStatusCode = 500;
// Errors thrown after the shell has flushed can't change the status code, so surface them.
if (shellRendered) {
// eslint-disable-next-line no-console
console.error(error);
}
},
});
shellRendered = true;

if (userAgent && isbot(userAgent)) {
await body.allReady;
}

responseHeaders.set('Content-Type', 'text/html');

return new Response(Sentry.injectTraceMetaTags(body), {
headers: responseHeaders,
status: responseStatusCode,
});
}
export default Sentry.createSentryHandleRequest({
renderToReadableStream,
ServerRouter,
getNonce: () => btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16)))),
});

export const handleError: HandleErrorFunction = (error, { request }) => {
if (!request.signal.aborted) {
Expand All @@ -47,6 +16,4 @@ export const handleError: HandleErrorFunction = (error, { request }) => {
}
};

export default Sentry.wrapSentryHandleRequest(handleRequest);

export const instrumentations = [Sentry.createSentryServerInstrumentation()];
50 changes: 3 additions & 47 deletions packages/react-router/src/cloudflare/index.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,10 @@
import { getTraceMetaTags } from '@sentry/core';
import { isPrerenderRequest } from '../server/serverBuild';

export * from '../client';

export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest';
export {
createSentryServerInstrumentation,
type CreateSentryServerInstrumentationOptions,
} from '../server/createServerInstrumentation';

/**
* Injects Sentry trace meta tags into the HTML response by transforming the ReadableStream.
* This enables distributed tracing by adding trace context to the HTML document head.
* @param body - ReadableStream containing the HTML response body to modify
* @returns A new ReadableStream with Sentry trace meta tags injected into the head section
*/
export function injectTraceMetaTags(body: ReadableStream): ReadableStream {
if (isPrerenderRequest()) {
return body;
}

const headClosingTag = '</head>';

const reader = body.getReader();
const encoder = new TextEncoder();
// A single streaming decoder carries incomplete multi-byte sequences across chunk
// boundaries. A fresh, non-streaming decoder per chunk would flush a split character
// as U+FFFD, corrupting the response (see https://github.com/whatwg/encoding/issues/184).
const decoder = new TextDecoder();
const stream = new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();

if (done) {
controller.close();
return;
}

const html = value instanceof Uint8Array ? decoder.decode(value, { stream: true }) : String(value);

if (html.includes(headClosingTag)) {
const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`);

controller.enqueue(encoder.encode(modifiedHtml));
return;
}

controller.enqueue(encoder.encode(html));
},
});

return stream;
}
export { createSentryHandleRequestWebStream as createSentryHandleRequest } from '../server/createSentryHandleRequestWebStream';
export type { SentryWebStreamHandleRequestOptions } from '../server/handleRequestTypes';
export { injectTraceMetaTags } from '../server/injectTraceMetaTags';
34 changes: 18 additions & 16 deletions packages/react-router/src/server/createSentryHandleRequest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import React from 'react';
import type { AppLoadContext, EntryContext, RouterContextProvider, ServerRouter } from 'react-router';
import { PassThrough } from 'stream';
import { getMetaTagTransformer } from './getMetaTagTransformer';
import { createSentryHandleRequestWebStream } from './createSentryHandleRequestWebStream';
import type {
HandleRequestWithMiddleware,
HandleRequestWithoutMiddleware,
SentryWebStreamHandleRequestOptions,
} from './handleRequestTypes';
import { wrapSentryHandleRequest } from './wrapSentryHandleRequest';

type RenderToPipeableStreamOptions = {
Expand Down Expand Up @@ -59,32 +65,28 @@ export interface SentryHandleRequestOptions {
getNonce?: (args: { request: Request; loadContext: AppLoadContext | RouterContextProvider }) => string | undefined;
}

type HandleRequestWithoutMiddleware = (
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
) => Promise<unknown>;

type HandleRequestWithMiddleware = (
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: RouterContextProvider,
) => Promise<unknown>;

/**
* A complete Sentry-instrumented handleRequest implementation that handles both
* route parametrization and trace meta tag injection.
*
* Pass `renderToReadableStream` (Web Streams) instead of `renderToPipeableStream` to use the Web Streams variant.
*
* @param options Configuration options
* @returns A Sentry-instrumented handleRequest function
*/
export function createSentryHandleRequest(
options: SentryHandleRequestOptions,
): HandleRequestWithoutMiddleware & HandleRequestWithMiddleware;
export function createSentryHandleRequest(
options: SentryWebStreamHandleRequestOptions,
): HandleRequestWithoutMiddleware & HandleRequestWithMiddleware;
export function createSentryHandleRequest(
options: SentryHandleRequestOptions | SentryWebStreamHandleRequestOptions,
): HandleRequestWithoutMiddleware & HandleRequestWithMiddleware {
if ('renderToReadableStream' in options) {
return createSentryHandleRequestWebStream(options);
}

const {
streamTimeout = 10000,
renderToPipeableStream,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React from 'react';
import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router';
import type {
HandleRequestWithMiddleware,
HandleRequestWithoutMiddleware,
RenderToReadableStreamResult,
SentryWebStreamHandleRequestOptions,
} from './handleRequestTypes';
import { injectTraceMetaTags } from './injectTraceMetaTags';
import { wrapSentryHandleRequest } from './wrapSentryHandleRequest';

/**
* Web Streams variant of `createSentryHandleRequest`, for runtimes without Node streams (e.g. Cloudflare Workers).
* Must not import any Node.js modules.
*/
export function createSentryHandleRequestWebStream(
options: SentryWebStreamHandleRequestOptions,
): HandleRequestWithoutMiddleware & HandleRequestWithMiddleware {
const {
streamTimeout = 10000,
renderToReadableStream,
ServerRouter,
botRegex = /bot|crawler|spider|googlebot|chrome-lighthouse|baidu|bing|google|yahoo|lighthouse/i,
getNonce,
} = options;

const handleRequest = async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext | RouterContextProvider,
): Promise<Response> {
let statusCode = responseStatusCode;
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
const isBot = typeof userAgent === 'string' && botRegex.test(userAgent);
const isSpaMode = !!(routerContext as { isSpaMode?: boolean }).isSpaMode;

const nonce = getNonce?.({ request, loadContext });
const app = <ServerRouter context={routerContext} url={request.url} nonce={nonce} />;

const controller = new AbortController();
const abortTimer = setTimeout(() => controller.abort(), streamTimeout);

let body: RenderToReadableStreamResult;
try {
body = await renderToReadableStream(app, {
nonce,
signal: controller.signal,
onError(error: unknown) {
statusCode = 500;
// Shell errors reject the render promise and get logged by the caller.
if (shellRendered) {
// eslint-disable-next-line no-console
console.error(error);
}
},
});
} catch (error) {
clearTimeout(abortTimer);
throw error;
}

shellRendered = true;
const clearAbortTimer = (): void => clearTimeout(abortTimer);
body.allReady.then(clearAbortTimer, clearAbortTimer);

if (isBot || isSpaMode) {
await body.allReady;
}

responseHeaders.set('Content-Type', 'text/html');

return new Response(injectTraceMetaTags(body), {
headers: responseHeaders,
status: statusCode,
});
};

return wrapSentryHandleRequest(handleRequest) as HandleRequestWithoutMiddleware & HandleRequestWithMiddleware;
}
14 changes: 7 additions & 7 deletions packages/react-router/src/server/getMetaTagTransformer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PassThrough } from 'node:stream';
import { Transform } from 'node:stream';
import { getTraceMetaTags } from '@sentry/core';
import { createHeadTagInjector } from './headTagInjector';

/**
* Injects Sentry trace meta tags into the HTML response by piping through a transform stream.
Expand All @@ -9,21 +10,20 @@ import { getTraceMetaTags } from '@sentry/core';
* @param body - PassThrough stream containing the HTML response body to modify
*/
export function getMetaTagTransformer(body: PassThrough): Transform {
const headClosingTag = '</head>';
// A single streaming decoder carries incomplete multi-byte sequences across chunk
// boundaries. Decoding each chunk on its own (e.g. `Buffer.toString()`) would flush a
// split character as U+FFFD, corrupting the response (see
// https://github.com/whatwg/encoding/issues/184).
const decoder = new TextDecoder();
const injector = createHeadTagInjector(getTraceMetaTags);
const htmlMetaTagTransformer = new Transform({
transform(chunk, _encoding, callback) {
const html = Buffer.isBuffer(chunk) ? decoder.decode(chunk, { stream: true }) : String(chunk);
if (html.includes(headClosingTag)) {
const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`);
callback(null, modifiedHtml);
return;
}
callback(null, html);
callback(null, injector.push(html));
},
flush(callback) {
const out = injector.push(decoder.decode()) + injector.flush();
callback(null, out);
},
});
htmlMetaTagTransformer.pipe(body);
Expand Down
62 changes: 62 additions & 0 deletions packages/react-router/src/server/handleRequestTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { ReactNode } from 'react';
import type { AppLoadContext, EntryContext, RouterContextProvider, ServerRouter } from 'react-router';

type RenderToReadableStreamOptions = {
[key: string]: unknown;
nonce?: string;
signal?: AbortSignal;
onError?: (error: unknown) => void;
};

export type RenderToReadableStreamResult = ReadableStream & { allReady: Promise<void> };

type RenderToReadableStreamFunction = (
node: ReactNode,
options: RenderToReadableStreamOptions,
) => Promise<RenderToReadableStreamResult>;

export interface SentryWebStreamHandleRequestOptions {
/**
* Timeout in milliseconds after which the rendering stream will be aborted
* @default 10000
*/
streamTimeout?: number;

/**
* React's renderToReadableStream function from 'react-dom/server'
*/
renderToReadableStream: RenderToReadableStreamFunction;

/**
* The <ServerRouter /> component from '@react-router/server'
*/
ServerRouter: typeof ServerRouter;

/**
* Regular expression to identify bot user agents
* @default /bot|crawler|spider|googlebot|chrome-lighthouse|baidu|bing|google|yahoo|lighthouse/i
*/
botRegex?: RegExp;

/**
* Returns a Content Security Policy nonce for the current request.
* The nonce is passed to both `<ServerRouter />` and `renderToReadableStream`.
*/
getNonce?: (args: { request: Request; loadContext: AppLoadContext | RouterContextProvider }) => string | undefined;
}

export type HandleRequestWithoutMiddleware = (
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
) => Promise<unknown>;

export type HandleRequestWithMiddleware = (
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: RouterContextProvider,
) => Promise<unknown>;
Loading