diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.cloudflare.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.cloudflare.tsx
index 31a4fec981f6..57bc9460c40c 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.cloudflare.tsx
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.cloudflare.tsx
@@ -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 {
- let shellRendered = false;
- const userAgent = request.headers.get('user-agent');
- const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))));
-
- const body = await renderToReadableStream(, {
- 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) {
@@ -47,6 +16,4 @@ export const handleError: HandleErrorFunction = (error, { request }) => {
}
};
-export default Sentry.wrapSentryHandleRequest(handleRequest);
-
export const instrumentations = [Sentry.createSentryServerInstrumentation()];
diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts
index b94430728125..45a6996eff73 100644
--- a/packages/react-router/src/cloudflare/index.ts
+++ b/packages/react-router/src/cloudflare/index.ts
@@ -1,6 +1,3 @@
-import { getTraceMetaTags } from '@sentry/core';
-import { isPrerenderRequest } from '../server/serverBuild';
-
export * from '../client';
export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest';
@@ -8,47 +5,6 @@ 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 = '';
-
- 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';
diff --git a/packages/react-router/src/server/createSentryHandleRequest.tsx b/packages/react-router/src/server/createSentryHandleRequest.tsx
index 20910d300312..be901a13c28b 100644
--- a/packages/react-router/src/server/createSentryHandleRequest.tsx
+++ b/packages/react-router/src/server/createSentryHandleRequest.tsx
@@ -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 = {
@@ -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;
-
-type HandleRequestWithMiddleware = (
- request: Request,
- responseStatusCode: number,
- responseHeaders: Headers,
- routerContext: EntryContext,
- loadContext: RouterContextProvider,
-) => Promise;
-
/**
* 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,
diff --git a/packages/react-router/src/server/createSentryHandleRequestWebStream.tsx b/packages/react-router/src/server/createSentryHandleRequestWebStream.tsx
new file mode 100644
index 000000000000..37c5646b3468
--- /dev/null
+++ b/packages/react-router/src/server/createSentryHandleRequestWebStream.tsx
@@ -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 {
+ 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 = ;
+
+ 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;
+}
diff --git a/packages/react-router/src/server/getMetaTagTransformer.ts b/packages/react-router/src/server/getMetaTagTransformer.ts
index 376bf8919691..419c9c5f7a25 100644
--- a/packages/react-router/src/server/getMetaTagTransformer.ts
+++ b/packages/react-router/src/server/getMetaTagTransformer.ts
@@ -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.
@@ -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 = '';
// 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);
diff --git a/packages/react-router/src/server/handleRequestTypes.ts b/packages/react-router/src/server/handleRequestTypes.ts
new file mode 100644
index 000000000000..5a111a320d2c
--- /dev/null
+++ b/packages/react-router/src/server/handleRequestTypes.ts
@@ -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 };
+
+type RenderToReadableStreamFunction = (
+ node: ReactNode,
+ options: RenderToReadableStreamOptions,
+) => Promise;
+
+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 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 `` 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;
+
+export type HandleRequestWithMiddleware = (
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ routerContext: EntryContext,
+ loadContext: RouterContextProvider,
+) => Promise;
diff --git a/packages/react-router/src/server/headTagInjector.ts b/packages/react-router/src/server/headTagInjector.ts
new file mode 100644
index 000000000000..3207f4a6aa85
--- /dev/null
+++ b/packages/react-router/src/server/headTagInjector.ts
@@ -0,0 +1,47 @@
+const HEAD_CLOSING_TAG = '';
+
+/**
+ * Injects `getTags()` before the first `` of a string stream. Text that could be
+ * the start of a `` split across chunks is held back until the next push.
+ */
+export function createHeadTagInjector(getTags: () => string): { push(text: string): string; flush(): string } {
+ let pending = '';
+ let injected = false;
+
+ return {
+ push(text: string): string {
+ if (injected) {
+ return text;
+ }
+
+ pending += text;
+ const index = pending.indexOf(HEAD_CLOSING_TAG);
+ if (index !== -1) {
+ injected = true;
+ const out = `${pending.slice(0, index)}${getTags()}${HEAD_CLOSING_TAG}${pending.slice(index + HEAD_CLOSING_TAG.length)}`;
+ pending = '';
+ return out;
+ }
+
+ const holdLength = longestPartialTagSuffix(pending);
+ const out = pending.slice(0, pending.length - holdLength);
+ pending = pending.slice(pending.length - holdLength);
+ return out;
+ },
+
+ flush(): string {
+ const out = pending;
+ pending = '';
+ return out;
+ },
+ };
+}
+
+function longestPartialTagSuffix(text: string): number {
+ for (let length = Math.min(HEAD_CLOSING_TAG.length - 1, text.length); length > 0; length--) {
+ if (text.endsWith(HEAD_CLOSING_TAG.slice(0, length))) {
+ return length;
+ }
+ }
+ return 0;
+}
diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts
index 8245169f01a7..b458747afbaa 100644
--- a/packages/react-router/src/server/index.ts
+++ b/packages/react-router/src/server/index.ts
@@ -6,6 +6,7 @@ export * from '@sentry/node';
export { init } from './sdk';
export { wrapSentryHandleRequest } from './wrapSentryHandleRequest';
export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest';
+export type { SentryWebStreamHandleRequestOptions } from './handleRequestTypes';
export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError';
export { getMetaTagTransformer } from './getMetaTagTransformer';
diff --git a/packages/react-router/src/server/injectTraceMetaTags.ts b/packages/react-router/src/server/injectTraceMetaTags.ts
new file mode 100644
index 000000000000..1b1659922ebe
--- /dev/null
+++ b/packages/react-router/src/server/injectTraceMetaTags.ts
@@ -0,0 +1,58 @@
+import { getTraceMetaTags } from '@sentry/core';
+import { createHeadTagInjector } from './headTagInjector';
+import { isPrerenderRequest } from './serverBuild';
+
+/**
+ * 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;
+ }
+
+ // Resolved now, while the request's async context is still active. Runtimes like workerd run
+ // stream callbacks outside of it, where there is no client and no trace data.
+ const traceMetaTags = getTraceMetaTags();
+ const injector = createHeadTagInjector(() => traceMetaTags);
+
+ 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();
+
+ // Not a TransformStream: workerd only supports a custom transformer from compatibility date
+ // 2024-11-11, and Hydrogen's MiniOxygen runs with an older one.
+ return new ReadableStream({
+ async pull(controller) {
+ // `pull` is not called again until a chunk is enqueued, so keep reading while text is held back.
+ for (;;) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ const rest = injector.push(decoder.decode()) + injector.flush();
+ if (rest) {
+ controller.enqueue(encoder.encode(rest));
+ }
+ controller.close();
+ return;
+ }
+
+ const html = value instanceof Uint8Array ? decoder.decode(value, { stream: true }) : String(value);
+ const out = injector.push(html);
+
+ if (out) {
+ controller.enqueue(encoder.encode(out));
+ return;
+ }
+ }
+ },
+ cancel(reason) {
+ return reader.cancel(reason);
+ },
+ });
+}
diff --git a/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts b/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts
index 012a426bb5d6..b6bc564c2137 100644
--- a/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts
+++ b/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts
@@ -96,4 +96,87 @@ describe('injectTraceMetaTags', () => {
expect(output).not.toContain(REPLACEMENT_CHARACTER);
expect(output).toContain('€ inside body');
});
+
+ test('injects meta tags when is split across chunks', async () => {
+ const encoder = new TextEncoder();
+ const input = streamFromChunks([encoder.encode('Test')]);
+
+ const output = new TextDecoder().decode(await readAll(injectTraceMetaTags(input)));
+
+ expect(output).toBe(
+ 'Test',
+ );
+ });
+
+ test('preserves a multi-byte character split together with a split ', async () => {
+ const encoder = new TextEncoder();
+ const input = streamFromChunks([
+ encoder.encode(''), 0xc2]),
+ new Uint8Array([0xa9, ...encoder.encode(' 2026
')]),
+ ]);
+
+ const output = new TextDecoder('utf-8', { fatal: false }).decode(await readAll(injectTraceMetaTags(input)));
+
+ expect(output).not.toContain(REPLACEMENT_CHARACTER);
+ expect(output).toContain('© 2026
');
+ });
+
+ test('flushes a held-back partial tag when the stream has no ', async () => {
+ const encoder = new TextEncoder();
+ const input = streamFromChunks([encoder.encode(' {
+ vi.stubGlobal(
+ 'TransformStream',
+ class {
+ constructor() {
+ throw new Error('TransformStream is not supported');
+ }
+ },
+ );
+
+ try {
+ const input = streamFromChunks([new TextEncoder().encode('')]);
+ const output = new TextDecoder().decode(await readAll(injectTraceMetaTags(input)));
+
+ expect(output).toContain('');
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ test('keeps reading while a partial tag is held back so the stream does not stall', async () => {
+ const encoder = new TextEncoder();
+ const input = streamFromChunks([encoder.encode('<'), encoder.encode('/'), encoder.encode('head>')]);
+
+ const output = new TextDecoder().decode(await readAll(injectTraceMetaTags(input)));
+
+ expect(output).toBe('');
+ });
+
+ test('cancels the source stream when the consumer cancels', async () => {
+ const cancel = vi.fn();
+ const input = new ReadableStream({ cancel });
+
+ await injectTraceMetaTags(input).cancel('done');
+
+ expect(cancel).toHaveBeenCalledWith('done');
+ });
+
+ test('resolves the trace meta tags synchronously, before the stream is read', async () => {
+ const encoder = new TextEncoder();
+ const input = streamFromChunks([encoder.encode('')]);
+
+ const output = injectTraceMetaTags(input);
+
+ expect(getTraceMetaTags).toHaveBeenCalledTimes(1);
+ expect(new TextDecoder().decode(await readAll(output))).toContain(' {
handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext),
).rejects.toThrow(testError);
});
+
+ it('routes to the Web Streams handler when renderToReadableStream is provided', async () => {
+ const mockRenderToReadableStream = vi
+ .fn()
+ .mockResolvedValue(Object.assign(new ReadableStream(), { allReady: Promise.resolve() }));
+
+ const handleRequest = createSentryHandleRequest({
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ });
+
+ expect(handleRequest).toBeDefined();
+ expect(wrapSentryHandleRequestModule.wrapSentryHandleRequest).toHaveBeenCalled();
+
+ await handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext);
+
+ expect(mockRenderToReadableStream).toHaveBeenCalled();
+ expect(mockRenderToPipeableStream).not.toHaveBeenCalled();
+ });
});
diff --git a/packages/react-router/test/server/createSentryHandleRequestWebStream.test.ts b/packages/react-router/test/server/createSentryHandleRequestWebStream.test.ts
new file mode 100644
index 000000000000..18040a4318c5
--- /dev/null
+++ b/packages/react-router/test/server/createSentryHandleRequestWebStream.test.ts
@@ -0,0 +1,316 @@
+import type { EntryContext } from 'react-router';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createSentryHandleRequestWebStream } from '../../src/server/createSentryHandleRequestWebStream';
+
+vi.mock('../../src/server/wrapSentryHandleRequest', () => ({
+ wrapSentryHandleRequest: vi.fn(fn => fn),
+}));
+
+vi.mock('../../src/server/injectTraceMetaTags', () => ({
+ injectTraceMetaTags: vi.fn((body: ReadableStream) => body),
+}));
+
+type Deferred = { promise: Promise; resolve: () => void; reject: (e: unknown) => void };
+
+function createDeferred(): Deferred {
+ let resolve!: () => void;
+ let reject!: (e: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+function createBody(allReady: Promise): ReadableStream & { allReady: Promise } {
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode(''));
+ controller.close();
+ },
+ });
+ return Object.assign(stream, { allReady });
+}
+
+describe('createSentryHandleRequestWebStream', () => {
+ const mockServerRouter = vi.fn();
+ const mockRenderToReadableStream = vi.fn();
+
+ const makeRequest = (userAgent: string | null = 'Mozilla/5.0'): Request =>
+ ({
+ url: 'https://sentry-example.com/test',
+ headers: {
+ get: vi.fn((name: string) => (name === 'user-agent' ? userAgent : null)),
+ },
+ }) as unknown as Request;
+
+ const makeRouterContext = (isSpaMode = false) => ({ isSpaMode }) as unknown as EntryContext;
+
+ let headers: Headers;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ headers = new Headers();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('awaits allReady for bots before resolving', async () => {
+ const allReady = createDeferred();
+ mockRenderToReadableStream.mockResolvedValue(createBody(allReady.promise));
+
+ const handleRequest = createSentryHandleRequestWebStream({
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ });
+
+ let resolved = false;
+ const promise = handleRequest(makeRequest('Googlebot/2.1'), 200, headers, makeRouterContext(), {}).then(res => {
+ resolved = true;
+ return res;
+ });
+
+ await vi.advanceTimersByTimeAsync(0);
+ expect(resolved).toBe(false);
+
+ allReady.resolve();
+ await promise;
+ expect(resolved).toBe(true);
+ });
+
+ it('awaits allReady in SPA mode before resolving', async () => {
+ const allReady = createDeferred();
+ mockRenderToReadableStream.mockResolvedValue(createBody(allReady.promise));
+
+ const handleRequest = createSentryHandleRequestWebStream({
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ });
+
+ let resolved = false;
+ const promise = handleRequest(makeRequest(), 200, headers, makeRouterContext(true), {}).then(res => {
+ resolved = true;
+ return res;
+ });
+
+ await vi.advanceTimersByTimeAsync(0);
+ expect(resolved).toBe(false);
+
+ allReady.resolve();
+ await promise;
+ expect(resolved).toBe(true);
+ });
+
+ it.each([
+ ['a bot', makeRequest('Googlebot/2.1'), makeRouterContext()],
+ ['SPA mode', makeRequest(), makeRouterContext(true)],
+ ])('rejects when allReady rejects for %s', async (_name, request, routerContext) => {
+ const allReady = createDeferred();
+ mockRenderToReadableStream.mockResolvedValue(createBody(allReady.promise));
+
+ const handleRequest = createSentryHandleRequestWebStream({
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ });
+
+ const promise = handleRequest(request, 200, headers, routerContext, {});
+ const assertion = expect(promise).rejects.toThrow('fatal stream error');
+
+ await vi.advanceTimersByTimeAsync(0);
+ allReady.reject(new Error('fatal stream error'));
+
+ await assertion;
+ });
+
+ it('resolves without waiting for allReady for regular browsers', async () => {
+ const allReady = createDeferred();
+ mockRenderToReadableStream.mockResolvedValue(createBody(allReady.promise));
+
+ const handleRequest = createSentryHandleRequestWebStream({
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ });
+
+ const response = (await handleRequest(makeRequest(), 200, headers, makeRouterContext(), {})) as Response;
+ expect(response).toBeInstanceOf(Response);
+ expect(response.status).toBe(200);
+ });
+
+ it('aborts the signal after streamTimeout and clears the timer once allReady settles', async () => {
+ const allReady = createDeferred();
+ mockRenderToReadableStream.mockResolvedValue(createBody(allReady.promise));
+
+ const handleRequest = createSentryHandleRequestWebStream({
+ streamTimeout: 5000,
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ });
+
+ await handleRequest(makeRequest(), 200, headers, makeRouterContext(), {});
+
+ const { signal } = mockRenderToReadableStream.mock.calls[0]![1] as { signal: AbortSignal };
+ expect(signal.aborted).toBe(false);
+
+ allReady.resolve();
+ await allReady.promise;
+ await vi.advanceTimersByTimeAsync(0);
+ vi.advanceTimersByTime(5000);
+ expect(signal.aborted).toBe(false);
+ });
+
+ it('aborts the signal once streamTimeout elapses before allReady', async () => {
+ const allReady = createDeferred();
+ mockRenderToReadableStream.mockResolvedValue(createBody(allReady.promise));
+
+ const handleRequest = createSentryHandleRequestWebStream({
+ streamTimeout: 5000,
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ });
+
+ await handleRequest(makeRequest(), 200, headers, makeRouterContext(), {});
+ const { signal } = mockRenderToReadableStream.mock.calls[0]![1] as { signal: AbortSignal };
+
+ vi.advanceTimersByTime(4999);
+ expect(signal.aborted).toBe(false);
+ vi.advanceTimersByTime(1);
+ expect(signal.aborted).toBe(true);
+ });
+
+ it('uses a 10000ms default stream timeout', async () => {
+ mockRenderToReadableStream.mockResolvedValue(createBody(new Promise(() => {})));
+
+ const handleRequest = createSentryHandleRequestWebStream({
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ });
+
+ await handleRequest(makeRequest(), 200, headers, makeRouterContext(), {});
+ const { signal } = mockRenderToReadableStream.mock.calls[0]![1] as { signal: AbortSignal };
+
+ vi.advanceTimersByTime(9999);
+ expect(signal.aborted).toBe(false);
+ vi.advanceTimersByTime(1);
+ expect(signal.aborted).toBe(true);
+ });
+
+ it('sets status 500 when onError fires before the response', async () => {
+ mockRenderToReadableStream.mockImplementation(async (_node, options) => {
+ options.onError?.(new Error('late error'));
+ return createBody(Promise.resolve());
+ });
+
+ const handleRequest = createSentryHandleRequestWebStream({
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ });
+
+ const response = (await handleRequest(
+ makeRequest('Googlebot/2.1'),
+ 200,
+ headers,
+ makeRouterContext(),
+ {},
+ )) as Response;
+ expect(response.status).toBe(500);
+ });
+
+ it('logs errors that fire after the shell has rendered', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const error = new Error('stream error');
+ const allReady = createDeferred();
+
+ mockRenderToReadableStream.mockImplementation(async (_node, options) => {
+ const body = createBody(allReady.promise);
+ setTimeout(() => options.onError?.(error), 0);
+ return body;
+ });
+
+ const handleRequest = createSentryHandleRequestWebStream({
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ });
+
+ const promise = handleRequest(makeRequest(), 200, headers, makeRouterContext(), {});
+ await vi.advanceTimersByTimeAsync(0);
+ const response = (await promise) as Response;
+
+ expect(consoleSpy).toHaveBeenCalledWith(error);
+ expect(response.status).toBe(200);
+ consoleSpy.mockRestore();
+ });
+
+ it('rejects when the shell fails to render', async () => {
+ const testError = new Error('Shell error');
+ mockRenderToReadableStream.mockRejectedValue(testError);
+
+ const handleRequest = createSentryHandleRequestWebStream({
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ });
+
+ await expect(handleRequest(makeRequest(), 200, headers, makeRouterContext(), {})).rejects.toThrow(testError);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('passes the nonce from getNonce to ServerRouter and renderToReadableStream', async () => {
+ mockRenderToReadableStream.mockResolvedValue(createBody(Promise.resolve()));
+ const getNonce = vi.fn().mockReturnValue('test-nonce');
+ const request = makeRequest();
+ const loadContext = {};
+
+ const handleRequest = createSentryHandleRequestWebStream({
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ getNonce,
+ });
+
+ await handleRequest(request, 200, headers, makeRouterContext(), loadContext);
+
+ expect(getNonce).toHaveBeenCalledWith({ request, loadContext });
+ expect(mockRenderToReadableStream).toHaveBeenCalledWith(
+ expect.objectContaining({ props: expect.objectContaining({ nonce: 'test-nonce' }) }),
+ expect.objectContaining({ nonce: 'test-nonce' }),
+ );
+ });
+
+ it('uses a custom bot regex', async () => {
+ const allReady = createDeferred();
+ mockRenderToReadableStream.mockResolvedValue(createBody(allReady.promise));
+
+ const handleRequest = createSentryHandleRequestWebStream({
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ botRegex: /custom-bot/i,
+ });
+
+ let resolved = false;
+ const promise = handleRequest(makeRequest('Googlebot/2.1'), 200, headers, makeRouterContext(), {}).then(res => {
+ resolved = true;
+ return res;
+ });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(resolved).toBe(true);
+ await promise;
+ });
+
+ it('sets Content-Type and passes the body through injectTraceMetaTags', async () => {
+ const { injectTraceMetaTags } = await import('../../src/server/injectTraceMetaTags');
+ const body = createBody(Promise.resolve());
+ mockRenderToReadableStream.mockResolvedValue(body);
+
+ const handleRequest = createSentryHandleRequestWebStream({
+ renderToReadableStream: mockRenderToReadableStream,
+ ServerRouter: mockServerRouter,
+ });
+
+ const response = (await handleRequest(makeRequest(), 200, headers, makeRouterContext(), {})) as Response;
+
+ expect(headers.get('Content-Type')).toBe('text/html');
+ expect(injectTraceMetaTags).toHaveBeenCalledWith(body);
+ expect(response.headers.get('Content-Type')).toBe('text/html');
+ });
+});
diff --git a/packages/react-router/test/server/getMetaTagTransformer.test.ts b/packages/react-router/test/server/getMetaTagTransformer.test.ts
index 6b2fbe10ee31..03560b3f4588 100644
--- a/packages/react-router/test/server/getMetaTagTransformer.test.ts
+++ b/packages/react-router/test/server/getMetaTagTransformer.test.ts
@@ -146,4 +146,55 @@ describe('getMetaTagTransformer', () => {
transformer.write(Buffer.from([0xa9, ...Buffer.from(' 2026
')]));
transformer.end();
}));
+
+ test('should inject meta tags when is split across chunks', () =>
+ new Promise((resolve, reject) => {
+ const bodyStream = new PassThrough();
+ const transformer = getMetaTagTransformer(bodyStream);
+
+ let outputData = '';
+ bodyStream.on('data', chunk => {
+ outputData += chunk.toString();
+ });
+
+ bodyStream.on('end', () => {
+ try {
+ expect(outputData).toBe(
+ 'Test',
+ );
+ expect(getTraceMetaTags).toHaveBeenCalledTimes(1);
+ resolve();
+ } catch (e) {
+ reject(e);
+ }
+ });
+
+ transformer.write('Test');
+ transformer.end();
+ }));
+
+ test('should flush a held-back partial tag at end of stream', () =>
+ new Promise((resolve, reject) => {
+ const bodyStream = new PassThrough();
+ const transformer = getMetaTagTransformer(bodyStream);
+
+ let outputData = '';
+ bodyStream.on('data', chunk => {
+ outputData += chunk.toString();
+ });
+
+ bodyStream.on('end', () => {
+ try {
+ expect(outputData).toBe('';
+const HEAD = '';
+
+function makeInjector() {
+ const getTags = vi.fn(() => TAG);
+ return { getTags, injector: createHeadTagInjector(getTags) };
+}
+
+describe('createHeadTagInjector', () => {
+ test('injects when the tag is fully inside one chunk', () => {
+ const { injector, getTags } = makeInjector();
+ const out = injector.push('x');
+ expect(out).toBe(`${TAG}x`);
+ expect(getTags).toHaveBeenCalledTimes(1);
+ });
+
+ test.each([1, 2, 3, 4, 5])('injects when is split at offset %i across two pushes', offset => {
+ const { injector } = makeInjector();
+ const first = `${HEAD.slice(0, offset)}`;
+ const second = `${HEAD.slice(offset)}`;
+ const out = injector.push(first) + injector.push(second) + injector.flush();
+ expect(out).toBe(`${TAG}${HEAD}`);
+ });
+
+ test('holds back a partial tag and does not emit it early', () => {
+ const { injector } = makeInjector();
+ expect(injector.push('');
+ expect(injector.push('ad>')).toBe(`${TAG}${HEAD}`);
+ });
+
+ test('injects when is split across three chunks', () => {
+ const { injector } = makeInjector();
+ const out = injector.push('') + injector.push('he') + injector.push('ad>end') + injector.flush();
+ expect(out).toBe(`${TAG}${HEAD}end`);
+ });
+
+ test('flush returns the held-back tail when no exists', () => {
+ const { injector, getTags } = makeInjector();
+ const out = injector.push(' {
+ const { injector } = makeInjector();
+ injector.push('');
+ expect(injector.flush()).toBe('');
+ });
+
+ test('injects only into the first ', () => {
+ const { injector, getTags } = makeInjector();
+ const out = injector.push(`${HEAD}${HEAD}`) + injector.flush();
+ expect(out).toBe(`${TAG}${HEAD}${HEAD}`);
+ expect(getTags).toHaveBeenCalledTimes(1);
+ });
+
+ test('does not buffer text after injection', () => {
+ const { injector } = makeInjector();
+ injector.push(HEAD);
+ expect(injector.push(' {
+ const { injector, getTags } = makeInjector();
+ injector.push('');
+ expect(getTags).not.toHaveBeenCalled();
+ injector.push(`${HEAD}`);
+ injector.push(`${HEAD}`);
+ expect(getTags).toHaveBeenCalledTimes(1);
+ });
+});