Node.js streams: Fork points#92252
Conversation
3d332db to
d555ab2
Compare
Tests Passed |
Stats from current PR🔴 1 regression
📊 All Metrics📖 Metrics GlossaryDev Server Metrics:
Build Metrics:
Change Thresholds:
⚡ Dev Server
📦 Dev Server (Webpack) (Legacy)📦 Dev Server (Webpack)
⚡ Production Builds
📦 Production Builds (Webpack) (Legacy)📦 Production Builds (Webpack)
📦 Bundle SizesBundle Sizes⚡ TurbopackClient Main Bundles
Server Middleware
Build DetailsBuild Manifests
📦 WebpackClient Main Bundles
Polyfills
Pages
Server Edge SSR
Middleware
Build DetailsBuild Manifests
Build Cache
🔄 Shared (bundler-independent)Runtimes
📝 Changed Files (8 files)Files with changes:
View diffsapp-page-exp..ntime.dev.jsfailed to diffapp-page-exp..time.prod.jsfailed to diffapp-page-tur..ntime.dev.jsfailed to diffapp-page-tur..time.prod.jsfailed to diffapp-page-tur..ntime.dev.jsfailed to diffapp-page-tur..time.prod.jsfailed to diffapp-page.runtime.dev.jsfailed to diffapp-page.runtime.prod.jsfailed to diff📎 Tarball URL |
2eb898c to
332ab75
Compare
6b0c620 to
af86f92
Compare
7e19bfc to
9260c86
Compare
9260c86 to
7f98bbe
Compare
gnoff
left a comment
There was a problem hiding this comment.
nits and a few suggestions. we can land though so just lmk if you want me to rereview if you end up changing anything significant
| if (delayDataUntilFirstHtmlChunk) { | ||
| startOrContinuePulling(this) | ||
| } |
There was a problem hiding this comment.
this conditional isn't necessary and maybe it is safer to unconditionally call this. atm it won't change anything but since we're making a mutually exclusive condition with the start on line 232 it might be safer to just rely on the idemopotency
| iconMarkLength += 2 | ||
| } else { | ||
| iconMarkLength++ | ||
| } |
There was a problem hiding this comment.
not sure if the original was commented but this kind of thing should be explained since it is pretty opaque
| if (chunkIndex === 0) { | ||
| closedHeadIndex = indexOfUint8Array(chunk, ENCODED_TAGS.CLOSED.HEAD) | ||
| if (iconMarkIndex < closedHeadIndex) { | ||
| const replaced = Buffer.allocUnsafe(chunk.length - iconMarkLength) |
There was a problem hiding this comment.
if we ever had a mistake in our memory sets that follow this we'd be creating an exfil vuln. Not sure it's worth avoiding the zeroing of alloc
| ? nodeReadableToWebReadableStream(opts.inlinedDataStream) | ||
| : undefined, | ||
| { | ||
| suffix, |
There was a problem hiding this comment.
seems like suffix is unused so we can delete it as an option and delete the associated transforms for both node and web
| // Flight data injection – interleaves RSC data chunks with the HTML stream | ||
| if (inlinedDataStream) { | ||
| const flightInjection = createFlightDataInjectionTransform( | ||
| webToReadable(inlinedDataStream), |
There was a problem hiding this comment.
Ideally the typing would know that the incoming streams must be Readable already. but I think we can land this way. I assume it was originally to allow for a single API mirrored in both impls but now that we have a separate API for node vs web we should just type the stream args as their expected types
| // --------------------------------------------------------------------------- | ||
| // data-dpl-id insertion – Node.js Transform that inserts a `data-dpl-id` | ||
| // attribute on the opening <html tag for deployment identification. | ||
| // --------------------------------------------------------------------------- | ||
|
|
There was a problem hiding this comment.
this is sort of an aside but why aren't we using a meta tag for this and just rendering it?
| "1154": "not implemented", | ||
| "1155": "Not implemented", |
There was a problem hiding this comment.
should fix the version without the capital
|
Will add a follow-up PR to address the comments |
## What? Building on top of the changes in #92252. This PR implements similar fork points for the path when cacheComponents is enabled. Both the development and production path are implemented.
## What? Building on top of the changes in #92252. This PR implements similar fork points for the `catch` path.
## What? Building on top of the changes in #92252. This PR implements similar fork points for continueDynamicHTMLResume
## What? Building on top of the changes in #92252. This PR implements similar fork points for createCombinedPayloadStream
## What? Building on top of the changes in #92252. This PR implements similar fork points for prerenderToStream

What?
Implements native Node.js stream support for the RSC / HTML rendering. This first step does not include cacheComponents / static generation.
Instead of converting between Node.js Readable streams and Web ReadableStreams at every boundary, the rendering pipeline now uses native Node.js
Transform/PassThrough/Readablestreams end-to-end when the useNodeStreams experimental flag is enabled.Why?
The current rendering pipeline has a bottleneck on Web Streams. Each step adds overhead: buffering, scheduling mismatches, and unnecessary memory copies. Node.js streams are the native I/O primitive on the server. Using them directly eliminates these conversion and scheduling/promise costs.
This is the foundational plumbing needed to run the full app render pipeline on native Node.js streams, which improves server-side rendering throughput.
How?
Stream transform reimplementation (
stream-ops.node.ts):All web
TransformStream-based transforms have been reimplemented as Node.jsTransformstreams:createBufferedTransformStream— coalesces chunks written in the same microtask into a singleUint8ArraycreateFlightDataInjectionTransform— interleaves RSC flight data chunks with the HTML streamcreateHeadInsertionTransform— inserts server-generated HTML (polyfills, scripts) before</head>createMetadataTransform— finds and replaces the«nxt-icon»meta markcreateDeferredSuffixTransform— appends the document suffix after the first HTML chunkcreateMoveSuffixTransform— moves</body></html>to the end of the streamcreateHtmlDataDplIdTransform— inserts adata-dpl-idattribute on the opening<html>tagcreateRootLayoutValidatorTransform— validates<html>and<body>tags are present (dev only)ReplayableNodeStream(app-render-prerender-utils.ts):New class that buffers all chunks from a source
Readableand allows creating multiple independent replay streams viacreateReplayStream(). This replaces the web streamtee()operation forReactServerResultwhen using Node.js streams. Uses pull-based delivery (_read()) to avoid AsyncLocalStorage context issues — chunks are only delivered when the consumer reads, keeping them inside the correct ALS scope during Fizz'sperformWork. Includes 392 lines of unit tests covering buffering, live replay, error propagation, multiple independent replays, subscriber cleanup, and dispose behavior.Native rendering functions:
renderToNodeFlightStream— calls React'srenderToPipeableStreamdirectly, pipes to aPassThroughrenderToNodeFizzStream— callsrenderToPipeableStreamfor HTML rendering without the web stream wrappercreateNodeInlinedDataStream— wraps RSC flight data in<script>tags usingBufferoperations andisUtf8()for encoding detectioncontinueFizzStreamrewritten for Node.js:Instead of converting to web streams and delegating to
webContinueFizzStream, the Node.js path pipes through a chain of nativeTransformstreams directly: buffer → dpl-id → metadata → deferred suffix → flight data injection → root layout validator → move suffix → head insertion.API renames for clarity:
renderToFlightStream→renderToWebFlightStream/renderToNodeFlightStreamrenderToFizzStream→renderToWebFizzStream/renderToNodeFizzStreamcreateInlinedDataStream→createWebInlinedDataStream/createNodeInlinedDataStreamcreateDebugChannel→createWebDebugChannel/createNodeDebugChannelapp-render.tsxfork points:The main render function now branches on
process.env.__NEXT_USE_NODE_STREAMSat two key points:renderToNodeFlightStream+createNodeDebugChannelrenderToNodeFizzStream+ nativecontinueFizzStreamwithcreateNodeInlinedDataStreamAll pre-render, cache components, and dev staged-render paths continue to use web streams.
CI updates (
.github/workflows/build_and_test.yml):The
use-node-streamstest jobs now run without__NEXT_CACHE_COMPONENTS/__NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS/__NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLERto isolate the Node.js streams path.