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..e273b0b59282 --- /dev/null +++ b/packages/react-router/src/server/createSentryHandleRequestWebStream.tsx @@ -0,0 +1,83 @@ +import { _INTERNAL_safeUnref } from '@sentry/core/server'; +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 = _INTERNAL_safeUnref(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..03a2fbf937b7 --- /dev/null +++ b/packages/react-router/test/server/createSentryHandleRequestWebStream.test.ts @@ -0,0 +1,336 @@ +import type * as SentryCoreServer from '@sentry/core/server'; +import { _INTERNAL_safeUnref } from '@sentry/core/server'; +import type { EntryContext } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSentryHandleRequestWebStream } from '../../src/server/createSentryHandleRequestWebStream'; + +vi.mock('@sentry/core/server', async importOriginal => ({ + ...(await importOriginal()), + _INTERNAL_safeUnref: vi.fn(timer => timer), +})); + +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('unrefs the abort timer so it cannot keep a Node process alive', async () => { + mockRenderToReadableStream.mockResolvedValue(createBody(Promise.resolve())); + + const handleRequest = createSentryHandleRequestWebStream({ + renderToReadableStream: mockRenderToReadableStream, + ServerRouter: mockServerRouter, + }); + + await handleRequest(makeRequest(), 200, headers, makeRouterContext(), {}); + + expect(_INTERNAL_safeUnref).toHaveBeenCalledTimes(1); + }); + + 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(' 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('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); + }); +});