From a6269c957ccedf52742224c1b966f1cad74600ae Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 24 Feb 2026 20:50:20 +0100 Subject: [PATCH 01/34] Node.js streams: First pass --- .../app-render/app-render-prerender-utils.ts | 62 ++- .../server/app-render/flight-render-result.ts | 3 +- .../src/server/app-render/stream-ops.node.ts | 423 ++++++++++++++++++ .../next/src/server/app-render/stream-ops.ts | 104 +++-- .../src/server/app-render/stream-ops.web.ts | 3 +- packages/next/src/server/pipe-readable.ts | 95 ++++ packages/next/src/server/render-result.ts | 38 +- 7 files changed, 685 insertions(+), 43 deletions(-) create mode 100644 packages/next/src/server/app-render/stream-ops.node.ts diff --git a/packages/next/src/server/app-render/app-render-prerender-utils.ts b/packages/next/src/server/app-render/app-render-prerender-utils.ts index 9e21adb2865d..192fc32cc4de 100644 --- a/packages/next/src/server/app-render/app-render-prerender-utils.ts +++ b/packages/next/src/server/app-render/app-render-prerender-utils.ts @@ -1,28 +1,46 @@ +import type { Readable } from 'node:stream' import { InvariantError } from '../../shared/lib/invariant-error' +export type StreamLike = ReadableStream | Readable + +function isWebStream(stream: StreamLike): stream is ReadableStream { + return typeof (stream as ReadableStream).tee === 'function' +} + // React's RSC prerender function will emit an incomplete flight stream when using `prerender`. If the connection // closes then whatever hanging chunks exist will be errored. This is because prerender (an experimental feature) // has not yet implemented a concept of resume. For now we will simulate a paused connection by wrapping the stream // in one that doesn't close even when the underlying is complete. export class ReactServerResult { - private _stream: null | ReadableStream + private _stream: null | StreamLike - constructor(stream: ReadableStream) { + constructor(stream: StreamLike) { this._stream = stream } - tee() { + tee(): StreamLike { if (this._stream === null) { throw new Error( 'Cannot tee a ReactServerResult that has already been consumed' ) } - const tee = this._stream.tee() - this._stream = tee[0] - return tee[1] + if (isWebStream(this._stream)) { + const tee = this._stream.tee() + this._stream = tee[0] + return tee[1] + } + // Node.js Readable: pipe to two PassThrough streams + const { PassThrough } = + require('node:stream') as typeof import('node:stream') + const pt1 = new PassThrough() + const pt2 = new PassThrough() + this._stream.pipe(pt1) + this._stream.pipe(pt2) + this._stream = pt1 + return pt2 } - consume() { + consume(): StreamLike { if (this._stream === null) { throw new Error( 'Cannot consume a ReactServerResult that has already been consumed' @@ -55,18 +73,32 @@ export async function createReactServerPrerenderResult( } export async function createReactServerPrerenderResultFromRender( - underlying: ReadableStream + underlying: StreamLike ): Promise { const chunks: Array = [] - const reader = underlying.getReader() - while (true) { - const { done, value } = await reader.read() - if (done) { - break - } else { - chunks.push(value) + + if (isWebStream(underlying)) { + const reader = underlying.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } else { + chunks.push(value) + } } + } else { + // Node.js Readable stream + const readable: Readable = underlying + await new Promise((resolve, reject) => { + readable.on('data', (chunk: Buffer | Uint8Array) => { + chunks.push(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)) + }) + readable.on('end', resolve) + readable.on('error', reject) + }) } + return new ReactServerPrerenderResult(chunks) } export class ReactServerPrerenderResult { diff --git a/packages/next/src/server/app-render/flight-render-result.ts b/packages/next/src/server/app-render/flight-render-result.ts index 005c618294e8..bb79f651728b 100644 --- a/packages/next/src/server/app-render/flight-render-result.ts +++ b/packages/next/src/server/app-render/flight-render-result.ts @@ -1,3 +1,4 @@ +import type { Readable } from 'node:stream' import { RSC_CONTENT_TYPE_HEADER } from '../../client/components/app-router-headers' import RenderResult, { type RenderResultMetadata } from '../render-result' @@ -6,7 +7,7 @@ import RenderResult, { type RenderResultMetadata } from '../render-result' */ export class FlightRenderResult extends RenderResult { constructor( - response: string | ReadableStream, + response: string | ReadableStream | Readable, metadata: RenderResultMetadata = {}, waitUntil?: Promise ) { diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts new file mode 100644 index 000000000000..43ef7fdb359d --- /dev/null +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -0,0 +1,423 @@ +/** + * Node.js stream operations for the rendering pipeline. + * Loaded by stream-ops.ts when process.env.__NEXT_USE_NODE_STREAMS is true. + * + * AnyStream = Readable in this module. + * Rendering uses pipeable APIs; continue functions wrap the existing web + * transforms via Readable.fromWeb() on their output. + */ + +import type { PostponedState, PrerenderOptions } from 'react-dom/static' +import { + renderToPipeableStream, + resumeToPipeableStream, +} from 'react-dom/server' +import { prerender } from 'react-dom/static' +import { PassThrough, Readable } from 'node:stream' + +import type { ReactDOMServerReadableStream } from 'react-dom/server' +import { + continueFizzStream as webContinueFizzStream, + continueStaticPrerender as webContinueStaticPrerender, + continueDynamicPrerender as webContinueDynamicPrerender, + continueStaticFallbackPrerender as webContinueStaticFallbackPrerender, + continueDynamicHTMLResume as webContinueDynamicHTMLResume, + streamToBuffer as webStreamToBuffer, + streamToString as webStreamToString, + createDocumentClosingStream as webCreateDocumentClosingStream, + createRuntimePrefetchTransformStream, +} from '../stream-utils/node-web-streams-helper' +import { createInlinedDataReadableStream } from './use-flight-response' +import type { StreamLike } from './app-render-prerender-utils' +import { DetachedPromise } from '../../lib/detached-promise' +import { getTracer } from '../lib/trace/tracer' +import { AppRenderSpan } from '../lib/trace/constants' + +// --------------------------------------------------------------------------- +// Re-export shared types from the web module +// --------------------------------------------------------------------------- + +export type { + ContinueStreamSharedOptions, + ContinueFizzStreamOptions, + ContinueStaticPrerenderOptions, + ContinueDynamicHTMLResumeOptions, + ServerPrerenderComponentMod, + FlightPayload, + FlightClientModules, + FlightRenderOptions, +} from './stream-ops.web' + +// --------------------------------------------------------------------------- +// Override AnyStream and dependent types for Node path +// --------------------------------------------------------------------------- + +export type AnyStream = Readable + +export type FlightComponentMod = { + renderToReadableStream: ( + model: any, + webpackMap: any, + options?: any + ) => ReadableStream + renderToPipeableStream?: ( + model: any, + webpackMap: any, + options?: any + ) => { + pipe( + destination: Writable + ): Writable + abort(reason?: unknown): void + } +} + +export type FizzStreamResult = { + stream: AnyStream + allReady: Promise + abort?: (reason?: unknown) => void +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +type WebReadableStream = import('stream/web').ReadableStream + +function readableToWeb( + stream: Readable | ReadableStream +): ReadableStream { + if (stream instanceof ReadableStream) { + return stream + } + // Readable.toWeb returns stream/web ReadableStream which is structurally + // identical to the global ReadableStream. + return Readable.toWeb(stream) as unknown as ReadableStream +} + +function webToReadable( + stream: ReadableStream | Readable +): Readable { + if (stream instanceof Readable) { + return stream + } + return Readable.fromWeb(stream as WebReadableStream) +} + +// --------------------------------------------------------------------------- +// Rendering functions (output Node Readable natively via PassThrough) +// --------------------------------------------------------------------------- + +export function renderToFlightStream( + ComponentMod: FlightComponentMod, + payload: any, + clientModules: any, + opts: any, + runInContext?: (fn: () => T) => T +): AnyStream { + const run: (fn: () => T) => T = runInContext ?? ((fn) => fn()) + + if (ComponentMod.renderToPipeableStream) { + const pt = new PassThrough() + const pipeable = run(() => + ComponentMod.renderToPipeableStream!(payload, clientModules, opts) + ) + pipeable.pipe(pt) + return pt + } + + // Fallback: use web API and convert + const webStream = run(() => + ComponentMod.renderToReadableStream(payload, clientModules, opts) + ) + return webToReadable(webStream) +} + +export async function renderToFizzStream( + element: React.ReactElement, + streamOptions: any, + runInContext?: (fn: () => T) => T +): Promise { + const run: (fn: () => T) => T = runInContext ?? ((fn) => fn()) + + const pt = new PassThrough() + const shellReady = new DetachedPromise() + const allReady = new DetachedPromise() + + // Node.js renderToPipeableStream passes a plain object to onHeaders, + // but callers expect a web Headers instance. + const originalOnHeaders = streamOptions?.onHeaders + const wrappedOnHeaders = originalOnHeaders + ? (headers: Record) => { + originalOnHeaders(new Headers(headers)) + } + : undefined + + const pipeable = run(() => + getTracer().trace(AppRenderSpan.renderToReadableStream, () => + renderToPipeableStream(element, { + ...streamOptions, + onHeaders: wrappedOnHeaders, + onShellReady() { + streamOptions?.onShellReady?.() + pipeable.pipe(pt) + shellReady.resolve() + }, + onShellError(error: unknown) { + streamOptions?.onShellError?.(error) + shellReady.reject(error) + }, + onAllReady() { + streamOptions?.onAllReady?.() + allReady.resolve() + }, + onError: streamOptions?.onError, + }) + ) + ) + + await shellReady.promise + + return { + stream: pt, + allReady: allReady.promise, + abort: (reason?: unknown) => pipeable.abort(reason), + } +} + +export async function resumeToFizzStream( + element: React.ReactElement, + postponedState: PostponedState, + streamOptions: any, + runInContext?: (fn: () => T) => T +): Promise { + const run: (fn: () => T) => T = runInContext ?? ((fn) => fn()) + + const pt = new PassThrough() + const allReady = new DetachedPromise() + + const pipeable = await run(() => + resumeToPipeableStream(element, postponedState, { + ...streamOptions, + onAllReady() { + streamOptions?.onAllReady?.() + allReady.resolve() + }, + }) + ) + pipeable.pipe(pt) + + return { + stream: pt, + allReady: allReady.promise, + abort: (reason?: unknown) => pipeable.abort(reason), + } +} + +export async function resumeAndAbort( + element: React.ReactElement, + postponed: PostponedState | null, + opts: any +): Promise { + const pt = new PassThrough() + const pipeable = await resumeToPipeableStream( + element, + postponed as PostponedState, + opts + ) + pipeable.pipe(pt) + pipeable.abort() + return pt +} + +// --------------------------------------------------------------------------- +// Continue function wrappers +// Bridge Node Readable → web, apply existing web transforms, Readable.fromWeb() +// --------------------------------------------------------------------------- + +export async function continueFizzStream( + renderStream: AnyStream, + opts: import('./stream-ops.web').ContinueFizzStreamOptions +): Promise { + const webOpts = { + ...opts, + inlinedDataStream: opts.inlinedDataStream + ? readableToWeb(opts.inlinedDataStream) + : undefined, + } + const webResult = await webContinueFizzStream( + readableToWeb(renderStream) as ReactDOMServerReadableStream, + webOpts + ) + return webToReadable(webResult) +} + +export async function continueStaticPrerender( + prerenderStream: AnyStream, + opts: import('./stream-ops.web').ContinueStaticPrerenderOptions +): Promise { + const webResult = await webContinueStaticPrerender( + readableToWeb(prerenderStream), + { + ...opts, + inlinedDataStream: readableToWeb(opts.inlinedDataStream), + } + ) + return webToReadable(webResult) +} + +export async function continueDynamicPrerender( + prerenderStream: AnyStream, + opts: { + getServerInsertedHTML: () => Promise + getServerInsertedMetadata: () => Promise + deploymentId: string | undefined + } +): Promise { + const webResult = await webContinueDynamicPrerender( + readableToWeb(prerenderStream), + opts + ) + return webToReadable(webResult) +} + +export async function continueStaticFallbackPrerender( + prerenderStream: AnyStream, + opts: import('./stream-ops.web').ContinueStaticPrerenderOptions +): Promise { + const webResult = await webContinueStaticFallbackPrerender( + readableToWeb(prerenderStream), + { + ...opts, + inlinedDataStream: readableToWeb(opts.inlinedDataStream), + } + ) + return webToReadable(webResult) +} + +export async function continueDynamicHTMLResume( + renderStream: AnyStream, + opts: import('./stream-ops.web').ContinueDynamicHTMLResumeOptions +): Promise { + const webResult = await webContinueDynamicHTMLResume( + readableToWeb(renderStream), + { + ...opts, + inlinedDataStream: readableToWeb(opts.inlinedDataStream), + } + ) + return webToReadable(webResult) +} + +// --------------------------------------------------------------------------- +// Utility functions (Node-native) +// --------------------------------------------------------------------------- + +export function chainStreams(...streams: AnyStream[]): AnyStream { + if (streams.length === 0) { + const pt = new PassThrough() + pt.end() + return pt + } + + if (streams.length === 1) { + return streams[0] + } + + const out = new PassThrough() + let i = 0 + + function pipeNext() { + if (i >= streams.length) { + out.end() + return + } + const current = streams[i++] + current.pipe(out, { end: false }) + current.on('end', pipeNext) + current.on('error', (err) => out.destroy(err)) + } + + pipeNext() + return out +} + +export async function streamToBuffer(stream: AnyStream): Promise { + return webStreamToBuffer(readableToWeb(stream)) +} + +export async function streamToString(stream: AnyStream): Promise { + return webStreamToString(readableToWeb(stream)) +} + +export function createInlinedDataStream( + source: StreamLike, + nonce: string | undefined, + formState: unknown | null +): AnyStream { + const webSource = readableToWeb(source) + const webResult = createInlinedDataReadableStream(webSource, nonce, formState) + return webToReadable(webResult) +} + +export function createPendingStream(): AnyStream { + return new PassThrough() +} + +export function createDocumentClosingStream(): AnyStream { + const webStream = webCreateDocumentClosingStream() + return webToReadable(webStream) +} + +export function createOnHeadersCallback( + appendHeader: (key: string, value: string) => void +): NonNullable { + return (headers: Headers) => { + headers.forEach((value, key) => { + appendHeader(key, value) + }) + } +} + +export function pipeRuntimePrefetchTransform( + stream: AnyStream, + sentinel: number, + isPartial: boolean, + staleTime: number +): AnyStream { + const webStream = readableToWeb(stream) + const transformed = webStream.pipeThrough( + createRuntimePrefetchTransformStream(sentinel, isPartial, staleTime) + ) + return webToReadable(transformed) +} + +// --------------------------------------------------------------------------- +// Re-exports (no stream involvement, identical to web) +// --------------------------------------------------------------------------- + +export async function processPrelude(unprocessedPrelude: AnyStream) { + const pt1 = new PassThrough() + const pt2 = new PassThrough() + ;(unprocessedPrelude as Readable).pipe(pt1) + ;(unprocessedPrelude as Readable).pipe(pt2) + + const firstChunk = await new Promise((resolve) => { + pt2.once('data', (chunk: Buffer) => { + pt2.destroy() + resolve(chunk) + }) + pt2.once('end', () => resolve(null)) + }) + + return { prelude: pt1 as AnyStream, preludeIsEmpty: firstChunk === null } +} + +export function getServerPrerender(ComponentMod: { + prerender: (...args: any[]) => Promise +}): (...args: any[]) => any { + return ComponentMod.prerender +} + +export const getClientPrerender: typeof import('react-dom/static').prerender = + prerender diff --git a/packages/next/src/server/app-render/stream-ops.ts b/packages/next/src/server/app-render/stream-ops.ts index c703556f440c..2113afb38f6a 100644 --- a/packages/next/src/server/app-render/stream-ops.ts +++ b/packages/next/src/server/app-render/stream-ops.ts @@ -1,8 +1,13 @@ /** * Compile-time switcher for stream operations. * - * PR2: Simple re-export from the web implementation. - * A future change will add a conditional branch for node streams. + * When __NEXT_USE_NODE_STREAMS is true, uses Node.js pipeable stream APIs. + * Otherwise, uses web ReadableStream APIs. + * + * Types are always sourced from stream-ops.web (the API surface is identical). + * In the Node path, AnyStream is Readable at runtime, but consumers see + * ReadableStream from the type exports — the module-level cast + * bridges this intentional mismatch in one place. */ export type { AnyStream, @@ -18,26 +23,75 @@ export type { FizzStreamResult, } from './stream-ops.web' -export { - continueFizzStream, - continueStaticPrerender, - continueDynamicPrerender, - continueStaticFallbackPrerender, - continueDynamicHTMLResume, - streamToBuffer, - chainStreams, - createDocumentClosingStream, - processPrelude, - nodeReadableToWeb, - createInlinedDataStream, - createPendingStream, - createOnHeadersCallback, - resumeAndAbort, - renderToFlightStream, - streamToString, - renderToFizzStream, - resumeToFizzStream, - getServerPrerender, - getClientPrerender, - pipeRuntimePrefetchTransform, -} from './stream-ops.web' +type WebMod = typeof import('./stream-ops.web') + +export let continueFizzStream: WebMod['continueFizzStream'] +export let continueStaticPrerender: WebMod['continueStaticPrerender'] +export let continueDynamicPrerender: WebMod['continueDynamicPrerender'] +export let continueStaticFallbackPrerender: WebMod['continueStaticFallbackPrerender'] +export let continueDynamicHTMLResume: WebMod['continueDynamicHTMLResume'] +export let streamToBuffer: WebMod['streamToBuffer'] +export let chainStreams: WebMod['chainStreams'] +export let createDocumentClosingStream: WebMod['createDocumentClosingStream'] +export let processPrelude: WebMod['processPrelude'] +export let createInlinedDataStream: WebMod['createInlinedDataStream'] +export let createPendingStream: WebMod['createPendingStream'] +export let createOnHeadersCallback: WebMod['createOnHeadersCallback'] +export let resumeAndAbort: WebMod['resumeAndAbort'] +export let renderToFlightStream: WebMod['renderToFlightStream'] +export let streamToString: WebMod['streamToString'] +export let renderToFizzStream: WebMod['renderToFizzStream'] +export let resumeToFizzStream: WebMod['resumeToFizzStream'] +export let getServerPrerender: WebMod['getServerPrerender'] +export let getClientPrerender: WebMod['getClientPrerender'] +export let pipeRuntimePrefetchTransform: WebMod['pipeRuntimePrefetchTransform'] + +if (process.env.__NEXT_USE_NODE_STREAMS) { + // The node module uses Readable where the web module uses ReadableStream. + // Consumers always see the web types via the type exports above, so we + // bridge to WebMod once here rather than casting per-export. + const _m: WebMod = + require('./stream-ops.node') as typeof import('./stream-ops.node') as unknown as WebMod + continueFizzStream = _m.continueFizzStream + continueStaticPrerender = _m.continueStaticPrerender + continueDynamicPrerender = _m.continueDynamicPrerender + continueStaticFallbackPrerender = _m.continueStaticFallbackPrerender + continueDynamicHTMLResume = _m.continueDynamicHTMLResume + streamToBuffer = _m.streamToBuffer + chainStreams = _m.chainStreams + createDocumentClosingStream = _m.createDocumentClosingStream + processPrelude = _m.processPrelude + createInlinedDataStream = _m.createInlinedDataStream + createPendingStream = _m.createPendingStream + createOnHeadersCallback = _m.createOnHeadersCallback + resumeAndAbort = _m.resumeAndAbort + renderToFlightStream = _m.renderToFlightStream + streamToString = _m.streamToString + renderToFizzStream = _m.renderToFizzStream + resumeToFizzStream = _m.resumeToFizzStream + getServerPrerender = _m.getServerPrerender + getClientPrerender = _m.getClientPrerender + pipeRuntimePrefetchTransform = _m.pipeRuntimePrefetchTransform +} else { + const _m = require('./stream-ops.web') as typeof import('./stream-ops.web') + continueFizzStream = _m.continueFizzStream + continueStaticPrerender = _m.continueStaticPrerender + continueDynamicPrerender = _m.continueDynamicPrerender + continueStaticFallbackPrerender = _m.continueStaticFallbackPrerender + continueDynamicHTMLResume = _m.continueDynamicHTMLResume + streamToBuffer = _m.streamToBuffer + chainStreams = _m.chainStreams + createDocumentClosingStream = _m.createDocumentClosingStream + processPrelude = _m.processPrelude + createInlinedDataStream = _m.createInlinedDataStream + createPendingStream = _m.createPendingStream + createOnHeadersCallback = _m.createOnHeadersCallback + resumeAndAbort = _m.resumeAndAbort + renderToFlightStream = _m.renderToFlightStream + streamToString = _m.streamToString + renderToFizzStream = _m.renderToFizzStream + resumeToFizzStream = _m.resumeToFizzStream + getServerPrerender = _m.getServerPrerender + getClientPrerender = _m.getClientPrerender + pipeRuntimePrefetchTransform = _m.pipeRuntimePrefetchTransform +} diff --git a/packages/next/src/server/app-render/stream-ops.web.ts b/packages/next/src/server/app-render/stream-ops.web.ts index 509e5143b297..053a3f19e266 100644 --- a/packages/next/src/server/app-render/stream-ops.web.ts +++ b/packages/next/src/server/app-render/stream-ops.web.ts @@ -14,6 +14,7 @@ import { continueFizzStream as webContinueFizzStream, } from '../stream-utils/node-web-streams-helper' import { createInlinedDataReadableStream } from './use-flight-response' +import type { StreamLike } from './app-render-prerender-utils' // --------------------------------------------------------------------------- // Shared types (web-only for now; will move to stream-ops.node.ts later) @@ -106,7 +107,7 @@ export const nodeReadableToWeb: // --------------------------------------------------------------------------- export function createInlinedDataStream( - source: AnyStream, + source: StreamLike, nonce: string | undefined, formState: unknown | null ): AnyStream { diff --git a/packages/next/src/server/pipe-readable.ts b/packages/next/src/server/pipe-readable.ts index 2722c0c474cb..b4c1939fb946 100644 --- a/packages/next/src/server/pipe-readable.ts +++ b/packages/next/src/server/pipe-readable.ts @@ -1,4 +1,5 @@ import type { ServerResponse } from 'node:http' +import type { Readable } from 'node:stream' import { ResponseAbortedName, @@ -144,3 +145,97 @@ export async function pipeToNodeResponse( throw new Error('failed to pipe response', { cause: err }) } } + +export async function pipeNodeReadableToNodeResponse( + readable: Readable, + res: ServerResponse, + waitUntilForEnd?: Promise +) { + try { + const { errored, destroyed } = res + if (errored || destroyed) return + + let started = false + + const finished = new DetachedPromise() + + res.once('close', () => { + readable.destroy() + finished.resolve() + }) + + readable.on('data', (chunk: Buffer) => { + if (!started) { + started = true + + if ( + 'performance' in globalThis && + process.env.NEXT_OTEL_PERFORMANCE_PREFIX + ) { + const metrics = getClientComponentLoaderMetrics() + if (metrics) { + performance.measure( + `${process.env.NEXT_OTEL_PERFORMANCE_PREFIX}:next-client-component-loading`, + { + start: metrics.clientComponentLoadStart, + end: + metrics.clientComponentLoadStart + + metrics.clientComponentLoadTimes, + } + ) + } + } + + res.flushHeaders() + getTracer().trace( + NextNodeServerSpan.startResponse, + { + spanName: 'start response', + }, + () => undefined + ) + } + + const ok = res.write(chunk) + + if ('flush' in res && typeof res.flush === 'function') { + res.flush() + } + + if (!ok) { + readable.pause() + res.once('drain', () => { + readable.resume() + }) + } + }) + + readable.on('end', async () => { + if (waitUntilForEnd) { + await waitUntilForEnd + } + + if (!res.writableFinished) { + res.end() + } + + finished.resolve() + }) + + readable.on('error', (err) => { + if (isAbortError(err)) { + finished.resolve() + return + } + + res.destroy(err) + finished.resolve() + }) + + await finished.promise + } catch (err: any) { + if (isAbortError(err)) return + + throw new Error('failed to pipe response', { cause: err }) + } +} diff --git a/packages/next/src/server/render-result.ts b/packages/next/src/server/render-result.ts index d46ca5a21554..56745700e717 100644 --- a/packages/next/src/server/render-result.ts +++ b/packages/next/src/server/render-result.ts @@ -1,4 +1,5 @@ import type { OutgoingHttpHeaders, ServerResponse } from 'http' +import type { Readable } from 'node:stream' import type { CacheControl } from './lib/cache-control' import type { FetchMetrics } from './base-http' import type { PrefetchHints } from '../shared/lib/app-router-types' @@ -9,7 +10,11 @@ import { streamFromString, streamToString, } from './stream-utils/node-web-streams-helper' -import { isAbortError, pipeToNodeResponse } from './pipe-readable' +import { + isAbortError, + pipeToNodeResponse, + pipeNodeReadableToNodeResponse, +} from './pipe-readable' import type { RenderResumeDataCache } from './resume-data-cache/resume-data-cache' import { InvariantError } from '../shared/lib/invariant-error' import type { @@ -82,6 +87,7 @@ export type RenderResultMetadata = AppPageRenderResultMetadata & export type RenderResultResponse = | ReadableStream[] | ReadableStream + | Readable | string | Buffer | null @@ -94,6 +100,16 @@ export type RenderResultOptions< metadata: Metadata } +function isNodeReadable(value: unknown): value is Readable { + return ( + value !== null && + typeof value === 'object' && + typeof (value as Record).pipe === 'function' && + typeof (value as Record).on === 'function' && + !(value instanceof ReadableStream) + ) +} + export default class RenderResult< Metadata extends RenderResultMetadata = RenderResultMetadata, > { @@ -231,6 +247,12 @@ export default class RenderResult< return chainStreams(...this.response) } + if (isNodeReadable(this.response)) { + const { Readable: NodeReadable } = + require('node:stream') as typeof import('node:stream') + return NodeReadable.toWeb(this.response) as ReadableStream + } + return this.response } @@ -253,6 +275,10 @@ export default class RenderResult< return this.response } else if (Buffer.isBuffer(this.response)) { return [streamFromBuffer(this.response)] + } else if (isNodeReadable(this.response)) { + const { Readable: NodeReadable } = + require('node:stream') as typeof import('node:stream') + return [NodeReadable.toWeb(this.response) as ReadableStream] } else { return [this.response] } @@ -349,6 +375,16 @@ export default class RenderResult< * @param res */ public async pipeToNodeResponse(res: ServerResponse) { + if ( + this.response !== null && + typeof this.response !== 'string' && + !Buffer.isBuffer(this.response) && + !Array.isArray(this.response) && + isNodeReadable(this.response) + ) { + await pipeNodeReadableToNodeResponse(this.response, res, this.waitUntil) + return + } await pipeToNodeResponse(this.readable, res, this.waitUntil) } } From 94530e9393b049701167698fede81cf9117753fa Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 25 Feb 2026 11:02:42 +0100 Subject: [PATCH 02/34] Simplify types --- .../next/src/server/app-render/app-render.tsx | 77 +++++----- .../src/server/app-render/stream-ops.node.ts | 29 +++- .../next/src/server/app-render/stream-ops.ts | 100 ++++--------- .../src/server/app-render/stream-ops.web.ts | 134 ++++++++++++++---- 4 files changed, 194 insertions(+), 146 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index ad59e53482d1..7217306f101e 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -57,7 +57,10 @@ import { getClientPrerender, processPrelude as processPreludeOp, createDocumentClosingStream, + teeStream, + toReadableStream, } from './stream-ops' +import type { AnyStream } from './stream-ops' import { stripInternalQueries } from '../internal-utils' import { NEXT_HMR_REFRESH_HEADER, @@ -1184,7 +1187,7 @@ async function generateDynamicFlightRenderResultWithStagesInDev( } let debugChannel: DebugChannelPair | undefined - let stream: ReadableStream + let stream: AnyStream if ( // We only do this flow if we can safely recreate the store from scratch @@ -2131,7 +2134,7 @@ function ErrorApp({ // certain object shape. The generic type is not used directly in the type so it // requires a disabling of the eslint rule disallowing unused vars // eslint-disable-next-line @typescript-eslint/no-unused-vars -export type BinaryStreamOf = ReadableStream +export type BinaryStreamOf = AnyStream /** * Extracted to a separate function to prevent V8 from retaining the entire @@ -2859,7 +2862,7 @@ async function renderToStream( metadata: AppPageRenderResultMetadata, createRequestStore: (() => RequestStore) | undefined, fallbackParams: OpaqueFallbackRouteParams | null -): Promise> { +): Promise { /* eslint-disable @next/internal/no-ambiguous-jsx -- React Client */ const { assetPrefix, @@ -3697,23 +3700,16 @@ async function renderWithRestartOnCacheMissInDev( initialStageController.advanceStage(RenderStage.EarlyStatic) startTime = performance.now() + performance.timeOrigin - const streamPair = workUnitAsyncStorage - .run( - requestStore, - renderToFlightStream, - ComponentMod, - initialRscPayload, - clientModules, - { - onError, - environmentName, - startTime, - filterStackFrame, - debugChannel: debugChannel?.serverSide, - signal: initialReactController.signal, - } - ) - .tee() + const streamPair = teeStream( + renderToFlightStream(ComponentMod, initialRscPayload, clientModules, { + onError, + environmentName, + startTime, + filterStackFrame, + debugChannel: debugChannel?.serverSide, + signal: initialReactController.signal, + }) + ) // If we abort the render, we want to reject the stage-dependent promises as well. // Note that we want to install this listener after the render is started @@ -3729,7 +3725,7 @@ async function renderWithRestartOnCacheMissInDev( const stream = streamPair[0] const accumulatedChunksPromise = accumulateStreamChunks( - streamPair[1], + toReadableStream(streamPair[1]), initialStageController, initialDataController.signal ) @@ -3738,7 +3734,11 @@ async function renderWithRestartOnCacheMissInDev( 'abort', () => { accumulatedChunksPromise.catch(() => {}) - stream.cancel() + if (stream instanceof ReadableStream) { + stream.cancel() + } else { + stream.destroy() + } }, { once: true } ) @@ -3860,27 +3860,20 @@ async function renderWithRestartOnCacheMissInDev( finalStageController.advanceStage(RenderStage.EarlyStatic) startTime = performance.now() + performance.timeOrigin - const streamPair = workUnitAsyncStorage - .run( - requestStore, - renderToFlightStream, - ComponentMod, - finalRscPayload, - clientModules, - { - onError, - environmentName, - startTime, - filterStackFrame, - debugChannel: debugChannel?.serverSide, - } - ) - .tee() + const streamPair = teeStream( + renderToFlightStream(ComponentMod, finalRscPayload, clientModules, { + onError, + environmentName, + startTime, + filterStackFrame, + debugChannel: debugChannel?.serverSide, + }) + ) return { stream: streamPair[0], accumulatedChunksPromise: accumulateStreamChunks( - streamPair[1], + toReadableStream(streamPair[1]), finalStageController, null ), @@ -4148,7 +4141,7 @@ async function logMessagesAndSendErrorsToBrowser( { filterStackFrame } ) - sendErrorsToBrowser(errorsFlightStream, htmlRequestId) + sendErrorsToBrowser(toReadableStream(errorsFlightStream), htmlRequestId) } } @@ -5675,7 +5668,7 @@ async function validateInstantConfigInBuildWithSample( } type PrerenderToStreamResult = { - stream: ReadableStream + stream: AnyStream digestErrorsMap: Map ssrErrors: Array dynamicAccess?: null | Array @@ -6533,7 +6526,7 @@ async function prerenderToStream( ) } - let htmlStream: ReadableStream = prelude + let htmlStream: AnyStream = prelude if (postponed != null) { // We postponed but nothing dynamic was used. We resume the render now and immediately abort it // so we can set all the postponed boundaries to client render mode before we store the HTML response diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts index 43ef7fdb359d..12ab7db53329 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -2,7 +2,8 @@ * Node.js stream operations for the rendering pipeline. * Loaded by stream-ops.ts when process.env.__NEXT_USE_NODE_STREAMS is true. * - * AnyStream = Readable in this module. + * AnyStream = StreamLike so the exported type surface matches stream-ops.web.ts, + * allowing the switcher to assign either module without casts. * Rendering uses pipeable APIs; continue functions wrap the existing web * transforms via Readable.fromWeb() on their output. */ @@ -49,10 +50,10 @@ export type { } from './stream-ops.web' // --------------------------------------------------------------------------- -// Override AnyStream and dependent types for Node path +// AnyStream matches stream-ops.web.ts so both modules have the same type surface // --------------------------------------------------------------------------- -export type AnyStream = Readable +export type AnyStream = StreamLike export type FlightComponentMod = { renderToReadableStream: ( @@ -332,7 +333,7 @@ export function chainStreams(...streams: AnyStream[]): AnyStream { out.end() return } - const current = streams[i++] + const current = webToReadable(streams[i++]) current.pipe(out, { end: false }) current.on('end', pipeNext) current.on('error', (err) => out.destroy(err)) @@ -397,10 +398,11 @@ export function pipeRuntimePrefetchTransform( // --------------------------------------------------------------------------- export async function processPrelude(unprocessedPrelude: AnyStream) { + const readable = webToReadable(unprocessedPrelude) const pt1 = new PassThrough() const pt2 = new PassThrough() - ;(unprocessedPrelude as Readable).pipe(pt1) - ;(unprocessedPrelude as Readable).pipe(pt2) + readable.pipe(pt1) + readable.pipe(pt2) const firstChunk = await new Promise((resolve) => { pt2.once('data', (chunk: Buffer) => { @@ -421,3 +423,18 @@ export function getServerPrerender(ComponentMod: { export const getClientPrerender: typeof import('react-dom/static').prerender = prerender + +export function teeStream(stream: AnyStream): [AnyStream, AnyStream] { + const readable = webToReadable(stream) + const pt1 = new PassThrough() + const pt2 = new PassThrough() + readable.pipe(pt1) + readable.pipe(pt2) + return [pt1, pt2] +} + +export function toReadableStream( + stream: AnyStream +): ReadableStream { + return readableToWeb(stream) +} diff --git a/packages/next/src/server/app-render/stream-ops.ts b/packages/next/src/server/app-render/stream-ops.ts index 2113afb38f6a..4ac1f60484d9 100644 --- a/packages/next/src/server/app-render/stream-ops.ts +++ b/packages/next/src/server/app-render/stream-ops.ts @@ -4,10 +4,8 @@ * When __NEXT_USE_NODE_STREAMS is true, uses Node.js pipeable stream APIs. * Otherwise, uses web ReadableStream APIs. * - * Types are always sourced from stream-ops.web (the API surface is identical). - * In the Node path, AnyStream is Readable at runtime, but consumers see - * ReadableStream from the type exports — the module-level cast - * bridges this intentional mismatch in one place. + * Both modules export AnyStream = StreamLike so their type surfaces are + * structurally identical — no `as unknown as` cast is needed. */ export type { AnyStream, @@ -25,73 +23,33 @@ export type { type WebMod = typeof import('./stream-ops.web') -export let continueFizzStream: WebMod['continueFizzStream'] -export let continueStaticPrerender: WebMod['continueStaticPrerender'] -export let continueDynamicPrerender: WebMod['continueDynamicPrerender'] -export let continueStaticFallbackPrerender: WebMod['continueStaticFallbackPrerender'] -export let continueDynamicHTMLResume: WebMod['continueDynamicHTMLResume'] -export let streamToBuffer: WebMod['streamToBuffer'] -export let chainStreams: WebMod['chainStreams'] -export let createDocumentClosingStream: WebMod['createDocumentClosingStream'] -export let processPrelude: WebMod['processPrelude'] -export let createInlinedDataStream: WebMod['createInlinedDataStream'] -export let createPendingStream: WebMod['createPendingStream'] -export let createOnHeadersCallback: WebMod['createOnHeadersCallback'] -export let resumeAndAbort: WebMod['resumeAndAbort'] -export let renderToFlightStream: WebMod['renderToFlightStream'] -export let streamToString: WebMod['streamToString'] -export let renderToFizzStream: WebMod['renderToFizzStream'] -export let resumeToFizzStream: WebMod['resumeToFizzStream'] -export let getServerPrerender: WebMod['getServerPrerender'] -export let getClientPrerender: WebMod['getClientPrerender'] -export let pipeRuntimePrefetchTransform: WebMod['pipeRuntimePrefetchTransform'] - +let _m: WebMod if (process.env.__NEXT_USE_NODE_STREAMS) { - // The node module uses Readable where the web module uses ReadableStream. - // Consumers always see the web types via the type exports above, so we - // bridge to WebMod once here rather than casting per-export. - const _m: WebMod = - require('./stream-ops.node') as typeof import('./stream-ops.node') as unknown as WebMod - continueFizzStream = _m.continueFizzStream - continueStaticPrerender = _m.continueStaticPrerender - continueDynamicPrerender = _m.continueDynamicPrerender - continueStaticFallbackPrerender = _m.continueStaticFallbackPrerender - continueDynamicHTMLResume = _m.continueDynamicHTMLResume - streamToBuffer = _m.streamToBuffer - chainStreams = _m.chainStreams - createDocumentClosingStream = _m.createDocumentClosingStream - processPrelude = _m.processPrelude - createInlinedDataStream = _m.createInlinedDataStream - createPendingStream = _m.createPendingStream - createOnHeadersCallback = _m.createOnHeadersCallback - resumeAndAbort = _m.resumeAndAbort - renderToFlightStream = _m.renderToFlightStream - streamToString = _m.streamToString - renderToFizzStream = _m.renderToFizzStream - resumeToFizzStream = _m.resumeToFizzStream - getServerPrerender = _m.getServerPrerender - getClientPrerender = _m.getClientPrerender - pipeRuntimePrefetchTransform = _m.pipeRuntimePrefetchTransform + _m = require('./stream-ops.node') as typeof import('./stream-ops.node') } else { - const _m = require('./stream-ops.web') as typeof import('./stream-ops.web') - continueFizzStream = _m.continueFizzStream - continueStaticPrerender = _m.continueStaticPrerender - continueDynamicPrerender = _m.continueDynamicPrerender - continueStaticFallbackPrerender = _m.continueStaticFallbackPrerender - continueDynamicHTMLResume = _m.continueDynamicHTMLResume - streamToBuffer = _m.streamToBuffer - chainStreams = _m.chainStreams - createDocumentClosingStream = _m.createDocumentClosingStream - processPrelude = _m.processPrelude - createInlinedDataStream = _m.createInlinedDataStream - createPendingStream = _m.createPendingStream - createOnHeadersCallback = _m.createOnHeadersCallback - resumeAndAbort = _m.resumeAndAbort - renderToFlightStream = _m.renderToFlightStream - streamToString = _m.streamToString - renderToFizzStream = _m.renderToFizzStream - resumeToFizzStream = _m.resumeToFizzStream - getServerPrerender = _m.getServerPrerender - getClientPrerender = _m.getClientPrerender - pipeRuntimePrefetchTransform = _m.pipeRuntimePrefetchTransform + _m = require('./stream-ops.web') as typeof import('./stream-ops.web') } + +export const continueFizzStream = _m.continueFizzStream +export const continueStaticPrerender = _m.continueStaticPrerender +export const continueDynamicPrerender = _m.continueDynamicPrerender +export const continueStaticFallbackPrerender = + _m.continueStaticFallbackPrerender +export const continueDynamicHTMLResume = _m.continueDynamicHTMLResume +export const streamToBuffer = _m.streamToBuffer +export const chainStreams = _m.chainStreams +export const createDocumentClosingStream = _m.createDocumentClosingStream +export const processPrelude = _m.processPrelude +export const createInlinedDataStream = _m.createInlinedDataStream +export const createPendingStream = _m.createPendingStream +export const createOnHeadersCallback = _m.createOnHeadersCallback +export const resumeAndAbort = _m.resumeAndAbort +export const renderToFlightStream = _m.renderToFlightStream +export const streamToString = _m.streamToString +export const renderToFizzStream = _m.renderToFizzStream +export const resumeToFizzStream = _m.resumeToFizzStream +export const getServerPrerender = _m.getServerPrerender +export const getClientPrerender = _m.getClientPrerender +export const pipeRuntimePrefetchTransform = _m.pipeRuntimePrefetchTransform +export const teeStream = _m.teeStream +export const toReadableStream = _m.toReadableStream diff --git a/packages/next/src/server/app-render/stream-ops.web.ts b/packages/next/src/server/app-render/stream-ops.web.ts index 053a3f19e266..03242f5d9bb1 100644 --- a/packages/next/src/server/app-render/stream-ops.web.ts +++ b/packages/next/src/server/app-render/stream-ops.web.ts @@ -1,6 +1,9 @@ /** * Web stream operations for the rendering pipeline. - * Loaded by stream-ops.ts (re-export in this PR, conditional switcher later). + * Loaded by stream-ops.ts when __NEXT_USE_NODE_STREAMS is false (default). + * + * AnyStream = StreamLike so the exported type surface matches stream-ops.node.ts, + * allowing the switcher to assign either module without `as unknown as`. */ import type { PostponedState, PrerenderOptions } from 'react-dom/static' @@ -12,12 +15,20 @@ import { streamToString as webStreamToString, createRuntimePrefetchTransformStream, continueFizzStream as webContinueFizzStream, + continueStaticPrerender as webContinueStaticPrerender, + continueDynamicPrerender as webContinueDynamicPrerender, + continueStaticFallbackPrerender as webContinueStaticFallbackPrerender, + continueDynamicHTMLResume as webContinueDynamicHTMLResume, + streamToBuffer as webStreamToBuffer, + chainStreams as webChainStreams, + createDocumentClosingStream as webCreateDocumentClosingStream, } from '../stream-utils/node-web-streams-helper' import { createInlinedDataReadableStream } from './use-flight-response' +import { processPrelude as webProcessPrelude } from './app-render-prerender-utils' import type { StreamLike } from './app-render-prerender-utils' // --------------------------------------------------------------------------- -// Shared types (web-only for now; will move to stream-ops.node.ts later) +// Shared types // --------------------------------------------------------------------------- type FlightRenderToReadableStream = ( @@ -26,7 +37,7 @@ type FlightRenderToReadableStream = ( options?: any ) => ReadableStream -export type AnyStream = ReadableStream +export type AnyStream = StreamLike export type ContinueStreamSharedOptions = { deploymentId: string | undefined @@ -70,37 +81,96 @@ export type FizzStreamResult = { } // --------------------------------------------------------------------------- -// Continue functions +// Continue function wrappers +// Thin wrappers that accept AnyStream (= StreamLike) and narrow to +// ReadableStream internally for the web helper functions. // --------------------------------------------------------------------------- -export { - continueStaticPrerender, - continueDynamicPrerender, - continueStaticFallbackPrerender, - continueDynamicHTMLResume, - streamToBuffer, - chainStreams, - createDocumentClosingStream, -} from '../stream-utils/node-web-streams-helper' - -export { processPrelude } from './app-render-prerender-utils' - -/** - * Wrapper for continueFizzStream that accepts AnyStream. - * The underlying implementation expects ReactDOMServerReadableStream but at - * the stream-ops boundary we only expose AnyStream. - */ export function continueFizzStream( renderStream: AnyStream, opts: ContinueFizzStreamOptions -): Promise> { - return webContinueFizzStream(renderStream as any, opts) +): Promise { + return webContinueFizzStream( + renderStream as ReadableStream as any, + { + ...opts, + inlinedDataStream: opts.inlinedDataStream as + | ReadableStream + | undefined, + } + ) } -// Not available in web bundles -export const nodeReadableToWeb: - | ((readable: import('node:stream').Readable) => ReadableStream) - | undefined = undefined +export async function continueStaticPrerender( + prerenderStream: AnyStream, + opts: ContinueStaticPrerenderOptions +): Promise { + return webContinueStaticPrerender( + prerenderStream as ReadableStream, + { + ...opts, + inlinedDataStream: opts.inlinedDataStream as ReadableStream, + } + ) +} + +export async function continueDynamicPrerender( + prerenderStream: AnyStream, + opts: { + getServerInsertedHTML: () => Promise + getServerInsertedMetadata: () => Promise + deploymentId: string | undefined + } +): Promise { + return webContinueDynamicPrerender( + prerenderStream as ReadableStream, + opts + ) +} + +export async function continueStaticFallbackPrerender( + prerenderStream: AnyStream, + opts: ContinueStaticPrerenderOptions +): Promise { + return webContinueStaticFallbackPrerender( + prerenderStream as ReadableStream, + { + ...opts, + inlinedDataStream: opts.inlinedDataStream as ReadableStream, + } + ) +} + +export async function continueDynamicHTMLResume( + renderStream: AnyStream, + opts: ContinueDynamicHTMLResumeOptions +): Promise { + return webContinueDynamicHTMLResume( + renderStream as ReadableStream, + { + ...opts, + inlinedDataStream: opts.inlinedDataStream as ReadableStream, + } + ) +} + +export async function streamToBuffer(stream: AnyStream): Promise { + return webStreamToBuffer(stream as ReadableStream) +} + +export function chainStreams(...streams: AnyStream[]): AnyStream { + return webChainStreams(...(streams as ReadableStream[])) +} + +export function createDocumentClosingStream(): AnyStream { + return webCreateDocumentClosingStream() +} + +export async function processPrelude( + unprocessedPrelude: AnyStream +): Promise<{ prelude: AnyStream; preludeIsEmpty: boolean }> { + return webProcessPrelude(unprocessedPrelude as ReadableStream) +} // --------------------------------------------------------------------------- // Composed helpers @@ -197,3 +267,13 @@ export function pipeRuntimePrefetchTransform( createRuntimePrefetchTransformStream(sentinel, isPartial, staleTime) ) } + +export function teeStream(stream: AnyStream): [AnyStream, AnyStream] { + return (stream as ReadableStream).tee() +} + +export function toReadableStream( + stream: AnyStream +): ReadableStream { + return stream as ReadableStream +} From 74647cbc1a74e898da07efd2a16882445becc1dc Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 3 Mar 2026 13:54:11 +0100 Subject: [PATCH 03/34] Update render-result.ts --- packages/next/src/server/render-result.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/next/src/server/render-result.ts b/packages/next/src/server/render-result.ts index 56745700e717..65698ba004be 100644 --- a/packages/next/src/server/render-result.ts +++ b/packages/next/src/server/render-result.ts @@ -249,7 +249,7 @@ export default class RenderResult< if (isNodeReadable(this.response)) { const { Readable: NodeReadable } = - require('node:stream') as typeof import('node:stream') + require(/* webpackIgnore: true */ 'node:stream') as typeof import('node:stream') return NodeReadable.toWeb(this.response) as ReadableStream } @@ -277,7 +277,7 @@ export default class RenderResult< return [streamFromBuffer(this.response)] } else if (isNodeReadable(this.response)) { const { Readable: NodeReadable } = - require('node:stream') as typeof import('node:stream') + require(/* webpackIgnore: true */ 'node:stream') as typeof import('node:stream') return [NodeReadable.toWeb(this.response) as ReadableStream] } else { return [this.response] From 940102f137425c1024acf57995763c0bc4f7eed8 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 3 Mar 2026 14:48:11 +0100 Subject: [PATCH 04/34] Update render-result.ts --- packages/next/src/server/render-result.ts | 28 +++++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/next/src/server/render-result.ts b/packages/next/src/server/render-result.ts index 65698ba004be..099ae1c29c02 100644 --- a/packages/next/src/server/render-result.ts +++ b/packages/next/src/server/render-result.ts @@ -1,5 +1,5 @@ import type { OutgoingHttpHeaders, ServerResponse } from 'http' -import type { Readable } from 'node:stream' +import type { Readable } from 'stream' import type { CacheControl } from './lib/cache-control' import type { FetchMetrics } from './base-http' import type { PrefetchHints } from '../shared/lib/app-router-types' @@ -248,9 +248,16 @@ export default class RenderResult< } if (isNodeReadable(this.response)) { - const { Readable: NodeReadable } = - require(/* webpackIgnore: true */ 'node:stream') as typeof import('node:stream') - return NodeReadable.toWeb(this.response) as ReadableStream + if (process.env.TURBOPACK) { + const { Readable: NodeReadable } = + require('stream') as typeof import('stream') + return NodeReadable.toWeb(this.response) as ReadableStream + } else { + const { Readable: NodeReadable } = __non_webpack_require__( + 'stream' + ) as typeof import('stream') + return NodeReadable.toWeb(this.response) as ReadableStream + } } return this.response @@ -276,9 +283,16 @@ export default class RenderResult< } else if (Buffer.isBuffer(this.response)) { return [streamFromBuffer(this.response)] } else if (isNodeReadable(this.response)) { - const { Readable: NodeReadable } = - require(/* webpackIgnore: true */ 'node:stream') as typeof import('node:stream') - return [NodeReadable.toWeb(this.response) as ReadableStream] + if (process.env.TURBOPACK) { + const { Readable: NodeReadable } = + require('stream') as typeof import('stream') + return [NodeReadable.toWeb(this.response) as ReadableStream] + } else { + const { Readable: NodeReadable } = __non_webpack_require__( + 'stream' + ) as typeof import('stream') + return [NodeReadable.toWeb(this.response) as ReadableStream] + } } else { return [this.response] } From 5bd027f82c30048274c8745f88317f760fe89432 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 3 Mar 2026 14:56:05 +0100 Subject: [PATCH 05/34] Fix readable bug --- .../server/app-render/use-flight-response.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/next/src/server/app-render/use-flight-response.tsx b/packages/next/src/server/app-render/use-flight-response.tsx index 5f148e1a0a43..0d19c95040fd 100644 --- a/packages/next/src/server/app-render/use-flight-response.tsx +++ b/packages/next/src/server/app-render/use-flight-response.tsx @@ -76,9 +76,17 @@ export function getFlightStream( const { Readable } = require('node:stream') as typeof import('node:stream') - // The types of flightStream and debugStream should match. - if (debugStream && !(debugStream instanceof Readable)) { - throw new InvariantError('Expected debug stream to be a Readable') + // Convert debug stream to Readable if it's a ReadableStream. + // This can happen when the flight stream is a Node Readable (node streams path) + // but the debug channel always produces web ReadableStreams. + let nodeDebugStream: Readable | undefined + if (debugStream) { + if (debugStream instanceof Readable) { + nodeDebugStream = debugStream + } else { + type WebReadableStream = import('stream/web').ReadableStream + nodeDebugStream = Readable.fromWeb(debugStream as WebReadableStream) + } } // react-server-dom-webpack/client.edge must not be hoisted for require cache clearing to work correctly @@ -96,7 +104,7 @@ export function getFlightStream( { findSourceMapURL, nonce, - debugChannel: debugStream, + debugChannel: nodeDebugStream, endTime: debugEndTime, } ) From f1f23ef5eb77f57443a896731b409d45f5e52e5e Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 3 Mar 2026 15:12:48 +0100 Subject: [PATCH 06/34] Update app-render-prerender-utils.ts --- .../app-render/app-render-prerender-utils.ts | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/next/src/server/app-render/app-render-prerender-utils.ts b/packages/next/src/server/app-render/app-render-prerender-utils.ts index 192fc32cc4de..23f922d607e3 100644 --- a/packages/next/src/server/app-render/app-render-prerender-utils.ts +++ b/packages/next/src/server/app-render/app-render-prerender-utils.ts @@ -29,15 +29,29 @@ export class ReactServerResult { this._stream = tee[0] return tee[1] } - // Node.js Readable: pipe to two PassThrough streams - const { PassThrough } = - require('node:stream') as typeof import('node:stream') - const pt1 = new PassThrough() - const pt2 = new PassThrough() - this._stream.pipe(pt1) - this._stream.pipe(pt2) - this._stream = pt1 - return pt2 + + if (process.env.TURBOPACK) { + // Node.js Readable: pipe to two PassThrough streams + const { PassThrough } = + require('node:stream') as typeof import('node:stream') + const pt1 = new PassThrough() + const pt2 = new PassThrough() + this._stream.pipe(pt1) + this._stream.pipe(pt2) + this._stream = pt1 + return pt2 + } else { + // Node.js Readable: pipe to two PassThrough streams + const { PassThrough } = __non_webpack_require__( + 'node:stream' + ) as typeof import('node:stream') + const pt1 = new PassThrough() + const pt2 = new PassThrough() + this._stream.pipe(pt1) + this._stream.pipe(pt2) + this._stream = pt1 + return pt2 + } } consume(): StreamLike { From f7a1b786c680b0391b3ab90b318270d6d9935657 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 3 Mar 2026 15:48:13 +0100 Subject: [PATCH 07/34] Fix bugs --- .../next/src/server/app-render/app-render.tsx | 44 ++++++++++++------- .../src/server/app-render/stream-ops.node.ts | 12 +++-- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 7217306f101e..d3e9258fd3f7 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -3701,14 +3701,21 @@ async function renderWithRestartOnCacheMissInDev( startTime = performance.now() + performance.timeOrigin const streamPair = teeStream( - renderToFlightStream(ComponentMod, initialRscPayload, clientModules, { - onError, - environmentName, - startTime, - filterStackFrame, - debugChannel: debugChannel?.serverSide, - signal: initialReactController.signal, - }) + workUnitAsyncStorage.run( + requestStore, + renderToFlightStream, + ComponentMod, + initialRscPayload, + clientModules, + { + onError, + environmentName, + startTime, + filterStackFrame, + debugChannel: debugChannel?.serverSide, + signal: initialReactController.signal, + } + ) ) // If we abort the render, we want to reject the stage-dependent promises as well. @@ -3861,13 +3868,20 @@ async function renderWithRestartOnCacheMissInDev( startTime = performance.now() + performance.timeOrigin const streamPair = teeStream( - renderToFlightStream(ComponentMod, finalRscPayload, clientModules, { - onError, - environmentName, - startTime, - filterStackFrame, - debugChannel: debugChannel?.serverSide, - }) + workUnitAsyncStorage.run( + requestStore, + renderToFlightStream, + ComponentMod, + finalRscPayload, + clientModules, + { + onError, + environmentName, + startTime, + filterStackFrame, + debugChannel: debugChannel?.serverSide, + } + ) ) return { diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts index 12ab7db53329..2b85e3f0fb01 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -246,10 +246,14 @@ export async function continueFizzStream( ? readableToWeb(opts.inlinedDataStream) : undefined, } - const webResult = await webContinueFizzStream( - readableToWeb(renderStream) as ReactDOMServerReadableStream, - webOpts - ) + // The web continueFizzStream reads renderStream.allReady from the stream + // object itself (ReactDOMServerReadableStream). A plain ReadableStream from + // readableToWeb() won't have that property, so we attach it from opts. + const webStream = readableToWeb(renderStream) + const fizzLike = Object.assign(webStream, { + allReady: opts.allReady ?? Promise.resolve(), + }) as ReactDOMServerReadableStream + const webResult = await webContinueFizzStream(fizzLike, webOpts) return webToReadable(webResult) } From 5d89a3297a0e90c73a9d2d69c8c19f84d69e4179 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 3 Mar 2026 18:37:29 +0100 Subject: [PATCH 08/34] Try fix --- .../app-render/app-render-prerender-utils.ts | 34 ++++++++++--------- .../src/server/app-render/stream-ops.node.ts | 31 ++++++----------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/packages/next/src/server/app-render/app-render-prerender-utils.ts b/packages/next/src/server/app-render/app-render-prerender-utils.ts index 23f922d607e3..c542e2b8e81b 100644 --- a/packages/next/src/server/app-render/app-render-prerender-utils.ts +++ b/packages/next/src/server/app-render/app-render-prerender-utils.ts @@ -31,26 +31,28 @@ export class ReactServerResult { } if (process.env.TURBOPACK) { - // Node.js Readable: pipe to two PassThrough streams - const { PassThrough } = + const { Readable } = require('node:stream') as typeof import('node:stream') - const pt1 = new PassThrough() - const pt2 = new PassThrough() - this._stream.pipe(pt1) - this._stream.pipe(pt2) - this._stream = pt1 - return pt2 + const webStream = Readable.toWeb( + this._stream + ) as ReadableStream + const tee = webStream.tee() + this._stream = Readable.fromWeb( + tee[0] as import('stream/web').ReadableStream + ) + return Readable.fromWeb(tee[1] as import('stream/web').ReadableStream) } else { - // Node.js Readable: pipe to two PassThrough streams - const { PassThrough } = __non_webpack_require__( + const { Readable } = __non_webpack_require__( 'node:stream' ) as typeof import('node:stream') - const pt1 = new PassThrough() - const pt2 = new PassThrough() - this._stream.pipe(pt1) - this._stream.pipe(pt2) - this._stream = pt1 - return pt2 + const webStream = Readable.toWeb( + this._stream + ) as ReadableStream + const tee = webStream.tee() + this._stream = Readable.fromWeb( + tee[0] as import('stream/web').ReadableStream + ) + return Readable.fromWeb(tee[1] as import('stream/web').ReadableStream) } } diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts index 2b85e3f0fb01..e9e385120cec 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -402,21 +402,16 @@ export function pipeRuntimePrefetchTransform( // --------------------------------------------------------------------------- export async function processPrelude(unprocessedPrelude: AnyStream) { - const readable = webToReadable(unprocessedPrelude) - const pt1 = new PassThrough() - const pt2 = new PassThrough() - readable.pipe(pt1) - readable.pipe(pt2) - - const firstChunk = await new Promise((resolve) => { - pt2.once('data', (chunk: Buffer) => { - pt2.destroy() - resolve(chunk) - }) - pt2.once('end', () => resolve(null)) - }) + const [prelude, peek] = readableToWeb(unprocessedPrelude).tee() + + const reader = peek.getReader() + const firstResult = await reader.read() + reader.cancel() - return { prelude: pt1 as AnyStream, preludeIsEmpty: firstChunk === null } + return { + prelude: webToReadable(prelude) as AnyStream, + preludeIsEmpty: firstResult.done === true, + } } export function getServerPrerender(ComponentMod: { @@ -429,12 +424,8 @@ export const getClientPrerender: typeof import('react-dom/static').prerender = prerender export function teeStream(stream: AnyStream): [AnyStream, AnyStream] { - const readable = webToReadable(stream) - const pt1 = new PassThrough() - const pt2 = new PassThrough() - readable.pipe(pt1) - readable.pipe(pt2) - return [pt1, pt2] + const [s1, s2] = readableToWeb(stream).tee() + return [webToReadable(s1), webToReadable(s2)] } export function toReadableStream( From ab2def8d43220772ae2f8f6742a9676de26e2c75 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 3 Mar 2026 19:45:07 +0100 Subject: [PATCH 09/34] Bugfixes --- packages/next/src/server/app-render/app-render.tsx | 4 +++- packages/next/src/server/app-render/stream-ops.node.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index d3e9258fd3f7..5b45cdbad888 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -6545,7 +6545,9 @@ async function prerenderToStream( // We postponed but nothing dynamic was used. We resume the render now and immediately abort it // so we can set all the postponed boundaries to client render mode before we store the HTML response const foreverStream = createPendingStream() - const resumePrelude = await resumeAndAbort( + const resumePrelude = await workUnitAsyncStorage.run( + finalServerPrerenderStore, + resumeAndAbort, // eslint-disable-next-line @next/internal/no-ambiguous-jsx Date: Wed, 4 Mar 2026 14:48:07 +0100 Subject: [PATCH 10/34] Add debug channel --- .../app-render/debug-channel-server.node.ts | 73 +++++++++++++++++++ .../server/app-render/debug-channel-server.ts | 23 ++++-- .../src/server/app-render/stream-ops.node.ts | 27 ++++++- 3 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 packages/next/src/server/app-render/debug-channel-server.node.ts diff --git a/packages/next/src/server/app-render/debug-channel-server.node.ts b/packages/next/src/server/app-render/debug-channel-server.node.ts new file mode 100644 index 000000000000..205966f86206 --- /dev/null +++ b/packages/next/src/server/app-render/debug-channel-server.node.ts @@ -0,0 +1,73 @@ +/** + * Node debug channel implementation. + * Loaded by debug-channel-server.ts when __NEXT_USE_NODE_STREAMS is enabled. + */ + +import type { Writable as NodeWritable } from 'node:stream' +import { PassThrough, Readable } from 'node:stream' + +import type { DebugChannelServer } from './debug-channel-server.web' + +type WebWritableStream = import('stream/web').WritableStream + +type NodeDebugChannelServer = { + readable?: ReadableStream + writable: NodeWritable +} + +type NodeDebugChannelPair = { + serverSide: NodeDebugChannelServer + clientSide: { + readable: ReadableStream + writable?: WritableStream + } +} + +function isNodeWritable(value: unknown): value is NodeWritable { + return ( + value !== null && + typeof value === 'object' && + typeof (value as { write?: unknown }).write === 'function' && + typeof (value as { on?: unknown }).on === 'function' + ) +} + +export function createDebugChannel(): + | import('./debug-channel-server.web').DebugChannelPair + | undefined { + if (process.env.NODE_ENV === 'production') { + return undefined + } + return createNodeDebugChannel() as unknown as import('./debug-channel-server.web').DebugChannelPair +} + +export function createNodeDebugChannel(): NodeDebugChannelPair { + const duplex = new PassThrough() + const clientReadable = Readable.toWeb(duplex) as ReadableStream + + return { + serverSide: { + writable: duplex, + }, + clientSide: { readable: clientReadable }, + } +} + +export function toNodeDebugChannel( + debugChannel: DebugChannelServer | NodeDebugChannelServer +): NodeWritable { + const { writable } = debugChannel + if (isNodeWritable(writable)) { + return writable + } + + if (process.env.TURBOPACK) { + const { Writable } = require('node:stream') as typeof import('node:stream') + return Writable.fromWeb(writable as WebWritableStream) + } else { + const { Writable } = __non_webpack_require__( + 'node:stream' + ) as typeof import('node:stream') + return Writable.fromWeb(writable as WebWritableStream) + } +} diff --git a/packages/next/src/server/app-render/debug-channel-server.ts b/packages/next/src/server/app-render/debug-channel-server.ts index 3d122179a36b..73afa445f683 100644 --- a/packages/next/src/server/app-render/debug-channel-server.ts +++ b/packages/next/src/server/app-render/debug-channel-server.ts @@ -1,15 +1,26 @@ /** * Compile-time switcher for debug channel operations. * - * Simple re-export from the web implementation. - * A future change will add a conditional branch for node streams. + * When __NEXT_USE_NODE_STREAMS is true, uses a Node Writable-based channel. + * Otherwise, uses web WritableStream APIs. */ export type { DebugChannelPair, DebugChannelServer, } from './debug-channel-server.web' -export { - createDebugChannel, - toNodeDebugChannel, -} from './debug-channel-server.web' +type DebugChannelMod = { + createDebugChannel: typeof import('./debug-channel-server.web').createDebugChannel + toNodeDebugChannel: (...args: any[]) => import('node:stream').Writable +} + +let _m: DebugChannelMod +if (process.env.__NEXT_USE_NODE_STREAMS) { + _m = + require('./debug-channel-server.node') as typeof import('./debug-channel-server.node') +} else { + _m = (require('./debug-channel-server.web') as typeof import('./debug-channel-server.web')) as DebugChannelMod +} + +export const createDebugChannel = _m.createDebugChannel +export const toNodeDebugChannel = _m.toNodeDebugChannel diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts index b4ed039fa837..0ea8f30587e3 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -30,6 +30,10 @@ import { } from '../stream-utils/node-web-streams-helper' import { createInlinedDataReadableStream } from './use-flight-response' import type { StreamLike } from './app-render-prerender-utils' +import { + toNodeDebugChannel, + type DebugChannelServer, +} from './debug-channel-server' import { DetachedPromise } from '../../lib/detached-promise' import { getTracer } from '../lib/trace/tracer' import { AppRenderSpan } from '../lib/trace/constants' @@ -105,6 +109,26 @@ function webToReadable( return Readable.fromWeb(stream as WebReadableStream) } +function normalizeNodeDebugChannel(options: any): any { + if (!options?.debugChannel) { + return options + } + + const debugChannel = options.debugChannel as unknown + if ( + typeof debugChannel === 'object' && + debugChannel !== null && + 'writable' in debugChannel + ) { + return { + ...options, + debugChannel: toNodeDebugChannel(debugChannel as DebugChannelServer), + } + } + + return options +} + // --------------------------------------------------------------------------- // Rendering functions (output Node Readable natively via PassThrough) // --------------------------------------------------------------------------- @@ -120,8 +144,9 @@ export function renderToFlightStream( if (ComponentMod.renderToPipeableStream) { const pt = new PassThrough() + const nodeOptions = normalizeNodeDebugChannel(opts) const pipeable = run(() => - ComponentMod.renderToPipeableStream!(payload, clientModules, opts) + ComponentMod.renderToPipeableStream!(payload, clientModules, nodeOptions) ) pipeable.pipe(pt) return pt From ba8c944bd0415a175d25383c15da496aaf0c366a Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 4 Mar 2026 15:20:41 +0100 Subject: [PATCH 11/34] Update --- .../app-render/debug-channel-server.node.ts | 35 +------------------ .../server/app-render/debug-channel-server.ts | 5 ++- .../app-render/debug-channel-server.web.ts | 12 ------- .../src/server/app-render/stream-ops.node.ts | 27 +------------- 4 files changed, 4 insertions(+), 75 deletions(-) diff --git a/packages/next/src/server/app-render/debug-channel-server.node.ts b/packages/next/src/server/app-render/debug-channel-server.node.ts index 205966f86206..f002405a48a9 100644 --- a/packages/next/src/server/app-render/debug-channel-server.node.ts +++ b/packages/next/src/server/app-render/debug-channel-server.node.ts @@ -3,16 +3,11 @@ * Loaded by debug-channel-server.ts when __NEXT_USE_NODE_STREAMS is enabled. */ -import type { Writable as NodeWritable } from 'node:stream' import { PassThrough, Readable } from 'node:stream' -import type { DebugChannelServer } from './debug-channel-server.web' - -type WebWritableStream = import('stream/web').WritableStream - type NodeDebugChannelServer = { readable?: ReadableStream - writable: NodeWritable + writable: import('node:stream').Writable } type NodeDebugChannelPair = { @@ -23,15 +18,6 @@ type NodeDebugChannelPair = { } } -function isNodeWritable(value: unknown): value is NodeWritable { - return ( - value !== null && - typeof value === 'object' && - typeof (value as { write?: unknown }).write === 'function' && - typeof (value as { on?: unknown }).on === 'function' - ) -} - export function createDebugChannel(): | import('./debug-channel-server.web').DebugChannelPair | undefined { @@ -52,22 +38,3 @@ export function createNodeDebugChannel(): NodeDebugChannelPair { clientSide: { readable: clientReadable }, } } - -export function toNodeDebugChannel( - debugChannel: DebugChannelServer | NodeDebugChannelServer -): NodeWritable { - const { writable } = debugChannel - if (isNodeWritable(writable)) { - return writable - } - - if (process.env.TURBOPACK) { - const { Writable } = require('node:stream') as typeof import('node:stream') - return Writable.fromWeb(writable as WebWritableStream) - } else { - const { Writable } = __non_webpack_require__( - 'node:stream' - ) as typeof import('node:stream') - return Writable.fromWeb(writable as WebWritableStream) - } -} diff --git a/packages/next/src/server/app-render/debug-channel-server.ts b/packages/next/src/server/app-render/debug-channel-server.ts index 73afa445f683..533ad6e34380 100644 --- a/packages/next/src/server/app-render/debug-channel-server.ts +++ b/packages/next/src/server/app-render/debug-channel-server.ts @@ -11,7 +11,6 @@ export type { type DebugChannelMod = { createDebugChannel: typeof import('./debug-channel-server.web').createDebugChannel - toNodeDebugChannel: (...args: any[]) => import('node:stream').Writable } let _m: DebugChannelMod @@ -19,8 +18,8 @@ if (process.env.__NEXT_USE_NODE_STREAMS) { _m = require('./debug-channel-server.node') as typeof import('./debug-channel-server.node') } else { - _m = (require('./debug-channel-server.web') as typeof import('./debug-channel-server.web')) as DebugChannelMod + _m = + require('./debug-channel-server.web') as typeof import('./debug-channel-server.web') as DebugChannelMod } export const createDebugChannel = _m.createDebugChannel -export const toNodeDebugChannel = _m.toNodeDebugChannel diff --git a/packages/next/src/server/app-render/debug-channel-server.web.ts b/packages/next/src/server/app-render/debug-channel-server.web.ts index 7e2ede83d003..58d22ec6377d 100644 --- a/packages/next/src/server/app-render/debug-channel-server.web.ts +++ b/packages/next/src/server/app-render/debug-channel-server.web.ts @@ -52,15 +52,3 @@ export function createWebDebugChannel(): DebugChannelPair { clientSide: { readable: clientSideReadable }, } } - -/** - * toNodeDebugChannel is a no-op stub on the web path. - * It should never be called in edge/web builds. - */ -export function toNodeDebugChannel( - _webDebugChannel: DebugChannelServer -): never { - throw new Error( - 'toNodeDebugChannel cannot be used in edge/web runtime, this is a bug in the Next.js codebase' - ) -} diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts index 0ea8f30587e3..b4ed039fa837 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -30,10 +30,6 @@ import { } from '../stream-utils/node-web-streams-helper' import { createInlinedDataReadableStream } from './use-flight-response' import type { StreamLike } from './app-render-prerender-utils' -import { - toNodeDebugChannel, - type DebugChannelServer, -} from './debug-channel-server' import { DetachedPromise } from '../../lib/detached-promise' import { getTracer } from '../lib/trace/tracer' import { AppRenderSpan } from '../lib/trace/constants' @@ -109,26 +105,6 @@ function webToReadable( return Readable.fromWeb(stream as WebReadableStream) } -function normalizeNodeDebugChannel(options: any): any { - if (!options?.debugChannel) { - return options - } - - const debugChannel = options.debugChannel as unknown - if ( - typeof debugChannel === 'object' && - debugChannel !== null && - 'writable' in debugChannel - ) { - return { - ...options, - debugChannel: toNodeDebugChannel(debugChannel as DebugChannelServer), - } - } - - return options -} - // --------------------------------------------------------------------------- // Rendering functions (output Node Readable natively via PassThrough) // --------------------------------------------------------------------------- @@ -144,9 +120,8 @@ export function renderToFlightStream( if (ComponentMod.renderToPipeableStream) { const pt = new PassThrough() - const nodeOptions = normalizeNodeDebugChannel(opts) const pipeable = run(() => - ComponentMod.renderToPipeableStream!(payload, clientModules, nodeOptions) + ComponentMod.renderToPipeableStream!(payload, clientModules, opts) ) pipeable.pipe(pt) return pt From b21cb4c8ce2482fb617445c8ed58caacd00cfc72 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 4 Mar 2026 15:52:02 +0100 Subject: [PATCH 12/34] Update debug-channel-server.node.ts --- .../src/server/app-render/debug-channel-server.node.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/next/src/server/app-render/debug-channel-server.node.ts b/packages/next/src/server/app-render/debug-channel-server.node.ts index f002405a48a9..32cb2d230a46 100644 --- a/packages/next/src/server/app-render/debug-channel-server.node.ts +++ b/packages/next/src/server/app-render/debug-channel-server.node.ts @@ -3,11 +3,11 @@ * Loaded by debug-channel-server.ts when __NEXT_USE_NODE_STREAMS is enabled. */ -import { PassThrough, Readable } from 'node:stream' +import { PassThrough, Readable, Writable } from 'node:stream' type NodeDebugChannelServer = { readable?: ReadableStream - writable: import('node:stream').Writable + writable: WritableStream } type NodeDebugChannelPair = { @@ -30,10 +30,11 @@ export function createDebugChannel(): export function createNodeDebugChannel(): NodeDebugChannelPair { const duplex = new PassThrough() const clientReadable = Readable.toWeb(duplex) as ReadableStream + const serverWritable = Writable.toWeb(duplex) as WritableStream return { serverSide: { - writable: duplex, + writable: serverWritable, }, clientSide: { readable: clientReadable }, } From 581ab9e19571546821e097ab40a72b639d9d152a Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 4 Mar 2026 17:00:17 +0100 Subject: [PATCH 13/34] Make errorsRscStream take anystream --- packages/next/src/server/app-render/app-render.tsx | 2 +- .../next/src/server/app-render/stream-ops.node.ts | 10 ++++++++++ packages/next/src/server/app-render/stream-ops.ts | 1 + .../next/src/server/app-render/stream-ops.web.ts | 7 +++++++ packages/next/src/server/app-render/types.ts | 3 ++- packages/next/src/server/dev/hot-reloader-types.ts | 6 ++---- packages/next/src/server/dev/hot-reloader-webpack.ts | 3 ++- packages/next/src/server/dev/serialized-errors.ts | 12 +++++------- .../server/lib/router-utils/router-server-context.ts | 3 ++- 9 files changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 5b45cdbad888..2a2805465608 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -4155,7 +4155,7 @@ async function logMessagesAndSendErrorsToBrowser( { filterStackFrame } ) - sendErrorsToBrowser(toReadableStream(errorsFlightStream), htmlRequestId) + sendErrorsToBrowser(errorsFlightStream, htmlRequestId) } } diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts index b4ed039fa837..c0e1dd745e25 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -351,6 +351,16 @@ export async function streamToBuffer(stream: AnyStream): Promise { return webStreamToBuffer(readableToWeb(stream)) } +export async function streamToUint8Array( + stream: AnyStream +): Promise { + const chunks: Buffer[] = [] + for await (const chunk of webToReadable(stream)) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) + } + return Buffer.concat(chunks) +} + export async function streamToString(stream: AnyStream): Promise { return webStreamToString(readableToWeb(stream)) } diff --git a/packages/next/src/server/app-render/stream-ops.ts b/packages/next/src/server/app-render/stream-ops.ts index 4ac1f60484d9..a534852e5271 100644 --- a/packages/next/src/server/app-render/stream-ops.ts +++ b/packages/next/src/server/app-render/stream-ops.ts @@ -46,6 +46,7 @@ export const createOnHeadersCallback = _m.createOnHeadersCallback export const resumeAndAbort = _m.resumeAndAbort export const renderToFlightStream = _m.renderToFlightStream export const streamToString = _m.streamToString +export const streamToUint8Array = _m.streamToUint8Array export const renderToFizzStream = _m.renderToFizzStream export const resumeToFizzStream = _m.resumeToFizzStream export const getServerPrerender = _m.getServerPrerender diff --git a/packages/next/src/server/app-render/stream-ops.web.ts b/packages/next/src/server/app-render/stream-ops.web.ts index 03242f5d9bb1..b22c830043ef 100644 --- a/packages/next/src/server/app-render/stream-ops.web.ts +++ b/packages/next/src/server/app-render/stream-ops.web.ts @@ -20,6 +20,7 @@ import { continueStaticFallbackPrerender as webContinueStaticFallbackPrerender, continueDynamicHTMLResume as webContinueDynamicHTMLResume, streamToBuffer as webStreamToBuffer, + streamToUint8Array as webStreamToUint8Array, chainStreams as webChainStreams, createDocumentClosingStream as webCreateDocumentClosingStream, } from '../stream-utils/node-web-streams-helper' @@ -158,6 +159,12 @@ export async function streamToBuffer(stream: AnyStream): Promise { return webStreamToBuffer(stream as ReadableStream) } +export async function streamToUint8Array( + stream: AnyStream +): Promise { + return webStreamToUint8Array(stream as ReadableStream) +} + export function chainStreams(...streams: AnyStream[]): AnyStream { return webChainStreams(...(streams as ReadableStream[])) } diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 7781b908e3c1..af4ac9cf248f 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -21,6 +21,7 @@ import type { IncomingMessage } from 'http' import type { RenderResumeDataCache } from '../resume-data-cache/resume-data-cache' import type { ServerCacheStatus } from '../../next-devtools/dev-overlay/cache-indicator' import type { PrefetchHints } from '../../shared/lib/app-router-types' +import type { AnyStream } from './stream-ops' const dynamicParamTypesSchema = s.enums([ 'c', @@ -120,7 +121,7 @@ export interface RenderOptsPartial { requestId: string ) => void sendErrorsToBrowser?: ( - errorsRscStream: ReadableStream, + errorsRscStream: AnyStream, htmlRequestId: string ) => void isBuildTimePrerendering?: boolean diff --git a/packages/next/src/server/dev/hot-reloader-types.ts b/packages/next/src/server/dev/hot-reloader-types.ts index 20250260079e..c620de4a707b 100644 --- a/packages/next/src/server/dev/hot-reloader-types.ts +++ b/packages/next/src/server/dev/hot-reloader-types.ts @@ -14,6 +14,7 @@ import type { } from '../../next-devtools/dev-overlay/cache-indicator' import type { DevToolsConfig } from '../../next-devtools/dev-overlay/shared' import type { ReactDebugChannelForBrowser } from './debug-channel' +import type { AnyStream } from '../app-render/stream-ops' export const enum HMR_MESSAGE_SENT_TO_BROWSER { // JSON messages: @@ -242,10 +243,7 @@ export interface NextJsHotReloaderInterface { htmlRequestId: string, requestId: string ): void - sendErrorsToBrowser( - errorsRscStream: ReadableStream, - htmlRequestId: string - ): void + sendErrorsToBrowser(errorsRscStream: AnyStream, htmlRequestId: string): void getCompilationErrors(page: string): Promise onHMR( req: IncomingMessage, diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index e67450ecca4c..d4f7a5f0e2af 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -5,6 +5,7 @@ import type { Telemetry } from '../../telemetry/storage' import type { IncomingMessage, ServerResponse } from 'http' import type { UrlObject } from 'url' import type { RouteDefinition } from '../route-definitions/route-definition' +import type { AnyStream } from '../app-render/stream-ops' import { type webpack, StringXor } from 'next/dist/compiled/webpack/webpack' import { @@ -1820,7 +1821,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { } public sendErrorsToBrowser( - errorsRscStream: ReadableStream, + errorsRscStream: AnyStream, htmlRequestId: string ): void { const client = this.webpackHotMiddleware?.getClient(htmlRequestId) diff --git a/packages/next/src/server/dev/serialized-errors.ts b/packages/next/src/server/dev/serialized-errors.ts index 41774a52ced6..64f8c32b66a6 100644 --- a/packages/next/src/server/dev/serialized-errors.ts +++ b/packages/next/src/server/dev/serialized-errors.ts @@ -1,16 +1,14 @@ -import { streamToUint8Array } from '../stream-utils/node-web-streams-helper' import { HMR_MESSAGE_SENT_TO_BROWSER, type HmrMessageSentToBrowser, } from './hot-reloader-types' +import type { AnyStream } from '../app-render/stream-ops' +import { streamToUint8Array } from '../app-render/stream-ops' -const errorsRscStreamsByHtmlRequestId = new Map< - string, - ReadableStream ->() +const errorsRscStreamsByHtmlRequestId = new Map() export function sendSerializedErrorsToClient( - errorsRscStream: ReadableStream, + errorsRscStream: AnyStream, sendToClient: (message: HmrMessageSentToBrowser) => void ) { streamToUint8Array(errorsRscStream).then( @@ -43,7 +41,7 @@ export function sendSerializedErrorsToClientForHtmlRequest( export function setErrorsRscStreamForHtmlRequest( htmlRequestId: string, - errorsRscStream: ReadableStream + errorsRscStream: AnyStream ) { // TODO: Clean up after a timeout, in case the client never connects, e.g. // when CURL'ing the page, or loading the page with JavaScript disabled etc. diff --git a/packages/next/src/server/lib/router-utils/router-server-context.ts b/packages/next/src/server/lib/router-utils/router-server-context.ts index 6b4af535f7ea..cdd546328534 100644 --- a/packages/next/src/server/lib/router-utils/router-server-context.ts +++ b/packages/next/src/server/lib/router-utils/router-server-context.ts @@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import type { NextConfigRuntime } from '../../config-shared' import type { UrlWithParsedQuery } from 'node:url' import type { ServerCacheStatus } from '../../../next-devtools/dev-overlay/cache-indicator' +import type { AnyStream } from '../../app-render/stream-ops' export type RevalidateFn = (config: { urlPath: string @@ -46,7 +47,7 @@ export type RouterServerContext = Record< ) => void setCacheStatus?: (status: ServerCacheStatus, htmlRequestId: string) => void sendErrorsToBrowser?: ( - errorsRscStream: ReadableStream, + errorsRscStream: AnyStream, htmlRequestId: string ) => void // indicates request handlers are already wrapped by next-server tracing From b2ba285c970931e1b30e935f0f1e8716c37b1ad4 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 4 Mar 2026 17:20:05 +0100 Subject: [PATCH 14/34] Update --- .../next/src/server/app-render/app-render.tsx | 142 ++++++++++++------ .../src/server/app-render/stream-ops.node.ts | 2 +- .../next/src/server/app-render/stream-ops.ts | 2 +- .../src/server/app-render/stream-ops.web.ts | 2 +- 4 files changed, 96 insertions(+), 52 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 2a2805465608..a6b2b43658b6 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -58,7 +58,6 @@ import { processPrelude as processPreludeOp, createDocumentClosingStream, teeStream, - toReadableStream, } from './stream-ops' import type { AnyStream } from './stream-ops' import { stripInternalQueries } from '../internal-utils' @@ -3732,7 +3731,7 @@ async function renderWithRestartOnCacheMissInDev( const stream = streamPair[0] const accumulatedChunksPromise = accumulateStreamChunks( - toReadableStream(streamPair[1]), + streamPair[1], initialStageController, initialDataController.signal ) @@ -3887,7 +3886,7 @@ async function renderWithRestartOnCacheMissInDev( return { stream: streamPair[0], accumulatedChunksPromise: accumulateStreamChunks( - toReadableStream(streamPair[1]), + streamPair[1], finalStageController, null ), @@ -3934,71 +3933,116 @@ interface AccumulatedStreamChunks { } async function accumulateStreamChunks( - stream: ReadableStream, + stream: AnyStream, stageController: StagedRenderingController, signal: AbortSignal | null ): Promise { const staticChunks: Array = [] const runtimeChunks: Array = [] const dynamicChunks: Array = [] - const reader = stream.getReader() - let cancelled = false - function cancel() { - if (!cancelled) { - cancelled = true - reader.cancel() + if (stream instanceof ReadableStream) { + const reader = stream.getReader() + + let cancelled = false + function cancel() { + if (!cancelled) { + cancelled = true + reader.cancel() + } } - } - if (signal) { - signal.addEventListener('abort', cancel, { once: true }) - } + if (signal) { + signal.addEventListener('abort', cancel, { once: true }) + } - try { - while (!cancelled) { - const { done, value } = await reader.read() - if (done) { - cancel() - break - } - switch (stageController.currentStage) { - case RenderStage.Before: - throw new InvariantError( - 'Unexpected stream chunk while in Before stage' - ) - case RenderStage.EarlyStatic: - case RenderStage.Static: - staticChunks.push(value) - // fall through - case RenderStage.EarlyRuntime: - case RenderStage.Runtime: - runtimeChunks.push(value) - // fall through - case RenderStage.Dynamic: - dynamicChunks.push(value) - break - case RenderStage.Abandoned: - // If the render was abandoned, we won't use the chunks, - // so there's no need to accumulate them - break - default: - stageController.currentStage satisfies never + try { + while (!cancelled) { + const { done, value } = await reader.read() + if (done) { + cancel() break + } + accumulateChunk( + stageController, + staticChunks, + runtimeChunks, + dynamicChunks, + value + ) + } + } catch (err) { + // When we cancel the reader we may reject the read. + // Only swallow errors caused by our intentional cancel(); + // re-throw unexpected errors to avoid silently returning partial data. + if (!cancelled) { + throw err } } - } catch (err) { - // When we cancel the reader we may reject the read. - // Only swallow errors caused by our intentional cancel(); - // re-throw unexpected errors to avoid silently returning partial data. - if (!cancelled) { - throw err + } else { + const nodeStream = stream as Readable + let cancelled = false + function cancel() { + if (!cancelled) { + cancelled = true + nodeStream.destroy() + } + } + + if (signal) { + signal.addEventListener('abort', cancel, { once: true }) + } + + try { + for await (const value of nodeStream) { + if (cancelled) break + accumulateChunk( + stageController, + staticChunks, + runtimeChunks, + dynamicChunks, + value + ) + } + } catch (err) { + if (!cancelled) { + throw err + } } } return { staticChunks, runtimeChunks, dynamicChunks } } +function accumulateChunk( + stageController: StagedRenderingController, + staticChunks: Array, + runtimeChunks: Array, + dynamicChunks: Array, + value: Uint8Array +): void { + switch (stageController.currentStage) { + case RenderStage.Before: + throw new InvariantError('Unexpected stream chunk while in Before stage') + case RenderStage.EarlyStatic: + case RenderStage.Static: + staticChunks.push(value) + // fall through + case RenderStage.EarlyRuntime: + case RenderStage.Runtime: + runtimeChunks.push(value) + // fall through + case RenderStage.Dynamic: + dynamicChunks.push(value) + break + case RenderStage.Abandoned: + break + default: + stageController.currentStage satisfies never + break + } +} + async function countStaticStageBytes( stream: ReadableStream, stageController: StagedRenderingController diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts index c0e1dd745e25..77ff5da9faf9 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -438,7 +438,7 @@ export function teeStream(stream: AnyStream): [AnyStream, AnyStream] { return [webToReadable(s1), webToReadable(s2)] } -export function toReadableStream( +export function toWebReadableStream( stream: AnyStream ): ReadableStream { return readableToWeb(stream) diff --git a/packages/next/src/server/app-render/stream-ops.ts b/packages/next/src/server/app-render/stream-ops.ts index a534852e5271..015cb1966793 100644 --- a/packages/next/src/server/app-render/stream-ops.ts +++ b/packages/next/src/server/app-render/stream-ops.ts @@ -53,4 +53,4 @@ export const getServerPrerender = _m.getServerPrerender export const getClientPrerender = _m.getClientPrerender export const pipeRuntimePrefetchTransform = _m.pipeRuntimePrefetchTransform export const teeStream = _m.teeStream -export const toReadableStream = _m.toReadableStream +export const toWebReadableStream = _m.toWebReadableStream diff --git a/packages/next/src/server/app-render/stream-ops.web.ts b/packages/next/src/server/app-render/stream-ops.web.ts index b22c830043ef..f5303d504da4 100644 --- a/packages/next/src/server/app-render/stream-ops.web.ts +++ b/packages/next/src/server/app-render/stream-ops.web.ts @@ -279,7 +279,7 @@ export function teeStream(stream: AnyStream): [AnyStream, AnyStream] { return (stream as ReadableStream).tee() } -export function toReadableStream( +export function toWebReadableStream( stream: AnyStream ): ReadableStream { return stream as ReadableStream From af79cc4e75a406106bb8a17c9511968c53af50d9 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 4 Mar 2026 17:26:11 +0100 Subject: [PATCH 15/34] Remove unused code --- packages/next/src/server/app-render/stream-ops.node.ts | 6 ------ packages/next/src/server/app-render/stream-ops.ts | 1 - packages/next/src/server/app-render/stream-ops.web.ts | 6 ------ 3 files changed, 13 deletions(-) diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts index 77ff5da9faf9..b8bab2e6561c 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -437,9 +437,3 @@ export function teeStream(stream: AnyStream): [AnyStream, AnyStream] { const [s1, s2] = readableToWeb(stream).tee() return [webToReadable(s1), webToReadable(s2)] } - -export function toWebReadableStream( - stream: AnyStream -): ReadableStream { - return readableToWeb(stream) -} diff --git a/packages/next/src/server/app-render/stream-ops.ts b/packages/next/src/server/app-render/stream-ops.ts index 015cb1966793..0ce0e2cd2f1a 100644 --- a/packages/next/src/server/app-render/stream-ops.ts +++ b/packages/next/src/server/app-render/stream-ops.ts @@ -53,4 +53,3 @@ export const getServerPrerender = _m.getServerPrerender export const getClientPrerender = _m.getClientPrerender export const pipeRuntimePrefetchTransform = _m.pipeRuntimePrefetchTransform export const teeStream = _m.teeStream -export const toWebReadableStream = _m.toWebReadableStream diff --git a/packages/next/src/server/app-render/stream-ops.web.ts b/packages/next/src/server/app-render/stream-ops.web.ts index f5303d504da4..980d229145d6 100644 --- a/packages/next/src/server/app-render/stream-ops.web.ts +++ b/packages/next/src/server/app-render/stream-ops.web.ts @@ -278,9 +278,3 @@ export function pipeRuntimePrefetchTransform( export function teeStream(stream: AnyStream): [AnyStream, AnyStream] { return (stream as ReadableStream).tee() } - -export function toWebReadableStream( - stream: AnyStream -): ReadableStream { - return stream as ReadableStream -} From f7222d67a65d4707338e5087d0e40def96c9021e Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 4 Mar 2026 17:28:41 +0100 Subject: [PATCH 16/34] Update stream-ops.node.ts --- .../src/server/app-render/stream-ops.node.ts | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts index b8bab2e6561c..1a3c5475fe69 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -85,7 +85,7 @@ export type FizzStreamResult = { type WebReadableStream = import('stream/web').ReadableStream -function readableToWeb( +function nodeReadableToWebReadableStream( stream: Readable | ReadableStream ): ReadableStream { if (stream instanceof ReadableStream) { @@ -243,13 +243,13 @@ export async function continueFizzStream( const webOpts = { ...opts, inlinedDataStream: opts.inlinedDataStream - ? readableToWeb(opts.inlinedDataStream) + ? nodeReadableToWebReadableStream(opts.inlinedDataStream) : undefined, } // The web continueFizzStream reads renderStream.allReady from the stream // object itself (ReactDOMServerReadableStream). A plain ReadableStream from // readableToWeb() won't have that property, so we attach it from opts. - const webStream = readableToWeb(renderStream) + const webStream = nodeReadableToWebReadableStream(renderStream) const fizzLike = Object.assign(webStream, { allReady: opts.allReady ?? Promise.resolve(), }) as ReactDOMServerReadableStream @@ -262,10 +262,12 @@ export async function continueStaticPrerender( opts: import('./stream-ops.web').ContinueStaticPrerenderOptions ): Promise { const webResult = await webContinueStaticPrerender( - readableToWeb(prerenderStream), + nodeReadableToWebReadableStream(prerenderStream), { ...opts, - inlinedDataStream: readableToWeb(opts.inlinedDataStream), + inlinedDataStream: nodeReadableToWebReadableStream( + opts.inlinedDataStream + ), } ) return webToReadable(webResult) @@ -280,7 +282,7 @@ export async function continueDynamicPrerender( } ): Promise { const webResult = await webContinueDynamicPrerender( - readableToWeb(prerenderStream), + nodeReadableToWebReadableStream(prerenderStream), opts ) return webToReadable(webResult) @@ -291,10 +293,12 @@ export async function continueStaticFallbackPrerender( opts: import('./stream-ops.web').ContinueStaticPrerenderOptions ): Promise { const webResult = await webContinueStaticFallbackPrerender( - readableToWeb(prerenderStream), + nodeReadableToWebReadableStream(prerenderStream), { ...opts, - inlinedDataStream: readableToWeb(opts.inlinedDataStream), + inlinedDataStream: nodeReadableToWebReadableStream( + opts.inlinedDataStream + ), } ) return webToReadable(webResult) @@ -305,10 +309,12 @@ export async function continueDynamicHTMLResume( opts: import('./stream-ops.web').ContinueDynamicHTMLResumeOptions ): Promise { const webResult = await webContinueDynamicHTMLResume( - readableToWeb(renderStream), + nodeReadableToWebReadableStream(renderStream), { ...opts, - inlinedDataStream: readableToWeb(opts.inlinedDataStream), + inlinedDataStream: nodeReadableToWebReadableStream( + opts.inlinedDataStream + ), } ) return webToReadable(webResult) @@ -348,7 +354,7 @@ export function chainStreams(...streams: AnyStream[]): AnyStream { } export async function streamToBuffer(stream: AnyStream): Promise { - return webStreamToBuffer(readableToWeb(stream)) + return webStreamToBuffer(nodeReadableToWebReadableStream(stream)) } export async function streamToUint8Array( @@ -362,7 +368,7 @@ export async function streamToUint8Array( } export async function streamToString(stream: AnyStream): Promise { - return webStreamToString(readableToWeb(stream)) + return webStreamToString(nodeReadableToWebReadableStream(stream)) } export function createInlinedDataStream( @@ -370,7 +376,7 @@ export function createInlinedDataStream( nonce: string | undefined, formState: unknown | null ): AnyStream { - const webSource = readableToWeb(source) + const webSource = nodeReadableToWebReadableStream(source) const webResult = createInlinedDataReadableStream(webSource, nonce, formState) return webToReadable(webResult) } @@ -400,7 +406,7 @@ export function pipeRuntimePrefetchTransform( isPartial: boolean, staleTime: number ): AnyStream { - const webStream = readableToWeb(stream) + const webStream = nodeReadableToWebReadableStream(stream) const transformed = webStream.pipeThrough( createRuntimePrefetchTransformStream(sentinel, isPartial, staleTime) ) @@ -412,7 +418,8 @@ export function pipeRuntimePrefetchTransform( // --------------------------------------------------------------------------- export async function processPrelude(unprocessedPrelude: AnyStream) { - const [prelude, peek] = readableToWeb(unprocessedPrelude).tee() + const [prelude, peek] = + nodeReadableToWebReadableStream(unprocessedPrelude).tee() const reader = peek.getReader() const firstResult = await reader.read() @@ -434,6 +441,6 @@ export const getClientPrerender: typeof import('react-dom/static').prerender = prerender export function teeStream(stream: AnyStream): [AnyStream, AnyStream] { - const [s1, s2] = readableToWeb(stream).tee() + const [s1, s2] = nodeReadableToWebReadableStream(stream).tee() return [webToReadable(s1), webToReadable(s2)] } From 91c1a3053eccacaed1b1b635296dceeeea12b135 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 4 Mar 2026 17:50:34 +0100 Subject: [PATCH 17/34] Simplify require --- .../app-render/app-render-prerender-utils.ts | 31 +++++++------------ packages/next/src/server/render-result.ts | 28 ++++++++--------- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/packages/next/src/server/app-render/app-render-prerender-utils.ts b/packages/next/src/server/app-render/app-render-prerender-utils.ts index c542e2b8e81b..0491177f6b67 100644 --- a/packages/next/src/server/app-render/app-render-prerender-utils.ts +++ b/packages/next/src/server/app-render/app-render-prerender-utils.ts @@ -30,30 +30,21 @@ export class ReactServerResult { return tee[1] } + let Readable: typeof import('node:stream').Readable if (process.env.TURBOPACK) { - const { Readable } = - require('node:stream') as typeof import('node:stream') - const webStream = Readable.toWeb( - this._stream - ) as ReadableStream - const tee = webStream.tee() - this._stream = Readable.fromWeb( - tee[0] as import('stream/web').ReadableStream - ) - return Readable.fromWeb(tee[1] as import('stream/web').ReadableStream) + Readable = (require('node:stream') as typeof import('node:stream')) + .Readable } else { - const { Readable } = __non_webpack_require__( + Readable = __non_webpack_require__( 'node:stream' - ) as typeof import('node:stream') - const webStream = Readable.toWeb( - this._stream - ) as ReadableStream - const tee = webStream.tee() - this._stream = Readable.fromWeb( - tee[0] as import('stream/web').ReadableStream - ) - return Readable.fromWeb(tee[1] as import('stream/web').ReadableStream) + ) as typeof import('node:stream').Readable } + const webStream = Readable.toWeb(this._stream) as ReadableStream + const tee = webStream.tee() + this._stream = Readable.fromWeb( + tee[0] as import('stream/web').ReadableStream + ) + return Readable.fromWeb(tee[1] as import('stream/web').ReadableStream) } consume(): StreamLike { diff --git a/packages/next/src/server/render-result.ts b/packages/next/src/server/render-result.ts index 099ae1c29c02..70ed20558967 100644 --- a/packages/next/src/server/render-result.ts +++ b/packages/next/src/server/render-result.ts @@ -248,16 +248,16 @@ export default class RenderResult< } if (isNodeReadable(this.response)) { + let Readable: typeof import('node:stream').Readable if (process.env.TURBOPACK) { - const { Readable: NodeReadable } = - require('stream') as typeof import('stream') - return NodeReadable.toWeb(this.response) as ReadableStream + Readable = (require('node:stream') as typeof import('node:stream')) + .Readable } else { - const { Readable: NodeReadable } = __non_webpack_require__( - 'stream' - ) as typeof import('stream') - return NodeReadable.toWeb(this.response) as ReadableStream + Readable = __non_webpack_require__( + 'node:stream' + ) as typeof import('node:stream').Readable } + return Readable.toWeb(this.response) as ReadableStream } return this.response @@ -283,16 +283,16 @@ export default class RenderResult< } else if (Buffer.isBuffer(this.response)) { return [streamFromBuffer(this.response)] } else if (isNodeReadable(this.response)) { + let Readable: typeof import('node:stream').Readable if (process.env.TURBOPACK) { - const { Readable: NodeReadable } = - require('stream') as typeof import('stream') - return [NodeReadable.toWeb(this.response) as ReadableStream] + Readable = (require('node:stream') as typeof import('node:stream')) + .Readable } else { - const { Readable: NodeReadable } = __non_webpack_require__( - 'stream' - ) as typeof import('stream') - return [NodeReadable.toWeb(this.response) as ReadableStream] + Readable = __non_webpack_require__( + 'node:stream' + ) as typeof import('node:stream').Readable } + return [Readable.toWeb(this.response) as ReadableStream] } else { return [this.response] } From cb0656a69ffa6366d2152e849856beeb61c504d5 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 4 Mar 2026 17:56:02 +0100 Subject: [PATCH 18/34] Remove confusion by removing "StreamLike" in favor of "AnyStream" --- .../app-render/app-render-prerender-utils.ts | 14 +++++++------- .../next/src/server/app-render/stream-ops.node.ts | 8 ++++---- packages/next/src/server/app-render/stream-ops.ts | 2 +- .../next/src/server/app-render/stream-ops.web.ts | 10 +++++----- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/next/src/server/app-render/app-render-prerender-utils.ts b/packages/next/src/server/app-render/app-render-prerender-utils.ts index 0491177f6b67..0b62b75a3590 100644 --- a/packages/next/src/server/app-render/app-render-prerender-utils.ts +++ b/packages/next/src/server/app-render/app-render-prerender-utils.ts @@ -1,9 +1,9 @@ import type { Readable } from 'node:stream' import { InvariantError } from '../../shared/lib/invariant-error' -export type StreamLike = ReadableStream | Readable +export type AnyStream = ReadableStream | Readable -function isWebStream(stream: StreamLike): stream is ReadableStream { +function isWebStream(stream: AnyStream): stream is ReadableStream { return typeof (stream as ReadableStream).tee === 'function' } @@ -12,13 +12,13 @@ function isWebStream(stream: StreamLike): stream is ReadableStream { // has not yet implemented a concept of resume. For now we will simulate a paused connection by wrapping the stream // in one that doesn't close even when the underlying is complete. export class ReactServerResult { - private _stream: null | StreamLike + private _stream: null | AnyStream - constructor(stream: StreamLike) { + constructor(stream: AnyStream) { this._stream = stream } - tee(): StreamLike { + tee(): AnyStream { if (this._stream === null) { throw new Error( 'Cannot tee a ReactServerResult that has already been consumed' @@ -47,7 +47,7 @@ export class ReactServerResult { return Readable.fromWeb(tee[1] as import('stream/web').ReadableStream) } - consume(): StreamLike { + consume(): AnyStream { if (this._stream === null) { throw new Error( 'Cannot consume a ReactServerResult that has already been consumed' @@ -80,7 +80,7 @@ export async function createReactServerPrerenderResult( } export async function createReactServerPrerenderResultFromRender( - underlying: StreamLike + underlying: AnyStream ): Promise { const chunks: Array = [] diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts index 1a3c5475fe69..4d1cd800f258 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -2,7 +2,7 @@ * Node.js stream operations for the rendering pipeline. * Loaded by stream-ops.ts when process.env.__NEXT_USE_NODE_STREAMS is true. * - * AnyStream = StreamLike so the exported type surface matches stream-ops.web.ts, + * AnyStream = AnyStreamType so the exported type surface matches stream-ops.web.ts, * allowing the switcher to assign either module without casts. * Rendering uses pipeable APIs; continue functions wrap the existing web * transforms via Readable.fromWeb() on their output. @@ -29,7 +29,7 @@ import { createRuntimePrefetchTransformStream, } from '../stream-utils/node-web-streams-helper' import { createInlinedDataReadableStream } from './use-flight-response' -import type { StreamLike } from './app-render-prerender-utils' +import type { AnyStream as AnyStreamType } from './app-render-prerender-utils' import { DetachedPromise } from '../../lib/detached-promise' import { getTracer } from '../lib/trace/tracer' import { AppRenderSpan } from '../lib/trace/constants' @@ -53,7 +53,7 @@ export type { // AnyStream matches stream-ops.web.ts so both modules have the same type surface // --------------------------------------------------------------------------- -export type AnyStream = StreamLike +export type AnyStream = AnyStreamType export type FlightComponentMod = { renderToReadableStream: ( @@ -372,7 +372,7 @@ export async function streamToString(stream: AnyStream): Promise { } export function createInlinedDataStream( - source: StreamLike, + source: AnyStream, nonce: string | undefined, formState: unknown | null ): AnyStream { diff --git a/packages/next/src/server/app-render/stream-ops.ts b/packages/next/src/server/app-render/stream-ops.ts index 0ce0e2cd2f1a..ae0614ba2e9c 100644 --- a/packages/next/src/server/app-render/stream-ops.ts +++ b/packages/next/src/server/app-render/stream-ops.ts @@ -4,7 +4,7 @@ * When __NEXT_USE_NODE_STREAMS is true, uses Node.js pipeable stream APIs. * Otherwise, uses web ReadableStream APIs. * - * Both modules export AnyStream = StreamLike so their type surfaces are + * Both modules export AnyStream = AnyStreamType so their type surfaces are * structurally identical — no `as unknown as` cast is needed. */ export type { diff --git a/packages/next/src/server/app-render/stream-ops.web.ts b/packages/next/src/server/app-render/stream-ops.web.ts index 980d229145d6..b9107c4a35be 100644 --- a/packages/next/src/server/app-render/stream-ops.web.ts +++ b/packages/next/src/server/app-render/stream-ops.web.ts @@ -2,7 +2,7 @@ * Web stream operations for the rendering pipeline. * Loaded by stream-ops.ts when __NEXT_USE_NODE_STREAMS is false (default). * - * AnyStream = StreamLike so the exported type surface matches stream-ops.node.ts, + * AnyStream = AnyStreamType so the exported type surface matches stream-ops.node.ts, * allowing the switcher to assign either module without `as unknown as`. */ @@ -26,7 +26,7 @@ import { } from '../stream-utils/node-web-streams-helper' import { createInlinedDataReadableStream } from './use-flight-response' import { processPrelude as webProcessPrelude } from './app-render-prerender-utils' -import type { StreamLike } from './app-render-prerender-utils' +import type { AnyStream as AnyStreamType } from './app-render-prerender-utils' // --------------------------------------------------------------------------- // Shared types @@ -38,7 +38,7 @@ type FlightRenderToReadableStream = ( options?: any ) => ReadableStream -export type AnyStream = StreamLike +export type AnyStream = AnyStreamType export type ContinueStreamSharedOptions = { deploymentId: string | undefined @@ -83,7 +83,7 @@ export type FizzStreamResult = { // --------------------------------------------------------------------------- // Continue function wrappers -// Thin wrappers that accept AnyStream (= StreamLike) and narrow to +// Thin wrappers that accept AnyStream and narrow to // ReadableStream internally for the web helper functions. // --------------------------------------------------------------------------- @@ -184,7 +184,7 @@ export async function processPrelude( // --------------------------------------------------------------------------- export function createInlinedDataStream( - source: StreamLike, + source: AnyStream, nonce: string | undefined, formState: unknown | null ): AnyStream { From d888ddfdbbb20a7188fa7b2a8f25519d7c02710d Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 4 Mar 2026 19:06:17 +0100 Subject: [PATCH 19/34] Fix --- .../server/app-render/app-render-prerender-utils.ts | 6 +++--- packages/next/src/server/render-result.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/next/src/server/app-render/app-render-prerender-utils.ts b/packages/next/src/server/app-render/app-render-prerender-utils.ts index 0b62b75a3590..f36a3859239a 100644 --- a/packages/next/src/server/app-render/app-render-prerender-utils.ts +++ b/packages/next/src/server/app-render/app-render-prerender-utils.ts @@ -35,9 +35,9 @@ export class ReactServerResult { Readable = (require('node:stream') as typeof import('node:stream')) .Readable } else { - Readable = __non_webpack_require__( - 'node:stream' - ) as typeof import('node:stream').Readable + Readable = ( + __non_webpack_require__('node:stream') as typeof import('node:stream') + ).Readable } const webStream = Readable.toWeb(this._stream) as ReadableStream const tee = webStream.tee() diff --git a/packages/next/src/server/render-result.ts b/packages/next/src/server/render-result.ts index 70ed20558967..421c89220d92 100644 --- a/packages/next/src/server/render-result.ts +++ b/packages/next/src/server/render-result.ts @@ -253,9 +253,9 @@ export default class RenderResult< Readable = (require('node:stream') as typeof import('node:stream')) .Readable } else { - Readable = __non_webpack_require__( - 'node:stream' - ) as typeof import('node:stream').Readable + Readable = ( + __non_webpack_require__('node:stream') as typeof import('node:stream') + ).Readable } return Readable.toWeb(this.response) as ReadableStream } @@ -288,9 +288,9 @@ export default class RenderResult< Readable = (require('node:stream') as typeof import('node:stream')) .Readable } else { - Readable = __non_webpack_require__( - 'node:stream' - ) as typeof import('node:stream').Readable + Readable = ( + __non_webpack_require__('node:stream') as typeof import('node:stream') + ).Readable } return [Readable.toWeb(this.response) as ReadableStream] } else { From b0137158e7da880f52638fb0c50398313d5ad4b0 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 4 Mar 2026 22:19:29 +0100 Subject: [PATCH 20/34] Update app-render-prerender-utils.ts --- .../server/app-render/app-render-prerender-utils.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/next/src/server/app-render/app-render-prerender-utils.ts b/packages/next/src/server/app-render/app-render-prerender-utils.ts index f36a3859239a..6b2349f4548b 100644 --- a/packages/next/src/server/app-render/app-render-prerender-utils.ts +++ b/packages/next/src/server/app-render/app-render-prerender-utils.ts @@ -95,15 +95,9 @@ export async function createReactServerPrerenderResultFromRender( } } } else { - // Node.js Readable stream - const readable: Readable = underlying - await new Promise((resolve, reject) => { - readable.on('data', (chunk: Buffer | Uint8Array) => { - chunks.push(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)) - }) - readable.on('end', resolve) - readable.on('error', reject) - }) + for await (const chunk of underlying) { + chunks.push(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)) + } } return new ReactServerPrerenderResult(chunks) From 8386014bc74828cd2c3dd38a89a1f5c5f7cfd48f Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 5 Mar 2026 12:01:58 +0100 Subject: [PATCH 21/34] Update flight-render-result.ts --- packages/next/src/server/app-render/flight-render-result.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/next/src/server/app-render/flight-render-result.ts b/packages/next/src/server/app-render/flight-render-result.ts index bb79f651728b..36eb8495a210 100644 --- a/packages/next/src/server/app-render/flight-render-result.ts +++ b/packages/next/src/server/app-render/flight-render-result.ts @@ -1,13 +1,13 @@ -import type { Readable } from 'node:stream' import { RSC_CONTENT_TYPE_HEADER } from '../../client/components/app-router-headers' import RenderResult, { type RenderResultMetadata } from '../render-result' +import type { AnyStream } from './stream-ops' /** * Flight Response is always set to RSC_CONTENT_TYPE_HEADER to ensure it does not get interpreted as HTML. */ export class FlightRenderResult extends RenderResult { constructor( - response: string | ReadableStream | Readable, + response: string | AnyStream, metadata: RenderResultMetadata = {}, waitUntil?: Promise ) { From fa32864767016a54de6a728f54a51e7011b3dacf Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 5 Mar 2026 12:50:31 +0100 Subject: [PATCH 22/34] Support AnyStream for debugChannel --- .../next/src/server/app-render/app-render.tsx | 49 +++++-------------- .../app-render/debug-channel-server.node.ts | 32 +++--------- .../server/app-render/debug-channel-server.ts | 7 ++- .../app-render/debug-channel-server.web.ts | 12 ++--- .../instant-validation/instant-validation.tsx | 6 ++- packages/next/src/server/app-render/types.ts | 2 +- .../server/app-render/use-flight-response.tsx | 4 +- packages/next/src/server/dev/debug-channel.ts | 15 ++++-- .../lib/router-utils/router-server-context.ts | 2 +- 9 files changed, 48 insertions(+), 81 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index a6b2b43658b6..7c7401c75659 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -1222,9 +1222,9 @@ async function generateDynamicFlightRenderResultWithStagesInDev( if (shouldValidate) { let validationDebugChannelClient: Readable | undefined = undefined if (returnedDebugChannel) { - const [t1, t2] = returnedDebugChannel.clientSide.readable.tee() + const [t1, t2] = teeStream(returnedDebugChannel.clientSide.readable) returnedDebugChannel.clientSide.readable = t1 - validationDebugChannelClient = nodeStreamFromReadableStream(t2) + validationDebugChannelClient = t2 as Readable } consoleAsyncStorage.run( { dim: true }, @@ -2026,7 +2026,7 @@ function App({ }: { /* eslint-disable @next/internal/no-ambiguous-jsx -- React Client */ reactServerStream: Readable | BinaryStreamOf - reactDebugStream: Readable | ReadableStream | undefined + reactDebugStream: AnyStream | undefined debugEndTime: number | undefined preinitScripts: () => void ServerInsertedHTMLProvider: ComponentType<{ @@ -3010,7 +3010,7 @@ async function renderToStream( ) let reactServerResult: null | ReactServerResult = null - let reactDebugStream: ReadableStream | undefined + let reactDebugStream: AnyStream | undefined const setHeader = res.setHeader.bind(res) const appendHeader = res.appendHeader.bind(res) @@ -3082,9 +3082,9 @@ async function renderToStream( let validationDebugChannelClient: Readable | undefined = undefined if (returnedDebugChannel) { - const [t1, t2] = returnedDebugChannel.clientSide.readable.tee() + const [t1, t2] = teeStream(returnedDebugChannel.clientSide.readable) returnedDebugChannel.clientSide.readable = t1 - validationDebugChannelClient = nodeStreamFromReadableStream(t2) + validationDebugChannelClient = t2 as Readable } consoleAsyncStorage.run( @@ -3127,8 +3127,9 @@ async function renderToStream( } if (debugChannel && setReactDebugChannel) { - const [readableSsr, readableBrowser] = - debugChannel.clientSide.readable.tee() + const [readableSsr, readableBrowser] = teeStream( + debugChannel.clientSide.readable + ) reactDebugStream = readableSsr @@ -3275,8 +3276,9 @@ async function renderToStream( const debugChannel = setReactDebugChannel && createDebugChannel() if (debugChannel) { - const [readableSsr, readableBrowser] = - debugChannel.clientSide.readable.tee() + const [readableSsr, readableBrowser] = teeStream( + debugChannel.clientSide.readable + ) reactDebugStream = readableSsr @@ -7389,30 +7391,3 @@ function WarnForBypassCachesInDev({ route }: { route: string }) { ) return null } - -function nodeStreamFromReadableStream(stream: ReadableStream) { - if (process.env.NEXT_RUNTIME === 'edge') { - throw new InvariantError( - 'nodeStreamFromReadableStream cannot be used in the edge runtime' - ) - } else { - const reader = stream.getReader() - - const { Readable } = require('node:stream') as typeof import('node:stream') - - return new Readable({ - read() { - reader - .read() - .then(({ done, value }) => { - if (done) { - this.push(null) - } else { - this.push(value) - } - }) - .catch((err) => this.destroy(err)) - }, - }) - } -} diff --git a/packages/next/src/server/app-render/debug-channel-server.node.ts b/packages/next/src/server/app-render/debug-channel-server.node.ts index 32cb2d230a46..cc93de8335af 100644 --- a/packages/next/src/server/app-render/debug-channel-server.node.ts +++ b/packages/next/src/server/app-render/debug-channel-server.node.ts @@ -3,39 +3,21 @@ * Loaded by debug-channel-server.ts when __NEXT_USE_NODE_STREAMS is enabled. */ -import { PassThrough, Readable, Writable } from 'node:stream' +import { PassThrough } from 'node:stream' +import type { DebugChannelPair } from './debug-channel-server.web' -type NodeDebugChannelServer = { - readable?: ReadableStream - writable: WritableStream -} - -type NodeDebugChannelPair = { - serverSide: NodeDebugChannelServer - clientSide: { - readable: ReadableStream - writable?: WritableStream - } -} - -export function createDebugChannel(): - | import('./debug-channel-server.web').DebugChannelPair - | undefined { +export function createDebugChannel(): DebugChannelPair | undefined { if (process.env.NODE_ENV === 'production') { return undefined } - return createNodeDebugChannel() as unknown as import('./debug-channel-server.web').DebugChannelPair + return createNodeDebugChannel() } -export function createNodeDebugChannel(): NodeDebugChannelPair { +export function createNodeDebugChannel(): DebugChannelPair { const duplex = new PassThrough() - const clientReadable = Readable.toWeb(duplex) as ReadableStream - const serverWritable = Writable.toWeb(duplex) as WritableStream return { - serverSide: { - writable: serverWritable, - }, - clientSide: { readable: clientReadable }, + serverSide: duplex, + clientSide: { readable: duplex }, } } diff --git a/packages/next/src/server/app-render/debug-channel-server.ts b/packages/next/src/server/app-render/debug-channel-server.ts index 533ad6e34380..a29b0b48be24 100644 --- a/packages/next/src/server/app-render/debug-channel-server.ts +++ b/packages/next/src/server/app-render/debug-channel-server.ts @@ -1,8 +1,11 @@ /** * Compile-time switcher for debug channel operations. * - * When __NEXT_USE_NODE_STREAMS is true, uses a Node Writable-based channel. + * When __NEXT_USE_NODE_STREAMS is true, uses a Node PassThrough-based channel. * Otherwise, uses web WritableStream APIs. + * + * Both modules share the same DebugChannelPair type surface via AnyStream, + * matching the pattern used by stream-ops.ts. */ export type { DebugChannelPair, @@ -19,7 +22,7 @@ if (process.env.__NEXT_USE_NODE_STREAMS) { require('./debug-channel-server.node') as typeof import('./debug-channel-server.node') } else { _m = - require('./debug-channel-server.web') as typeof import('./debug-channel-server.web') as DebugChannelMod + require('./debug-channel-server.web') as typeof import('./debug-channel-server.web') } export const createDebugChannel = _m.createDebugChannel diff --git a/packages/next/src/server/app-render/debug-channel-server.web.ts b/packages/next/src/server/app-render/debug-channel-server.web.ts index 58d22ec6377d..ea14b4dc3230 100644 --- a/packages/next/src/server/app-render/debug-channel-server.web.ts +++ b/packages/next/src/server/app-render/debug-channel-server.web.ts @@ -3,20 +3,18 @@ * Loaded by debug-channel-server.ts. */ -// Types defined inline for now; will move to debug-channel-server.node.ts later. +import type { AnyStream } from './app-render-prerender-utils' + export type DebugChannelPair = { serverSide: DebugChannelServer clientSide: DebugChannelClient } -export type DebugChannelServer = { - readable?: ReadableStream - writable: WritableStream -} + +export type DebugChannelServer = any type DebugChannelClient = { - readable: ReadableStream - writable?: WritableStream + readable: AnyStream } export function createDebugChannel(): DebugChannelPair | undefined { diff --git a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx index ba1245210629..643bba543492 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx +++ b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx @@ -342,7 +342,8 @@ export async function collectStagedSegmentData( // accumulate Debug chunks segmentDebugChannel && (async () => { - for await (const chunk of segmentDebugChannel.clientSide.readable.values()) { + for await (const chunk of segmentDebugChannel.clientSide + .readable as AsyncIterable) { cacheEntry.debugChunks!.push(chunk) } })(), @@ -583,7 +584,8 @@ export async function createCombinedPayloadStream( // Accumulate debug chunks debugChannel && (async () => { - for await (const chunk of debugChannel.clientSide.readable.values()) { + for await (const chunk of debugChannel.clientSide + .readable as AsyncIterable) { debugChunks!.push(chunk) } })(), diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index af4ac9cf248f..3e9da47f1ae3 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -116,7 +116,7 @@ export interface RenderOptsPartial { setCacheStatus?: (status: ServerCacheStatus, htmlRequestId: string) => void setIsrStatus?: (key: string, value: boolean | undefined) => void setReactDebugChannel?: ( - debugChannel: { readable: ReadableStream }, + debugChannel: { readable: AnyStream }, htmlRequestId: string, requestId: string ) => void diff --git a/packages/next/src/server/app-render/use-flight-response.tsx b/packages/next/src/server/app-render/use-flight-response.tsx index 0d19c95040fd..5c871bfe5534 100644 --- a/packages/next/src/server/app-render/use-flight-response.tsx +++ b/packages/next/src/server/app-render/use-flight-response.tsx @@ -77,8 +77,8 @@ export function getFlightStream( require('node:stream') as typeof import('node:stream') // Convert debug stream to Readable if it's a ReadableStream. - // This can happen when the flight stream is a Node Readable (node streams path) - // but the debug channel always produces web ReadableStreams. + // When __NEXT_USE_NODE_STREAMS is enabled, the debug channel produces + // Node Readables natively. Otherwise, it produces web ReadableStreams. let nodeDebugStream: Readable | undefined if (debugStream) { if (debugStream instanceof Readable) { diff --git a/packages/next/src/server/dev/debug-channel.ts b/packages/next/src/server/dev/debug-channel.ts index a0a052b8426a..8764b763f93c 100644 --- a/packages/next/src/server/dev/debug-channel.ts +++ b/packages/next/src/server/dev/debug-channel.ts @@ -1,12 +1,13 @@ +import { Readable } from 'node:stream' import { createBufferedTransformStream } from '../stream-utils/node-web-streams-helper' import { HMR_MESSAGE_SENT_TO_BROWSER, type HmrMessageSentToBrowser, } from './hot-reloader-types' +import type { AnyStream } from '../app-render/stream-ops' export interface ReactDebugChannelForBrowser { - readonly readable: ReadableStream - // Might also get a writable stream as return channel in the future. + readonly readable: AnyStream } const reactDebugChannelsByHtmlRequestId = new Map< @@ -14,14 +15,20 @@ const reactDebugChannelsByHtmlRequestId = new Map< ReactDebugChannelForBrowser >() +function toWebReadableStream(stream: AnyStream): ReadableStream { + if (stream instanceof ReadableStream) { + return stream + } + return Readable.toWeb(stream) as unknown as ReadableStream +} + export function connectReactDebugChannel( requestId: string, debugChannel: ReactDebugChannelForBrowser, sendToClient: (message: HmrMessageSentToBrowser) => void ) { - const reader = debugChannel.readable + const reader = toWebReadableStream(debugChannel.readable) .pipeThrough( - // We're sending the chunks in batches to reduce overhead in the browser. createBufferedTransformStream({ maxBufferByteLength: 128 * 1024 }) ) .getReader() diff --git a/packages/next/src/server/lib/router-utils/router-server-context.ts b/packages/next/src/server/lib/router-utils/router-server-context.ts index cdd546328534..ad6eaa817c58 100644 --- a/packages/next/src/server/lib/router-utils/router-server-context.ts +++ b/packages/next/src/server/lib/router-utils/router-server-context.ts @@ -41,7 +41,7 @@ export type RouterServerContext = Record< // allow setting ISR status in dev setIsrStatus?: (key: string, value: boolean | undefined) => void setReactDebugChannel?: ( - debugChannel: { readable: ReadableStream }, + debugChannel: { readable: AnyStream }, htmlRequestId: string, requestId: string ) => void From 5dcaafe83b3c2e5bc992ae8ac5f62074b860d68b Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 5 Mar 2026 13:13:10 +0100 Subject: [PATCH 23/34] Update debug-channel-server.web.ts --- packages/next/src/server/app-render/debug-channel-server.web.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/next/src/server/app-render/debug-channel-server.web.ts b/packages/next/src/server/app-render/debug-channel-server.web.ts index ea14b4dc3230..ae34f2252095 100644 --- a/packages/next/src/server/app-render/debug-channel-server.web.ts +++ b/packages/next/src/server/app-render/debug-channel-server.web.ts @@ -10,7 +10,6 @@ export type DebugChannelPair = { clientSide: DebugChannelClient } - export type DebugChannelServer = any type DebugChannelClient = { From 0cab21184b7da3a4ab2b06f15c8c4f95517d8f34 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 5 Mar 2026 14:19:58 +0100 Subject: [PATCH 24/34] Update debugChannel --- .../next/src/server/app-render/app-render.tsx | 19 ++++++++------- .../app-render/debug-channel-server.node.ts | 24 +++++++++++++++---- .../server/app-render/debug-channel-server.ts | 3 --- .../app-render/debug-channel-server.web.ts | 3 +++ packages/next/src/server/dev/debug-channel.ts | 19 ++++++++------- 5 files changed, 44 insertions(+), 24 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 7c7401c75659..ef892c58300a 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -1220,11 +1220,11 @@ async function generateDynamicFlightRenderResultWithStagesInDev( ) if (shouldValidate) { - let validationDebugChannelClient: Readable | undefined = undefined + let validationDebugChannelClient: AnyStream | undefined = undefined if (returnedDebugChannel) { const [t1, t2] = teeStream(returnedDebugChannel.clientSide.readable) returnedDebugChannel.clientSide.readable = t1 - validationDebugChannelClient = t2 as Readable + validationDebugChannelClient = t2 } consoleAsyncStorage.run( { dim: true }, @@ -3080,11 +3080,11 @@ async function renderToStream( serverComponentsErrorHandler ) - let validationDebugChannelClient: Readable | undefined = undefined + let validationDebugChannelClient: AnyStream | undefined = undefined if (returnedDebugChannel) { const [t1, t2] = teeStream(returnedDebugChannel.clientSide.readable) returnedDebugChannel.clientSide.readable = t1 - validationDebugChannelClient = t2 as Readable + validationDebugChannelClient = t2 } consoleAsyncStorage.run( @@ -4263,7 +4263,7 @@ async function spawnStaticShellValidationInDevImpl( ctx: AppRenderContext, requestStore: RequestStore, fallbackRouteParams: OpaqueFallbackRouteParams | null, - debugChannelClient: Readable | undefined + debugChannelClient: AnyStream | undefined ): Promise { const debug = process.env.NEXT_PRIVATE_DEBUG_VALIDATION === '1' ? console.log : undefined @@ -4299,9 +4299,12 @@ async function spawnStaticShellValidationInDevImpl( let debugChunks: Uint8Array[] | null = null if (debugChannelClient) { debugChunks = [] - debugChannelClient.on('data', (c) => { - debugChunks!.push(c) - }) + const chunks = debugChunks + ;(async () => { + for await (const c of debugChannelClient as AsyncIterable) { + chunks.push(c) + } + })() } const accumulatedChunks = await accumulatedChunksPromise diff --git a/packages/next/src/server/app-render/debug-channel-server.node.ts b/packages/next/src/server/app-render/debug-channel-server.node.ts index cc93de8335af..b96372e10e00 100644 --- a/packages/next/src/server/app-render/debug-channel-server.node.ts +++ b/packages/next/src/server/app-render/debug-channel-server.node.ts @@ -3,7 +3,7 @@ * Loaded by debug-channel-server.ts when __NEXT_USE_NODE_STREAMS is enabled. */ -import { PassThrough } from 'node:stream' +import { PassThrough, Writable } from 'node:stream' import type { DebugChannelPair } from './debug-channel-server.web' export function createDebugChannel(): DebugChannelPair | undefined { @@ -13,11 +13,25 @@ export function createDebugChannel(): DebugChannelPair | undefined { return createNodeDebugChannel() } -export function createNodeDebugChannel(): DebugChannelPair { - const duplex = new PassThrough() +function createNodeDebugChannel(): DebugChannelPair { + const readable = new PassThrough() + + // Use a plain Writable instead of exposing the PassThrough directly. + // React's renderToPipeableStream detects .read() on the debugChannel and + // enters bidirectional mode, reading its own output back as commands. + const writable = new Writable({ + write(chunk, _encoding, callback) { + readable.push(chunk) + callback() + }, + final(callback) { + readable.push(null) + callback() + }, + }) return { - serverSide: duplex, - clientSide: { readable: duplex }, + serverSide: writable, + clientSide: { readable }, } } diff --git a/packages/next/src/server/app-render/debug-channel-server.ts b/packages/next/src/server/app-render/debug-channel-server.ts index a29b0b48be24..2d36fd08dffe 100644 --- a/packages/next/src/server/app-render/debug-channel-server.ts +++ b/packages/next/src/server/app-render/debug-channel-server.ts @@ -3,9 +3,6 @@ * * When __NEXT_USE_NODE_STREAMS is true, uses a Node PassThrough-based channel. * Otherwise, uses web WritableStream APIs. - * - * Both modules share the same DebugChannelPair type surface via AnyStream, - * matching the pattern used by stream-ops.ts. */ export type { DebugChannelPair, diff --git a/packages/next/src/server/app-render/debug-channel-server.web.ts b/packages/next/src/server/app-render/debug-channel-server.web.ts index ae34f2252095..745f1d585dd7 100644 --- a/packages/next/src/server/app-render/debug-channel-server.web.ts +++ b/packages/next/src/server/app-render/debug-channel-server.web.ts @@ -10,6 +10,9 @@ export type DebugChannelPair = { clientSide: DebugChannelClient } +// Opaque: PassThrough on node, { writable: WritableStream } on web. +// Each React render API handles its own variant. + export type DebugChannelServer = any type DebugChannelClient = { diff --git a/packages/next/src/server/dev/debug-channel.ts b/packages/next/src/server/dev/debug-channel.ts index 8764b763f93c..61510afb2970 100644 --- a/packages/next/src/server/dev/debug-channel.ts +++ b/packages/next/src/server/dev/debug-channel.ts @@ -1,4 +1,4 @@ -import { Readable } from 'node:stream' +import type { Readable } from 'node:stream' import { createBufferedTransformStream } from '../stream-utils/node-web-streams-helper' import { HMR_MESSAGE_SENT_TO_BROWSER, @@ -6,6 +6,15 @@ import { } from './hot-reloader-types' import type { AnyStream } from '../app-render/stream-ops' +function toWebReadableStream(stream: AnyStream): ReadableStream { + if (stream instanceof ReadableStream) { + return stream + } + const { Readable: ReadableClass } = + require('node:stream') as typeof import('node:stream') + return ReadableClass.toWeb(stream as Readable) as ReadableStream +} + export interface ReactDebugChannelForBrowser { readonly readable: AnyStream } @@ -15,13 +24,6 @@ const reactDebugChannelsByHtmlRequestId = new Map< ReactDebugChannelForBrowser >() -function toWebReadableStream(stream: AnyStream): ReadableStream { - if (stream instanceof ReadableStream) { - return stream - } - return Readable.toWeb(stream) as unknown as ReadableStream -} - export function connectReactDebugChannel( requestId: string, debugChannel: ReactDebugChannelForBrowser, @@ -29,6 +31,7 @@ export function connectReactDebugChannel( ) { const reader = toWebReadableStream(debugChannel.readable) .pipeThrough( + // We're sending the chunks in batches to reduce overhead in the browser. createBufferedTransformStream({ maxBufferByteLength: 128 * 1024 }) ) .getReader() From 6fa94700644c7fad6930c68dd704f3122697f791 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 5 Mar 2026 14:35:42 +0100 Subject: [PATCH 25/34] Update instant-validation.tsx --- .../app-render/instant-validation/instant-validation.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx index 643bba543492..7f1213aebac6 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx +++ b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx @@ -342,8 +342,7 @@ export async function collectStagedSegmentData( // accumulate Debug chunks segmentDebugChannel && (async () => { - for await (const chunk of segmentDebugChannel.clientSide - .readable as AsyncIterable) { + for await (const chunk of segmentDebugChannel.clientSide.readable) { cacheEntry.debugChunks!.push(chunk) } })(), From ec19548cea1190c9809e29c4aa09467fb0b7ef0e Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 5 Mar 2026 14:36:13 +0100 Subject: [PATCH 26/34] Revert "Update instant-validation.tsx" This reverts commit b65c69e2944428c784e18bb6e5fb8f26d95ba279. --- .../app-render/instant-validation/instant-validation.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx index 7f1213aebac6..643bba543492 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx +++ b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx @@ -342,7 +342,8 @@ export async function collectStagedSegmentData( // accumulate Debug chunks segmentDebugChannel && (async () => { - for await (const chunk of segmentDebugChannel.clientSide.readable) { + for await (const chunk of segmentDebugChannel.clientSide + .readable as AsyncIterable) { cacheEntry.debugChunks!.push(chunk) } })(), From 5c6c498fe32b6aafb69b04bd307e164690075371 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 5 Mar 2026 14:36:19 +0100 Subject: [PATCH 27/34] Update debug-channel-server.web.ts --- packages/next/src/server/app-render/debug-channel-server.web.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/server/app-render/debug-channel-server.web.ts b/packages/next/src/server/app-render/debug-channel-server.web.ts index 745f1d585dd7..4d115fe4ed0b 100644 --- a/packages/next/src/server/app-render/debug-channel-server.web.ts +++ b/packages/next/src/server/app-render/debug-channel-server.web.ts @@ -12,7 +12,7 @@ export type DebugChannelPair = { // Opaque: PassThrough on node, { writable: WritableStream } on web. // Each React render API handles its own variant. - + export type DebugChannelServer = any type DebugChannelClient = { From e54067abcecf143240d038d3e20ab0b542a640d5 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 5 Mar 2026 15:22:08 +0100 Subject: [PATCH 28/34] Use renderToFlightStream from stream-ops --- .../next/src/server/app-render/app-render.tsx | 2 ++ .../instant-validation/instant-validation.tsx | 17 +++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index ef892c58300a..91ceca2f453b 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -4778,6 +4778,7 @@ async function validateInstantConfigs( payload: initialRscPayload, stageEndTimes, } = await collectStagedSegmentData( + ctx.componentMod, { [RenderStage.Static]: accumulatedChunks.staticChunks, [RenderStage.Runtime]: accumulatedChunks.runtimeChunks, @@ -4853,6 +4854,7 @@ async function validateInstantConfigs( const { stream: serverStream, debugStream } = await createCombinedPayloadStream( + ctx.componentMod, payloadResult.payload, extraChunksController, reactController.signal, diff --git a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx index 643bba543492..510d6751494a 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx +++ b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx @@ -42,10 +42,11 @@ import { createNodeStreamFromChunks, } from './stream-utils' import { createDebugChannel } from '../debug-channel-server' +import { renderToFlightStream } from '../stream-ops' +import type { AnyStream, FlightComponentMod } from '../stream-ops' + // eslint-disable-next-line import/no-extraneous-dependencies import { createFromNodeStream } from 'react-server-dom-webpack/client' -// eslint-disable-next-line import/no-extraneous-dependencies -import { renderToReadableStream } from 'react-server-dom-webpack/server' import { addSearchParamsIfPageSegment, isGroupSegment, @@ -204,6 +205,7 @@ export type StageEndTimes = { * into separate staged streams (also in arrays-of-chunks form), one for each segment. * */ export async function collectStagedSegmentData( + ComponentMod: FlightComponentMod, fullPageChunks: StageChunks, fullPageDebugChunks: Uint8Array[] | null, startTime: number, @@ -291,7 +293,8 @@ export async function collectStagedSegmentData( ? createDebugChannel() : undefined - const itemStream = renderToReadableStream( + const itemStream: AnyStream = renderToFlightStream( + ComponentMod, data, clientReferenceManifest.clientModules, { @@ -335,7 +338,7 @@ export async function collectStagedSegmentData( await Promise.all([ // accumulate Flight chunks (async () => { - for await (const chunk of itemStream.values()) { + for await (const chunk of itemStream as AsyncIterable) { writeChunk(cacheEntry.chunks, controller.currentStage, chunk) } })(), @@ -511,6 +514,7 @@ function writeChunk( * to provide extra debug info. * */ export async function createCombinedPayloadStream( + ComponentMod: FlightComponentMod, payload: InitialRSCPayload, extraChunksAbortController: AbortController, renderSignal: AbortSignal, @@ -531,7 +535,8 @@ export async function createCombinedPayloadStream( await runInSequentialTasks( () => { - const stream = renderToReadableStream( + const stream: AnyStream = renderToFlightStream( + ComponentMod, payload, clientReferenceManifest.clientModules, { @@ -574,7 +579,7 @@ export async function createCombinedPayloadStream( streamFinished = Promise.all([ // Accumulate Flight chunks (async () => { - for await (const chunk of stream.values()) { + for await (const chunk of stream as AsyncIterable) { allChunks.push(chunk) if (isRenderable) { renderableChunks.push(chunk) From be620635c5bae6513ea22e5723afc43ed0d91c62 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 5 Mar 2026 15:29:09 +0100 Subject: [PATCH 29/34] Remove typecast --- packages/next/src/server/app-render/app-render.tsx | 5 ++--- .../instant-validation/instant-validation.tsx | 10 ++++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 91ceca2f453b..ad7fe8d12bcb 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -4299,10 +4299,9 @@ async function spawnStaticShellValidationInDevImpl( let debugChunks: Uint8Array[] | null = null if (debugChannelClient) { debugChunks = [] - const chunks = debugChunks ;(async () => { - for await (const c of debugChannelClient as AsyncIterable) { - chunks.push(c) + for await (const c of debugChannelClient) { + debugChunks.push(c) } })() } diff --git a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx index 510d6751494a..c68eeea81118 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx +++ b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx @@ -338,15 +338,14 @@ export async function collectStagedSegmentData( await Promise.all([ // accumulate Flight chunks (async () => { - for await (const chunk of itemStream as AsyncIterable) { + for await (const chunk of itemStream) { writeChunk(cacheEntry.chunks, controller.currentStage, chunk) } })(), // accumulate Debug chunks segmentDebugChannel && (async () => { - for await (const chunk of segmentDebugChannel.clientSide - .readable as AsyncIterable) { + for await (const chunk of segmentDebugChannel.clientSide.readable) { cacheEntry.debugChunks!.push(chunk) } })(), @@ -579,7 +578,7 @@ export async function createCombinedPayloadStream( streamFinished = Promise.all([ // Accumulate Flight chunks (async () => { - for await (const chunk of stream as AsyncIterable) { + for await (const chunk of stream) { allChunks.push(chunk) if (isRenderable) { renderableChunks.push(chunk) @@ -589,8 +588,7 @@ export async function createCombinedPayloadStream( // Accumulate debug chunks debugChannel && (async () => { - for await (const chunk of debugChannel.clientSide - .readable as AsyncIterable) { + for await (const chunk of debugChannel.clientSide.readable) { debugChunks!.push(chunk) } })(), From 6ce7b91017d6ddb00ae2d22b3466779e5cdcb539 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 5 Mar 2026 15:31:20 +0100 Subject: [PATCH 30/34] Update instant-validation.tsx --- .../app-render/instant-validation/instant-validation.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx index c68eeea81118..75926401076b 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx +++ b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx @@ -43,7 +43,7 @@ import { } from './stream-utils' import { createDebugChannel } from '../debug-channel-server' import { renderToFlightStream } from '../stream-ops' -import type { AnyStream, FlightComponentMod } from '../stream-ops' +import type { FlightComponentMod } from '../stream-ops' // eslint-disable-next-line import/no-extraneous-dependencies import { createFromNodeStream } from 'react-server-dom-webpack/client' @@ -293,7 +293,7 @@ export async function collectStagedSegmentData( ? createDebugChannel() : undefined - const itemStream: AnyStream = renderToFlightStream( + const itemStream = renderToFlightStream( ComponentMod, data, clientReferenceManifest.clientModules, @@ -534,7 +534,7 @@ export async function createCombinedPayloadStream( await runInSequentialTasks( () => { - const stream: AnyStream = renderToFlightStream( + const stream = renderToFlightStream( ComponentMod, payload, clientReferenceManifest.clientModules, From 68b520aea2f9f27f6039bd9aa24f3859d4ccda0c Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 5 Mar 2026 15:53:16 +0100 Subject: [PATCH 31/34] Update app-render.tsx --- packages/next/src/server/app-render/app-render.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index ad7fe8d12bcb..efc0d06f9742 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -6595,9 +6595,7 @@ async function prerenderToStream( // We postponed but nothing dynamic was used. We resume the render now and immediately abort it // so we can set all the postponed boundaries to client render mode before we store the HTML response const foreverStream = createPendingStream() - const resumePrelude = await workUnitAsyncStorage.run( - finalServerPrerenderStore, - resumeAndAbort, + const resumePrelude = await resumeAndAbort( // eslint-disable-next-line @next/internal/no-ambiguous-jsx Date: Thu, 5 Mar 2026 16:10:06 +0100 Subject: [PATCH 32/34] Fix bundling issue --- packages/next/src/server/app-render/app-render.tsx | 2 ++ .../instant-validation/instant-validation.tsx | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index efc0d06f9742..1b04288d5d26 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -4778,6 +4778,7 @@ async function validateInstantConfigs( stageEndTimes, } = await collectStagedSegmentData( ctx.componentMod, + renderToFlightStream, { [RenderStage.Static]: accumulatedChunks.staticChunks, [RenderStage.Runtime]: accumulatedChunks.runtimeChunks, @@ -4854,6 +4855,7 @@ async function validateInstantConfigs( const { stream: serverStream, debugStream } = await createCombinedPayloadStream( ctx.componentMod, + renderToFlightStream, payloadResult.payload, extraChunksController, reactController.signal, diff --git a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx index 75926401076b..07d50a3d8dc1 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx +++ b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx @@ -42,7 +42,6 @@ import { createNodeStreamFromChunks, } from './stream-utils' import { createDebugChannel } from '../debug-channel-server' -import { renderToFlightStream } from '../stream-ops' import type { FlightComponentMod } from '../stream-ops' // eslint-disable-next-line import/no-extraneous-dependencies @@ -204,8 +203,16 @@ export type StageEndTimes = { * Splits an existing staged stream (represented as arrays of chunks) * into separate staged streams (also in arrays-of-chunks form), one for each segment. * */ +type RenderToFlightStream = ( + ComponentMod: FlightComponentMod, + payload: any, + clientModules: any, + opts: any +) => AsyncIterable + export async function collectStagedSegmentData( ComponentMod: FlightComponentMod, + renderFlightStream: RenderToFlightStream, fullPageChunks: StageChunks, fullPageDebugChunks: Uint8Array[] | null, startTime: number, @@ -293,7 +300,7 @@ export async function collectStagedSegmentData( ? createDebugChannel() : undefined - const itemStream = renderToFlightStream( + const itemStream = renderFlightStream( ComponentMod, data, clientReferenceManifest.clientModules, @@ -514,6 +521,7 @@ function writeChunk( * */ export async function createCombinedPayloadStream( ComponentMod: FlightComponentMod, + renderFlightStream: RenderToFlightStream, payload: InitialRSCPayload, extraChunksAbortController: AbortController, renderSignal: AbortSignal, @@ -534,7 +542,7 @@ export async function createCombinedPayloadStream( await runInSequentialTasks( () => { - const stream = renderToFlightStream( + const stream = renderFlightStream( ComponentMod, payload, clientReferenceManifest.clientModules, From f3500db38042f480ccc9e7138e34220b16edcc99 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 5 Mar 2026 17:03:00 +0100 Subject: [PATCH 33/34] Revert "Update app-render.tsx" This reverts commit 455ff01f13d858ec461412c3a37443c5b45d3fc9. --- packages/next/src/server/app-render/app-render.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 1b04288d5d26..0f53f95daeda 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -6597,7 +6597,9 @@ async function prerenderToStream( // We postponed but nothing dynamic was used. We resume the render now and immediately abort it // so we can set all the postponed boundaries to client render mode before we store the HTML response const foreverStream = createPendingStream() - const resumePrelude = await resumeAndAbort( + const resumePrelude = await workUnitAsyncStorage.run( + finalServerPrerenderStore, + resumeAndAbort, // eslint-disable-next-line @next/internal/no-ambiguous-jsx Date: Fri, 6 Mar 2026 11:14:53 +0100 Subject: [PATCH 34/34] Update app-render.tsx --- packages/next/src/server/app-render/app-render.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 0f53f95daeda..ac46b83128ec 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -6884,7 +6884,7 @@ async function prerenderToStream( ) } - let htmlStream: ReadableStream = prelude + let htmlStream: AnyStream = prelude if (postponed != null) { // We postponed but nothing dynamic was used. We resume the render now and immediately abort it // so we can set all the postponed boundaries to client render mode before we store the HTML response