From e73d2f5cc9a21f999a6540da4a7130a8a65e6111 Mon Sep 17 00:00:00 2001 From: Luciano Fantone Date: Mon, 12 Oct 2026 10:15:18 +0200 Subject: [PATCH 1/3] feat(react-router): Support renderToReadableStream in createSentryHandleRequest Adds a Web Streams variant of createSentryHandleRequest, accepted through an overload and exported from the cloudflare entry, and handles a tag split across chunks in both the Node and Web transformers. Closes #25168 --- .../app/entry.server.cloudflare.tsx | 45 +-- packages/react-router/src/cloudflare/index.ts | 52 +-- .../src/server/createSentryHandleRequest.tsx | 34 +- .../createSentryHandleRequestWebStream.tsx | 137 ++++++++ .../src/server/getMetaTagTransformer.ts | 14 +- .../src/server/headTagInjector.ts | 47 +++ packages/react-router/src/server/index.ts | 1 + .../src/server/injectTraceMetaTags.ts | 40 +++ .../cloudflare/injectTraceMetaTags.test.ts | 35 +++ .../server/createSentryHandleRequest.test.ts | 19 ++ ...createSentryHandleRequestWebStream.test.ts | 295 ++++++++++++++++++ .../test/server/getMetaTagTransformer.test.ts | 51 +++ .../test/server/headTagInjector.test.ts | 75 +++++ 13 files changed, 736 insertions(+), 109 deletions(-) create mode 100644 packages/react-router/src/server/createSentryHandleRequestWebStream.tsx create mode 100644 packages/react-router/src/server/headTagInjector.ts create mode 100644 packages/react-router/src/server/injectTraceMetaTags.ts create mode 100644 packages/react-router/test/server/createSentryHandleRequestWebStream.test.ts create mode 100644 packages/react-router/test/server/headTagInjector.test.ts 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..721a2a92c26d 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,8 @@ 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, + type SentryWebStreamHandleRequestOptions, +} from '../server/createSentryHandleRequestWebStream'; +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..413be5523265 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 type { + HandleRequestWithMiddleware, + HandleRequestWithoutMiddleware, + SentryWebStreamHandleRequestOptions, +} from './createSentryHandleRequestWebStream'; +import { createSentryHandleRequestWebStream } from './createSentryHandleRequestWebStream'; 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..c752d38221c7 --- /dev/null +++ b/packages/react-router/src/server/createSentryHandleRequestWebStream.tsx @@ -0,0 +1,137 @@ +import type { ReactNode } from 'react'; +import React from 'react'; +import type { AppLoadContext, EntryContext, RouterContextProvider, ServerRouter } from 'react-router'; +import { injectTraceMetaTags } from './injectTraceMetaTags'; +import { wrapSentryHandleRequest } from './wrapSentryHandleRequest'; + +type RenderToReadableStreamOptions = { + [key: string]: unknown; + nonce?: string; + signal?: AbortSignal; + onError?: (error: unknown) => void; +}; + +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; + +/** + * 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.catch(() => undefined); + } + + 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/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..d010f4fd3605 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 './createSentryHandleRequestWebStream'; 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..beee500b2ff7 --- /dev/null +++ b/packages/react-router/src/server/injectTraceMetaTags.ts @@ -0,0 +1,40 @@ +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; + } + + 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 injector = createHeadTagInjector(getTraceMetaTags); + + return body.pipeThrough( + new TransformStream({ + transform(value, controller) { + const html = value instanceof Uint8Array ? decoder.decode(value, { stream: true }) : String(value); + const out = injector.push(html); + if (out) { + controller.enqueue(encoder.encode(out)); + } + }, + flush(controller) { + const out = injector.push(decoder.decode()) + injector.flush(); + if (out) { + controller.enqueue(encoder.encode(out)); + } + }, + }), + ); +} diff --git a/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts b/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts index 012a426bb5d6..ddfcdcc5897c 100644 --- a/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts +++ b/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts @@ -96,4 +96,39 @@ 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(' { 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..401296620f08 --- /dev/null +++ b/packages/react-router/test/server/createSentryHandleRequestWebStream.test.ts @@ -0,0 +1,295 @@ +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('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(' 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); + }); +}); From 85b6ae49f5558efa094dc143493c91f9c0b144f8 Mon Sep 17 00:00:00 2001 From: Luciano Fantone Date: Mon, 12 Oct 2026 17:37:41 +0200 Subject: [PATCH 2/3] fix(react-router): Resolve trace meta tags eagerly and share handler types Resolve the trace meta tags when injectTraceMetaTags is called instead of inside the stream transform. Runtimes such as workerd run the transform outside the request's async context, where no trace data is available, so the tags were silently dropped. Also let allReady rejections propagate for bots and SPA mode, and move the handler types shared by the Node and Web Streams handlers into their own module. --- packages/react-router/src/cloudflare/index.ts | 6 +- .../src/server/createSentryHandleRequest.tsx | 4 +- .../createSentryHandleRequestWebStream.tsx | 71 +++---------------- .../src/server/handleRequestTypes.ts | 62 ++++++++++++++++ packages/react-router/src/server/index.ts | 2 +- .../src/server/injectTraceMetaTags.ts | 5 +- .../cloudflare/injectTraceMetaTags.test.ts | 12 +++- ...createSentryHandleRequestWebStream.test.ts | 21 ++++++ 8 files changed, 111 insertions(+), 72 deletions(-) create mode 100644 packages/react-router/src/server/handleRequestTypes.ts diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index 721a2a92c26d..45a6996eff73 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -5,8 +5,6 @@ export { createSentryServerInstrumentation, type CreateSentryServerInstrumentationOptions, } from '../server/createServerInstrumentation'; -export { - createSentryHandleRequestWebStream as createSentryHandleRequest, - type SentryWebStreamHandleRequestOptions, -} from '../server/createSentryHandleRequestWebStream'; +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 413be5523265..be901a13c28b 100644 --- a/packages/react-router/src/server/createSentryHandleRequest.tsx +++ b/packages/react-router/src/server/createSentryHandleRequest.tsx @@ -4,12 +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 './createSentryHandleRequestWebStream'; -import { createSentryHandleRequestWebStream } from './createSentryHandleRequestWebStream'; +} from './handleRequestTypes'; import { wrapSentryHandleRequest } from './wrapSentryHandleRequest'; type RenderToPipeableStreamOptions = { diff --git a/packages/react-router/src/server/createSentryHandleRequestWebStream.tsx b/packages/react-router/src/server/createSentryHandleRequestWebStream.tsx index c752d38221c7..37c5646b3468 100644 --- a/packages/react-router/src/server/createSentryHandleRequestWebStream.tsx +++ b/packages/react-router/src/server/createSentryHandleRequestWebStream.tsx @@ -1,69 +1,14 @@ -import type { ReactNode } from 'react'; import React from 'react'; -import type { AppLoadContext, EntryContext, RouterContextProvider, ServerRouter } from 'react-router'; +import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router'; +import type { + HandleRequestWithMiddleware, + HandleRequestWithoutMiddleware, + RenderToReadableStreamResult, + SentryWebStreamHandleRequestOptions, +} from './handleRequestTypes'; import { injectTraceMetaTags } from './injectTraceMetaTags'; import { wrapSentryHandleRequest } from './wrapSentryHandleRequest'; -type RenderToReadableStreamOptions = { - [key: string]: unknown; - nonce?: string; - signal?: AbortSignal; - onError?: (error: unknown) => void; -}; - -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; - /** * Web Streams variant of `createSentryHandleRequest`, for runtimes without Node streams (e.g. Cloudflare Workers). * Must not import any Node.js modules. @@ -122,7 +67,7 @@ export function createSentryHandleRequestWebStream( body.allReady.then(clearAbortTimer, clearAbortTimer); if (isBot || isSpaMode) { - await body.allReady.catch(() => undefined); + await body.allReady; } responseHeaders.set('Content-Type', 'text/html'); 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/index.ts b/packages/react-router/src/server/index.ts index d010f4fd3605..b458747afbaa 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -6,7 +6,7 @@ export * from '@sentry/node'; export { init } from './sdk'; export { wrapSentryHandleRequest } from './wrapSentryHandleRequest'; export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest'; -export type { SentryWebStreamHandleRequestOptions } from './createSentryHandleRequestWebStream'; +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 index beee500b2ff7..4dc2a8867913 100644 --- a/packages/react-router/src/server/injectTraceMetaTags.ts +++ b/packages/react-router/src/server/injectTraceMetaTags.ts @@ -18,7 +18,10 @@ export function injectTraceMetaTags(body: ReadableStream): ReadableStream { // 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 injector = createHeadTagInjector(getTraceMetaTags); + // Resolved now, while the request's async context is still active. Runtimes like workerd run + // stream transforms outside of it, where there is no client and no trace data. + const traceMetaTags = getTraceMetaTags(); + const injector = createHeadTagInjector(() => traceMetaTags); return body.pipeThrough( new TransformStream({ diff --git a/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts b/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts index ddfcdcc5897c..ee77b54f9fbc 100644 --- a/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts +++ b/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts @@ -129,6 +129,16 @@ describe('injectTraceMetaTags', () => { const output = new TextDecoder().decode(await readAll(injectTraceMetaTags(input))); expect(output).toBe(' { + 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(' { 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)); From 89478de78dbce6a796bea146fed098d629967e8f Mon Sep 17 00:00:00 2001 From: Luciano Fantone Date: Tue, 13 Oct 2026 14:26:27 +0200 Subject: [PATCH 3/3] fix(react-router): Avoid the TransformStream constructor in injectTraceMetaTags workerd only supports a custom TransformStream transformer from compatibility date 2024-11-11, and Hydrogen's MiniOxygen runs with an older one, so the constructor threw and no trace meta tags were injected. Use a ReadableStream with a pull handler instead, as before, while keeping the shared handling. --- .../src/server/injectTraceMetaTags.ts | 47 ++++++++++++------- .../cloudflare/injectTraceMetaTags.test.ts | 38 +++++++++++++++ 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/packages/react-router/src/server/injectTraceMetaTags.ts b/packages/react-router/src/server/injectTraceMetaTags.ts index 4dc2a8867913..1b1659922ebe 100644 --- a/packages/react-router/src/server/injectTraceMetaTags.ts +++ b/packages/react-router/src/server/injectTraceMetaTags.ts @@ -13,31 +13,46 @@ export function injectTraceMetaTags(body: ReadableStream): ReadableStream { 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(); - // Resolved now, while the request's async context is still active. Runtimes like workerd run - // stream transforms outside of it, where there is no client and no trace data. - const traceMetaTags = getTraceMetaTags(); - const injector = createHeadTagInjector(() => traceMetaTags); - return body.pipeThrough( - new TransformStream({ - transform(value, controller) { + // 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; } - }, - flush(controller) { - const out = injector.push(decoder.decode()) + injector.flush(); - if (out) { - controller.enqueue(encoder.encode(out)); - } - }, - }), - ); + } + }, + 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 ee77b54f9fbc..b6bc564c2137 100644 --- a/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts +++ b/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts @@ -131,6 +131,44 @@ describe('injectTraceMetaTags', () => { expect(output).toBe(' { + 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('')]);