diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts new file mode 100644 index 000000000000..45173ed825e8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts @@ -0,0 +1,32 @@ +import { wrapRequestHandler } from '@sentry/cloudflare/request'; + +interface Env { + SENTRY_DSN: string; +} + +export default { + async fetch(request, env, ctx) { + const contentType = new URL(request.url).searchParams.get('content-type') ?? 'text/x-component'; + + return wrapRequestHandler( + { + options: { + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + }, + request, + context: ctx, + }, + () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('
shell
')); + controller.close(); + }, + }); + + return new Response(body, { headers: { 'content-type': contentType } }); + }, + ); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts new file mode 100644 index 000000000000..b675a9ce8714 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; + +it.for(['text/x-component;charset=utf-8', 'text/event-stream'])( + 'sends the http.server span of a streamed %s response when TransformStream ignores the transformer', + async (contentType, { signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + }) + .start(signal); + + const body = await runner.makeRequest('get', `/?content-type=${encodeURIComponent(contentType)}`); + expect(body).toBe('
shell
'); + + await runner.completed(); + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc new file mode 100644 index 000000000000..10f17305c6f2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "name": "worker-name", + // With this date and only this flag, `new TransformStream()` ignores the transformer. Mini-oxygen + // releases before 4.0.0 run Workers with this setup. + "compatibility_date": "2022-10-31", + "compatibility_flags": ["streams_enable_constructors"], + "main": "index.ts", +} diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts new file mode 100644 index 000000000000..774290a56061 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts @@ -0,0 +1,19 @@ +interface Env { + SENTRY_DSN: string; +} + +const TEXT = 'precompressed'; + +export default { + async fetch(): Promise { + const gzipped = await new Response( + new Blob([TEXT]).stream().pipeThrough(new CompressionStream('gzip')), + ).arrayBuffer(); + + // `text/plain` without a Content-Length is classified as streaming, so the SDK replaces the response. + return new Response(gzipped, { + encodeBody: 'manual', + headers: { 'content-type': 'text/plain;charset=utf-8', 'content-encoding': 'gzip' }, + }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts new file mode 100644 index 000000000000..dd1ba461f2d6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; + +it('does not compress a response with `encodeBody: manual` a second time', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/' }); + }) + .start(signal); + + const body = await runner.makeRequest('get', '/', { headers: { 'accept-encoding': 'gzip' } }); + expect(body).toBe('precompressed'); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc new file mode 100644 index 000000000000..ffc67decd4d2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-request-handler-precompressed-response", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts new file mode 100644 index 000000000000..f983d2cd0faf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts @@ -0,0 +1,43 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +const SHELL = '
shell
'; +const SUSPENDED = '
suspended
'; + +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const contentType = url.searchParams.get('content-type'); + if (!contentType) { + return new Response('Missing content-type', { status: 400 }); + } + + if (url.searchParams.get('body') === 'stream') { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(SHELL)); + // Waits for a later task, so the suspended part renders after the handler has returned. + await new Promise(resolve => setTimeout(resolve, 0)); + Sentry.startSpan({ name: 'render' }, () => { + controller.enqueue(encoder.encode(SUSPENDED)); + }); + controller.close(); + }, + }); + + return new Response(body, { headers: { 'content-type': contentType } }); + } + + const html = Sentry.startSpan({ name: 'render' }, () => SHELL + SUSPENDED); + const headers = new Headers({ 'content-type': contentType }); + if (url.searchParams.get('body') === 'string-with-content-length') { + headers.set('content-length', String(new TextEncoder().encode(html).byteLength)); + } + + return new Response(html, { headers }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts new file mode 100644 index 000000000000..f4c87d6d4b28 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts @@ -0,0 +1,7 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + isStreamingResponse: response => (response.headers.get('content-type')?.startsWith('text/html') ? true : undefined), +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts new file mode 100644 index 000000000000..9bc187460cbe --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts @@ -0,0 +1,35 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp } from '../../../spanUtils'; + +it.for([ + { body: 'stream', contentType: 'text/html;charset=utf-8' }, + { body: 'stream', contentType: 'text/x-component;charset=utf-8' }, + { body: 'string', contentType: 'text/html;charset=utf-8' }, + { body: 'string', contentType: 'text/x-component;charset=utf-8' }, + { body: 'string-with-content-length', contentType: 'text/html;charset=utf-8' }, + { body: 'string-with-content-length', contentType: 'text/x-component;charset=utf-8' }, +])( + 'nests the render span in the http.server span for a $contentType response with a $body body', + async ({ body, contentType }, { signal }) => { + const runner = createRunner(__dirname).start(signal); + + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.some(span => span.is_segment) && spansOfTrace.some(span => span.name === 'render'), + ); + + const responseBody = await runner.makeRequest( + 'get', + `/?body=${body}&content-type=${encodeURIComponent(contentType)}`, + ); + expect(responseBody).toBe('
shell
suspended
'); + + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment); + const renderSpan = spans.find(span => span.name === 'render'); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(renderSpan?.parent_span_id).toBe(segmentSpan?.span_id); + expect(segmentSpan!.end_timestamp).toBeGreaterThanOrEqual(renderSpan!.end_timestamp); + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc new file mode 100644 index 000000000000..d41141882a2c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-request-handler-streamed-response", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 1a968cfe66b2..095a6b6f374d 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -460,6 +460,32 @@ interface BaseCloudflareOptions { * @default true */ cacheClient?: boolean; + + /** + * A function that receives the response of a `fetch` handler and returns whether its body is + * still being produced after the handler returns. + * + * For `true`, the SDK keeps the `http.server` span open until the body has been sent, so spans + * created while the body streams stay inside the request. It does this by passing the body through + * a `TransformStream`. For `false`, the SDK ends the span when the handler returns and sends the + * response unchanged. For `undefined`, the SDK decides: Server-Sent Events, NDJSON and + * `application/stream+json` responses, and `text/plain` and `text/x-component` responses without a + * `Content-Length` header count as streamed. HTML does not, so return `true` for pages that are + * rendered with streamed SSR. + * + * @example + * ```ts + * // src/instrument.server.ts + * import { defineCloudflareOptions } from '@sentry/cloudflare'; + * + * export default defineCloudflareOptions(env => ({ + * dsn: env.SENTRY_DSN, + * isStreamingResponse: response => + * response.headers.get('content-type')?.startsWith('text/html') ? true : undefined, + * })); + * ``` + */ + isStreamingResponse?: (response: Response) => boolean | undefined; } /** diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 3599b88f4243..8d9a68ff00c3 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -14,11 +14,13 @@ import { httpHeadersToSpanAttributes, HTTP_SPAN_NAME_FALLBACK, parseStringToURLObject, + safeCallback, setHttpStatus, startSpanManual, winterCGHeadersToDict, } from '@sentry/core'; import { classifyResponseStreaming } from '@sentry/core/server'; +import { DEBUG_BUILD } from './debug-build'; import { captureIncomingRequestBody } from './integrations/httpServer'; import { flushDeferredChannelEvents } from './orchestrion-deferred-channels'; import type { CloudflareClient, CloudflareOptions } from './client'; @@ -180,12 +182,17 @@ export function wrapRequestHandlerWithInit( throw e; } - // Classify response to detect actual streaming - const classification = classifyResponseStreaming(res); + const isStreaming = + safeCallback( + DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', + () => options.isStreamingResponse?.(res), + () => undefined, + ) ?? classifyResponseStreaming(res).isStreaming; - if (classification.isStreaming && res.body) { + if (isStreaming && res.body) { try { let ended = false; + let transformerUsed = false; const endSpanOnce = (): void => { if (ended) return; @@ -195,7 +202,14 @@ export function wrapRequestHandlerWithInit( waitUntil?.(flushAndDispose(client)); }; + // Workers with a compatibility date before 2022-11-30 and without the + // `transformstream_enable_standard_constructor` flag (for example under older mini-oxygen + // releases) ignore the transformer, so `flush` and `cancel` would never end the span. Only a + // used transformer runs `start`. const transform = new TransformStream({ + start() { + transformerUsed = true; + }, flush() { // Source stream completed normally. endSpanOnce(); @@ -208,15 +222,14 @@ export function wrapRequestHandlerWithInit( }, }); - return new Response(res.body.pipeThrough(transform), { - status: res.status, - statusText: res.statusText, - headers: res.headers, - }); + if (transformerUsed) { + // Passing the original response as the init keeps `encodeBody: 'manual'`, which a + // Response does not expose as a property. Without it, workerd compresses a + // pre-compressed body a second time. + return new Response(res.body.pipeThrough(transform), res); + } } catch { - span.end(); - waitUntil?.(flushAndDispose(client)); - return res; + // Falls back to ending the span at handler return. } } diff --git a/packages/cloudflare/test/options.test-d.ts b/packages/cloudflare/test/options.test-d.ts index f93a24bb7497..f4e9c25e0b01 100644 --- a/packages/cloudflare/test/options.test-d.ts +++ b/packages/cloudflare/test/options.test-d.ts @@ -107,6 +107,7 @@ describe('valid options keep compiling', () => { serverName: 'my-worker', rpcTracePropagationBindings: ['ORDERS', /^SVC_/], durableObjectSqlSpanAllowlist: ['cf_my_table', /^cf_reports_/], + isStreamingResponse: response => (response.headers.has('content-length') ? false : undefined), beforeSend: event => event, integrations: [], }), diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index ae30155c3c6a..a0cccbe157d5 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -961,6 +961,225 @@ describe('flushAndDispose', () => { }); }); + describe('streaming response classification', () => { + const options: CloudflareOptions = { dsn: MOCK_OPTIONS.dsn, cacheClient: false }; + + test('defers teardown until a text/x-component body without Content-Length is consumed', async () => { + const waits: Promise[] = []; + const waitUntil = vi.fn((promise: Promise) => waits.push(promise)); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + let releaseLastChunk!: () => void; + const lastChunkGate = new Promise(resolve => { + releaseLastChunk = resolve; + }); + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode('
shell
')); + await lastChunkGate; + controller.enqueue(new TextEncoder().encode('
suspended
')); + controller.close(); + }, + }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => new Response(stream, { headers: { 'content-type': 'text/x-component;charset=utf-8' } }), + ); + + expect(waitUntil).not.toHaveBeenCalled(); + + releaseLastChunk(); + expect(await result.text()).toBe('
shell
suspended
'); + + await Promise.all(waits); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('tears down at handler return for a text/html body without Content-Length', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
page
', { headers: { 'content-type': 'text/html;charset=utf-8' } }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test.each(['text/x-component;charset=utf-8', 'text/html;charset=utf-8'])( + 'tears down at handler return for a %s body with a Content-Length', + async contentType => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
prerendered
', { + headers: { 'content-type': contentType, 'content-length': '22' }, + }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }, + ); + + test('tears down at handler return when TransformStream ignores the transformer', async () => { + // workerd without `transformstream_enable_standard_constructor` returns an identity stream. + vi.stubGlobal( + 'TransformStream', + class extends TransformStream { + constructor() { + super(); + } + }, + ); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
shell
', { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('tears down at handler return when isStreamingResponse returns false', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
buffered
', { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }); + + const result = await wrapRequestHandler( + { + options: { ...options, isStreamingResponse: () => false }, + request: new Request('https://example.com'), + context, + }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('defers teardown when isStreamingResponse returns true for an unknown content type', async () => { + const waits: Promise[] = []; + const waitUntil = vi.fn((promise: Promise) => waits.push(promise)); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + let releaseLastChunk!: () => void; + const lastChunkGate = new Promise(resolve => { + releaseLastChunk = resolve; + }); + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode('first')); + await lastChunkGate; + controller.enqueue(new TextEncoder().encode('deferred')); + controller.close(); + }, + }); + + const result = await wrapRequestHandler( + { + options: { + ...options, + isStreamingResponse: response => + response.headers.get('content-type') === 'text/x-script' ? true : undefined, + }, + request: new Request('https://example.com'), + context, + }, + () => new Response(stream, { headers: { 'content-type': 'text/x-script' } }), + ); + + expect(waitUntil).not.toHaveBeenCalled(); + + releaseLastChunk(); + expect(await result.text()).toBe('firstdeferred'); + + await Promise.all(waits); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('uses the default classification when isStreamingResponse throws', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
shell
', { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }); + + const result = await wrapRequestHandler( + { + options: { + ...options, + isStreamingResponse: () => { + throw new Error('isStreamingResponse failed'); + }, + }, + request: new Request('https://example.com'), + context, + }, + () => response, + ); + + expect(result).not.toBe(response); + expect(waitUntil).not.toHaveBeenCalled(); + expect(await result.text()).toBe('
shell
'); + + flushSpy.mockRestore(); + }); + }); + test('dispose is NOT called for protocol upgrade responses (status 101)', async () => { const context = createMockExecutionContext(); const waits: Promise[] = []; diff --git a/packages/core/src/utils/responseStreaming.ts b/packages/core/src/utils/responseStreaming.ts index fee67cbb9f2a..c87f9142cd68 100644 --- a/packages/core/src/utils/responseStreaming.ts +++ b/packages/core/src/utils/responseStreaming.ts @@ -8,7 +8,7 @@ export type StreamingGuess = { * Heuristics: * - No body → not streaming * - Known streaming Content-Types → streaming (SSE, NDJSON, JSON streaming) - * - text/plain without Content-Length → streaming (some AI APIs) + * - text/plain or text/x-component without Content-Length → streaming (some AI APIs, streamed RSC) * - Otherwise → not streaming (conservative default, including HTML/SSR) * * We avoid probing the stream to prevent blocking on transform streams (like injectTraceMetaTags) @@ -27,11 +27,14 @@ export function classifyResponseStreaming(res: Response): StreamingGuess { // - application/x-ndjson, application/ndjson: Newline-delimited JSON // - application/stream+json: JSON streaming // - text/plain (without Content-Length): Some AI APIs use this for streaming text + // - text/x-component (without Content-Length): Streamed React Server Components payloads. + // Prerendered payloads are sent as one buffer with a Content-Length. + // HTML is left out: a buffered `new Response(html)` has no Content-Length header either. if ( /^text\/event-stream\b/i.test(contentType) || /^application\/(x-)?ndjson\b/i.test(contentType) || /^application\/stream\+json\b/i.test(contentType) || - (/^text\/plain\b/i.test(contentType) && !contentLength) + (/^text\/(plain|x-component)\b/i.test(contentType) && !contentLength) ) { return { isStreaming: true }; } diff --git a/packages/core/test/lib/utils/responseStreaming.test.ts b/packages/core/test/lib/utils/responseStreaming.test.ts index 5ec7779bd7e8..40a23fbfe910 100644 --- a/packages/core/test/lib/utils/responseStreaming.test.ts +++ b/packages/core/test/lib/utils/responseStreaming.test.ts @@ -9,6 +9,7 @@ describe('classifyResponseStreaming', () => { ['application/stream+json', true], ['TEXT/EVENT-STREAM', true], ['text/plain', true], + ['text/x-component;charset=utf-8', true], ['text/html', false], ['application/json', false], ['application/octet-stream', false], @@ -20,8 +21,8 @@ describe('classifyResponseStreaming', () => { expect(response.body?.locked).toBe(false); }); - test('treats plain text with a known length as non-streaming', () => { - const response = new Response('ready', { headers: { 'content-type': 'text/plain', 'content-length': '5' } }); + test.each(['text/plain', 'text/x-component'])('treats %s with a known length as non-streaming', contentType => { + const response = new Response('ready', { headers: { 'content-type': contentType, 'content-length': '5' } }); expect(classifyResponseStreaming(response)).toEqual({ isStreaming: false }); });