Skip to content

feat(node-streams): add primitives, build infra, and config flag (6/8)#89859

Draft
feedthejim wants to merge 2 commits intocanaryfrom
feedthejim/node-stream-03-primitives
Draft

feat(node-streams): add primitives, build infra, and config flag (6/8)#89859
feedthejim wants to merge 2 commits intocanaryfrom
feedthejim/node-stream-03-primitives

Conversation

@feedthejim
Copy link
Contributor

@feedthejim feedthejim commented Feb 11, 2026

Summary

Add all building blocks for native Node.js stream rendering, gated behind experimental.useNodeStreams (off by default).

Config flag wiring:

  • useNodeStreams in ExperimentalConfig, schema, __NEXT_USE_NODE_STREAMS env var
  • Runtime env propagation in next-server.ts and export/worker.ts

Node stream primitives (new files):

  • node-stream-helpers.ts (~1,022 lines): core operations (continue*, chain, buffer, prelude, tag scanning)
  • pipeable-stream-wrappers.ts: React renderToPipeableStream/resumeToPipeableStream bridges
  • node-stream-tee.ts: O(1) dequeue tee for node streams
  • pipe-readable.ts: backpressure-aware pipe to ServerResponse
  • chain-node-streams.ts: sequential stream chaining

Compile-time switcher upgrade:

  • stream-ops.ts: conditional require('./stream-ops.node') vs require('./stream-ops.web')
  • stream-ops.node.ts: node implementation of all stream ops
  • debug-channel-server.ts: 3-way conditional (edge/node-streams/default)
  • debug-channel-server.node.ts: native Node.js debug channel with Readable/Writable pair

Build infrastructure:

  • taskfile.js: 8 new bundle tasks for node stream variants
  • next-runtime.webpack-config.js: nodeStreams option
  • module.compiled.js: runtime bundle routing

Modified files:

  • entry-base.ts: conditional exports for renderToPipeableStream/prerenderToNodeStream
  • use-flight-response.tsx: createInlinedDataNodeStream Transform, getFlightStream node path

Test plan

  • pnpm --filter=next types passes
  • pnpm test-unit packages/next/src/server/pipe-readable.test.ts passes
  • NEXT_SKIP_ISOLATE=1 pnpm testonly test/unit/app-render/use-flight-response-node-stream.test.ts passes
  • pnpm test-dev-turbo test/e2e/app-dir/use-node-streams-env-precedence/ passes
  • Flag is off by default: existing tests unaffected

@feedthejim feedthejim changed the title feat(node-streams): add node stream primitives, build infra, and config flag feat(node-streams): add primitives, build infra, and config flag Feb 11, 2026
@feedthejim feedthejim force-pushed the feedthejim/node-stream-03-primitives branch from bf6e861 to 5d447ac Compare February 11, 2026 21:41
@feedthejim feedthejim force-pushed the feedthejim/node-stream-02-extract branch from 75234d5 to 22d9b4f Compare February 11, 2026 21:41
@feedthejim feedthejim force-pushed the feedthejim/node-stream-03-primitives branch from 5d447ac to 99fdd5a Compare February 11, 2026 21:46
@feedthejim feedthejim force-pushed the feedthejim/node-stream-02-extract branch from 22d9b4f to 6b4ea2a Compare February 11, 2026 21:46
@feedthejim feedthejim force-pushed the feedthejim/node-stream-03-primitives branch from 99fdd5a to 99bd282 Compare February 11, 2026 21:50
@feedthejim feedthejim force-pushed the feedthejim/node-stream-02-extract branch from 6b4ea2a to 54df7a3 Compare February 11, 2026 21:50
@feedthejim feedthejim force-pushed the feedthejim/node-stream-02-extract branch from 54df7a3 to c5ede11 Compare February 11, 2026 22:34
@feedthejim feedthejim force-pushed the feedthejim/node-stream-03-primitives branch from 99bd282 to 8813630 Compare February 11, 2026 22:34
@feedthejim feedthejim force-pushed the feedthejim/node-stream-02-extract branch from c5ede11 to ac9e39f Compare February 11, 2026 22:45
@feedthejim feedthejim force-pushed the feedthejim/node-stream-03-primitives branch from 8813630 to 4199831 Compare February 11, 2026 22:45
@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Feb 11, 2026

Stats from current PR

🔴 3 regressions

Metric Canary PR Change Trend
node_modules Size 474 MB 574 MB 🔴 +100 MB (+21%) █████
Webpack Build Time 14.140s 14.900s 🔴 +760ms (+5%) ██▁▁▂
Webpack Build Time (cached) 14.344s 15.047s 🔴 +703ms (+5%) ██▁▂▂
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change Trend
Cold (Listen) 914ms 914ms ▁▁██▁
Cold (Ready in log) 910ms 900ms ▆▁▇█▇
Cold (First Request) 1.715s 1.693s ▇▁▅▅█
Warm (Listen) 914ms 914ms ▁▁▅█▁
Warm (Ready in log) 906ms 914ms ▁▁▇█▁
Warm (First Request) 712ms 731ms ▁▃█▇▁
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 455ms 455ms ████▁
Cold (Ready in log) 438ms 438ms ▅▅▆██
Cold (First Request) 1.950s 1.949s ██▃▄▅
Warm (Listen) 456ms 456ms ▅█▁▅▅
Warm (Ready in log) 437ms 439ms ▅▃▅▆▆
Warm (First Request) 1.967s 1.970s █▇▂▃▄

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 6.806s 6.766s ▁▂██▁
Cached Build 6.606s 6.791s ▁▂██▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 14.140s 14.900s 🔴 +760ms (+5%) ██▁▁▂
Cached Build 14.344s 15.047s 🔴 +703ms (+5%) ██▁▂▂
node_modules Size 474 MB 574 MB 🔴 +100 MB (+21%) █████
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **399 kB** → **399 kB** ✅ -23 B

80 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 769 B 763 B
Total 769 B 763 B ✅ -6 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 451 B 450 B
Total 451 B 450 B ✅ -1 B

📦 Webpack

Client

Main Bundles
Canary PR Change
5528-HASH.js gzip 5.48 kB N/A -
6280-HASH.js gzip 57.5 kB N/A -
6335.HASH.js gzip 169 B N/A -
912-HASH.js gzip 4.53 kB N/A -
e8aec2e4-HASH.js gzip 62.6 kB N/A -
framework-HASH.js gzip 59.7 kB 59.7 kB
main-app-HASH.js gzip 255 B 254 B
main-HASH.js gzip 39.1 kB 39.1 kB
webpack-HASH.js gzip 1.68 kB 1.68 kB
262-HASH.js gzip N/A 4.53 kB -
2889.HASH.js gzip N/A 169 B -
5602-HASH.js gzip N/A 5.49 kB -
6948ada0-HASH.js gzip N/A 62.6 kB -
9544-HASH.js gzip N/A 58.3 kB -
Total 231 kB 232 kB ⚠️ +755 B
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB
Total 39.4 kB 39.4 kB
Pages
Canary PR Change
_app-HASH.js gzip 194 B 194 B
_error-HASH.js gzip 183 B 180 B 🟢 3 B (-2%)
css-HASH.js gzip 331 B 330 B
dynamic-HASH.js gzip 1.81 kB 1.81 kB
edge-ssr-HASH.js gzip 256 B 256 B
head-HASH.js gzip 351 B 352 B
hooks-HASH.js gzip 384 B 383 B
image-HASH.js gzip 580 B 581 B
index-HASH.js gzip 260 B 260 B
link-HASH.js gzip 2.5 kB 2.5 kB
routerDirect..HASH.js gzip 320 B 319 B
script-HASH.js gzip 386 B 386 B
withRouter-HASH.js gzip 315 B 315 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.97 kB 7.97 kB ✅ -2 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 125 kB 125 kB
page.js gzip 252 kB 254 kB
Total 377 kB 379 kB ⚠️ +1.77 kB
Middleware
Canary PR Change
middleware-b..fest.js gzip 613 B 611 B
middleware-r..fest.js gzip 156 B 155 B
middleware.js gzip 43.9 kB 43.9 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 45.5 kB 45.5 kB ✅ -37 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 715 B 718 B
Total 715 B 718 B ⚠️ +3 B
Build Cache
Canary PR Change
0.pack gzip 3.98 MB 4 MB 🔴 +19.4 kB (+0%)
index.pack gzip 103 kB 101 kB 🟢 1.23 kB (-1%)
index.pack.old gzip 103 kB 103 kB
Total 4.18 MB 4.2 MB ⚠️ +18.5 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 318 kB 323 kB 🔴 +5.31 kB (+2%)
app-page-exp..prod.js gzip 168 kB 172 kB 🔴 +3.14 kB (+2%)
app-page-tur...dev.js gzip 318 kB 323 kB 🔴 +5.3 kB (+2%)
app-page-tur..prod.js gzip 168 kB 172 kB 🔴 +3.17 kB (+2%)
app-page-tur...dev.js gzip 314 kB 320 kB 🔴 +5.23 kB (+2%)
app-page-tur..prod.js gzip 166 kB 170 kB 🔴 +3.19 kB (+2%)
app-page.run...dev.js gzip 315 kB 320 kB 🔴 +5.25 kB (+2%)
app-page.run..prod.js gzip 167 kB 170 kB 🔴 +3.17 kB (+2%)
app-route-ex...dev.js gzip 70.7 kB 70.7 kB
app-route-ex..prod.js gzip 49.1 kB 49.1 kB
app-route-tu...dev.js gzip 70.7 kB 70.7 kB
app-route-tu..prod.js gzip 49.2 kB 49.2 kB
app-route-tu...dev.js gzip 70.3 kB 70.3 kB
app-route-tu..prod.js gzip 48.9 kB 48.9 kB
app-route.ru...dev.js gzip 70.2 kB 70.2 kB
app-route.ru..prod.js gzip 48.9 kB 48.9 kB
dist_client_...dev.js gzip 324 B 324 B
dist_client_...dev.js gzip 326 B 326 B
dist_client_...dev.js gzip 318 B 318 B
dist_client_...dev.js gzip 317 B 317 B
pages-api-tu...dev.js gzip 43.2 kB 43.2 kB
pages-api-tu..prod.js gzip 32.9 kB 32.9 kB
pages-api.ru...dev.js gzip 43.2 kB 43.2 kB
pages-api.ru..prod.js gzip 32.8 kB 32.8 kB
pages-turbo....dev.js gzip 52.5 kB 52.5 kB
pages-turbo...prod.js gzip 38.5 kB 38.5 kB
pages.runtim...dev.js gzip 52.5 kB 52.5 kB
pages.runtim..prod.js gzip 38.4 kB 38.4 kB
server.runti..prod.js gzip 62 kB 62.1 kB
app-page-exp...dev.js gzip N/A 324 kB -
app-page-exp..prod.js gzip N/A 172 kB -
app-page-nod...dev.js gzip N/A 321 kB -
app-page-nod..prod.js gzip N/A 170 kB -
app-page-tur...dev.js gzip N/A 324 kB -
app-page-tur..prod.js gzip N/A 172 kB -
app-page-tur...dev.js gzip N/A 320 kB -
app-page-tur..prod.js gzip N/A 170 kB -
app-route-ex...dev.js gzip N/A 70.7 kB -
app-route-ex..prod.js gzip N/A 49.2 kB -
app-route-no...dev.js gzip N/A 70.3 kB -
app-route-no..prod.js gzip N/A 48.9 kB -
app-route-tu...dev.js gzip N/A 70.7 kB -
app-route-tu..prod.js gzip N/A 49.2 kB -
app-route-tu...dev.js gzip N/A 70.3 kB -
app-route-tu..prod.js gzip N/A 49 kB -
dist_client_...dev.js gzip N/A 332 B -
dist_client_...dev.js gzip N/A 324 B -
dist_client_...dev.js gzip N/A 334 B -
dist_client_...dev.js gzip N/A 326 B -
Total 2.81 MB 5.3 MB ⚠️ +2.49 MB
📝 Changed Files (37 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-nod..ntime.dev.js
  • app-page-nod..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
  • app-route-ex..ntime.dev.js
  • app-route-ex..time.prod.js
  • app-route-no..ntime.dev.js
  • app-route-no..time.prod.js
  • ... and 17 more
View diffs
app-page-exp..ntime.dev.js
failed to diff
app-page-exp..time.prod.js

Diff too large to display

app-page-exp..ntime.dev.js
failed to diff
app-page-exp..time.prod.js
failed to diff
app-page-nod..ntime.dev.js
failed to diff
app-page-nod..time.prod.js

Diff too large to display

app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js

Diff too large to display

app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js

Diff too large to display

app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page.runtime.dev.js
failed to diff
app-page.runtime.prod.js
failed to diff
app-route-ex..ntime.dev.js

Diff too large to display

app-route-ex..time.prod.js

Diff too large to display

app-route-no..ntime.dev.js

Diff too large to display

app-route-no..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

dist_client_..ntime.dev.js
@@ -0,0 +1,2 @@
+"use strict";exports.ids=["dist_client_dev_noop-turbopack-hmr_js"],exports.modules={"./dist/client/dev/noop-turbopack-hmr.js"(module,exports1){function connect(){}Object.defineProperty(exports1,"__esModule",{value:!0}),Object.defineProperty(exports1,"connect",{enumerable:!0,get:function(){return connect}}),("function"==typeof exports1.default||"object"==typeof exports1.default&&null!==exports1.default)&&void 0===exports1.default.__esModule&&(Object.defineProperty(exports1.default,"__esModule",{value:!0}),Object.assign(exports1.default,exports1),module.exports=exports1.default)}};
+//# sourceMappingURL=dist_client_dev_noop-turbopack-hmr_js-experimental-nodestreams.runtime.dev.js.map
\ No newline at end of file
dist_client_..ntime.dev.js
@@ -0,0 +1,2 @@
+"use strict";exports.ids=["dist_client_dev_noop-turbopack-hmr_js"],exports.modules={"./dist/client/dev/noop-turbopack-hmr.js"(module,exports1){function connect(){}Object.defineProperty(exports1,"__esModule",{value:!0}),Object.defineProperty(exports1,"connect",{enumerable:!0,get:function(){return connect}}),("function"==typeof exports1.default||"object"==typeof exports1.default&&null!==exports1.default)&&void 0===exports1.default.__esModule&&(Object.defineProperty(exports1.default,"__esModule",{value:!0}),Object.assign(exports1.default,exports1),module.exports=exports1.default)}};
+//# sourceMappingURL=dist_client_dev_noop-turbopack-hmr_js-nodestreams.runtime.dev.js.map
\ No newline at end of file
dist_client_..ntime.dev.js
@@ -0,0 +1,2 @@
+"use strict";exports.ids=["dist_client_dev_noop-turbopack-hmr_js"],exports.modules={"./dist/client/dev/noop-turbopack-hmr.js"(module,exports1){function connect(){}Object.defineProperty(exports1,"__esModule",{value:!0}),Object.defineProperty(exports1,"connect",{enumerable:!0,get:function(){return connect}}),("function"==typeof exports1.default||"object"==typeof exports1.default&&null!==exports1.default)&&void 0===exports1.default.__esModule&&(Object.defineProperty(exports1.default,"__esModule",{value:!0}),Object.assign(exports1.default,exports1),module.exports=exports1.default)}};
+//# sourceMappingURL=dist_client_dev_noop-turbopack-hmr_js-turbo-experimental-nodestreams.runtime.dev.js.map
\ No newline at end of file
dist_client_..ntime.dev.js
@@ -0,0 +1,2 @@
+"use strict";exports.ids=["dist_client_dev_noop-turbopack-hmr_js"],exports.modules={"./dist/client/dev/noop-turbopack-hmr.js"(module,exports1){function connect(){}Object.defineProperty(exports1,"__esModule",{value:!0}),Object.defineProperty(exports1,"connect",{enumerable:!0,get:function(){return connect}}),("function"==typeof exports1.default||"object"==typeof exports1.default&&null!==exports1.default)&&void 0===exports1.default.__esModule&&(Object.defineProperty(exports1.default,"__esModule",{value:!0}),Object.assign(exports1.default,exports1),module.exports=exports1.default)}};
+//# sourceMappingURL=dist_client_dev_noop-turbopack-hmr_js-turbo-nodestreams.runtime.dev.js.map
\ No newline at end of file
pages-api-tu..ntime.dev.js

Diff too large to display

pages-api-tu..time.prod.js

Diff too large to display

pages-api.runtime.dev.js

Diff too large to display

pages-api.ru..time.prod.js

Diff too large to display

pages-turbo...ntime.dev.js

Diff too large to display

pages-turbo...time.prod.js

Diff too large to display

pages.runtime.dev.js

Diff too large to display

pages.runtime.prod.js

Diff too large to display

server.runtime.prod.js

Diff too large to display

📎 Tarball URL
next@https://vercel-packages.vercel.app/next/prs/89859/next

@feedthejim feedthejim force-pushed the feedthejim/node-stream-03-primitives branch from 4199831 to 932773c Compare February 12, 2026 00:59
@feedthejim feedthejim changed the title feat(node-streams): add primitives, build infra, and config flag (6/8) feat(node-streams): add primitives, build infra, and config flag Feb 12, 2026
@feedthejim feedthejim changed the title (6/8) feat(node-streams): add primitives, build infra, and config flag feat(node-streams): add primitives, build infra, and config flag (6/8) Feb 12, 2026
): RenderToPipeableStreamOptions['onHeaders'] | undefined {
if (!onHeaders) return undefined
return (headersDescriptor: Headers | HeadersInit) => {
onHeaders(new Headers(headersDescriptor))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we always have to clone this object? Maybe only needed when it's HeadersInit and not when Headers?

const debugChannel = options.debugChannel
const isNodeWritable =
typeof debugChannel === 'object' &&
debugChannel !== null &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of if(options?.debugChannel)that is wrapped above this check is never true.

abort?: (reason?: unknown) => void
}

export async function renderToFizzStream(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can likely be optimized for JIT

Something like this:

export async function renderToFizzStream(
  element: React.ReactElement,
  streamOptions: StreamOptions, // specific type
  runInContext?: (fn: () => any) => any
): Promise<FizzStreamResult> {
  if (runInContext) {
    return runInContext(() => 
      renderToFizzPipeableStream(
        ReactDOMServer.renderToPipeableStream,
        element,
        streamOptions
      )
    )
  }
  return renderToFizzPipeableStream(
    ReactDOMServer.renderToPipeableStream,
    element,
    streamOptions
  )
}

return renderToFizzPipeableStream(renderFn, element, streamOptions)
}

export async function resumeToFizzStream(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to above comment, can likely be optimized for JIT:

export async function resumeToFizzStream(
  element: React.ReactElement,
  postponedState: PostponedState,
  streamOptions: StreamOptions,
  runInContext?: (fn: () => any) => any
): Promise<FizzStreamResult> {
  if (runInContext) {
    return runInContext(() =>
      resumeToFizzPipeableStream(
        ReactDOMServer.resumeToPipeableStream,
        element,
        postponedState,
        streamOptions
      )
    )
  }
  return resumeToFizzPipeableStream(
    ReactDOMServer.resumeToPipeableStream,
    element,
    postponedState,
    streamOptions
  )
}

} else {
// Node's fromWeb() overload expects stream/web.ReadableStream.
// Convert from the global ReadableStream type to satisfy that overload.
nodeDebugStream = Readable.fromWeb(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be dead code

* Tees a Node Readable into two Readables without coupling backpressure
* across branches.
*/
export function teeNodeReadable(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be solved differently via readable streams attaching listeners?

this probably comes because claude san tried to keep the same apis

postponedState: PostponedState,
options?: ResumeToPipeableOptions
): Promise<FizzPipeableStreamResult> {
return getTracer().trace(AppRenderSpan.renderToReadableStream, async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are spans conserved on the node version?

Copy link
Member

@lubieowoce lubieowoce Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't remember how our spans are implemented, but if it uses ALS, probably not!
https://github.com/vercel/next.js/pull/89859/changes#discussion_r2863051317


const originalOnAllReady = options?.onAllReady
// Same onHeaders wrapping as renderToFizzPipeableStream
const wrappedOnHeaders = wrapOnHeaders(options?.onHeaders)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this needed? need to reinvestigate

): Promise<FizzStreamResult> {
const run: <T>(fn: () => T) => T = runInContext ?? ((fn) => fn())
const renderFn = (...args: any[]) =>
run(() => (ReactDOMServer as any).renderToPipeableStream(...args))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wtf

Promise<unknown>
>()
const encoder = new TextEncoder()
const INLINE_FLIGHT_PAYLOAD_SUFFIX = ')</script>'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be done in a separate PR

* Each stream is fully consumed before moving to the next.
*/
export function chainNodeStreams(...streams: Readable[]): Readable {
const { PassThrough: PT } = getNodeStream()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

weird rename

@timneutkens timneutkens force-pushed the feedthejim/node-stream-02-extract branch from 229dd11 to cdc35bb Compare February 19, 2026 10:20
@timneutkens timneutkens force-pushed the feedthejim/node-stream-03-primitives branch from 4ccf817 to 6a196c8 Compare February 19, 2026 10:20
@timneutkens timneutkens force-pushed the feedthejim/node-stream-02-extract branch from cdc35bb to 694434e Compare February 19, 2026 11:07
@timneutkens timneutkens force-pushed the feedthejim/node-stream-03-primitives branch 2 times, most recently from 36ff037 to 35c5f12 Compare February 19, 2026 12:12
@timneutkens timneutkens force-pushed the feedthejim/node-stream-02-extract branch 2 times, most recently from 1cb6828 to f324ca1 Compare February 19, 2026 12:41
@timneutkens timneutkens force-pushed the feedthejim/node-stream-03-primitives branch 3 times, most recently from 05531f7 to 79b103c Compare February 23, 2026 10:02
@timneutkens timneutkens force-pushed the feedthejim/node-stream-02-extract branch from 258bc9f to 488d4c7 Compare February 23, 2026 10:02
Base automatically changed from feedthejim/node-stream-02-extract to canary February 23, 2026 10:45
feedthejim and others added 2 commits February 23, 2026 14:23
…ig flag

Add the building blocks for native Node.js stream rendering:

- Config flag: experimental.useNodeStreams with __NEXT_USE_NODE_STREAMS env var
- Node stream primitives: node-stream-helpers, pipeable-stream-wrappers,
  node-stream-tee, pipe-readable, chain-node-streams
- Stream ops: stream-ops.node.ts with compile-time switcher in stream-ops.ts
- Debug channel: debug-channel-server.node.ts with 3-way conditional switcher
- Build: taskfile.js bundle tasks, webpack config routing, module.compiled.js
- Flight response: createInlinedDataNodeStream for Node Transform encoding
- Entry base: conditional exports for renderToPipeableStream/prerenderToNodeStream
- Tests: pipe-readable, flight response node stream, env precedence e2e
@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Feb 24, 2026

Failing test suites

Commit: 558f424 | About building and testing Next.js

pnpm test-dev test/development/app-dir/ssr-in-rsc/ssr-in-rsc.test.ts (job)

  • react-dom/server in React Server environment > explicit react-dom/server.edge usage in app code (DD)
  • react-dom/server in React Server environment > explicit react-dom/server.edge usage in library code (DD)
Expand output

● react-dom/server in React Server environment › explicit react-dom/server.edge usage in app code

expect(received).toMatchInlineSnapshot(snapshot)

Snapshot name: `react-dom/server in React Server environment explicit react-dom/server.edge usage in app code 1`

- Snapshot  - 0
+ Received  + 4

  "{
    "default": [
+     "renderToPipeableStream",
      "renderToReadableStream",
      "renderToStaticMarkup",
      "renderToString",
      "resume",
+     "resumeToPipeableStream",
      "version"
    ],
    "named": [
      "default",
+     "renderToPipeableStream",
      "renderToReadableStream",
      "renderToStaticMarkup",
      "renderToString",
      "resume",
+     "resumeToPipeableStream",
      "version"
    ]
  }"

  131 |       `)
  132 |     } else {
> 133 |       expect(await browser.elementByCss('main').text()).toMatchInlineSnapshot(`
      |                                                         ^
  134 |         "{
  135 |           "default": [
  136 |             "renderToReadableStream",

  at Object.toMatchInlineSnapshot (development/app-dir/ssr-in-rsc/ssr-in-rsc.test.ts:133:57)

● react-dom/server in React Server environment › explicit react-dom/server.edge usage in library code

expect(received).toMatchInlineSnapshot(snapshot)

Snapshot name: `react-dom/server in React Server environment explicit react-dom/server.edge usage in library code 1`

- Snapshot  - 0
+ Received  + 4

  "{
    "default": {
      "default": [
+       "renderToPipeableStream",
        "renderToReadableStream",
        "renderToStaticMarkup",
        "renderToString",
        "resume",
+       "resumeToPipeableStream",
        "version"
      ],
      "named": [
        "default",
+       "renderToPipeableStream",
        "renderToReadableStream",
        "renderToStaticMarkup",
        "renderToString",
        "resume",
+       "resumeToPipeableStream",
        "version"
      ]
    }
  }"

  478 |       `)
  479 |     } else {
> 480 |       expect(await browser.elementByCss('main').text()).toMatchInlineSnapshot(`
      |                                                         ^
  481 |         "{
  482 |           "default": {
  483 |             "default": [

  at Object.toMatchInlineSnapshot (development/app-dir/ssr-in-rsc/ssr-in-rsc.test.ts:480:57)

pnpm test-start-turbo test/production/next-server-nft/next-server-nft.test.ts (turbopack) (job)

  • next-server-nft > with output:standalone > should not trace too many files in next-server.js.nft.json (DD)
Expand output

● next-server-nft › with output:standalone › should not trace too many files in next-server.js.nft.json

expect(received).toMatchInlineSnapshot(snapshot)

Snapshot name: `next-server-nft with output:standalone should not trace too many files in next-server.js.nft.json 1`

- Snapshot  - 0
+ Received  + 2

@@ -64,11 +64,13 @@
    "/node_modules/next/dist/compiled/is-animated/index.js",
    "/node_modules/next/dist/compiled/is-docker/index.js",
    "/node_modules/next/dist/compiled/is-wsl/index.js",
    "/node_modules/next/dist/compiled/jsonwebtoken/index.js",
    "/node_modules/next/dist/compiled/nanoid/index.cjs",
+   "/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental-nodestreams.runtime.prod.js",
    "/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js",
+   "/node_modules/next/dist/compiled/next-server/app-page-turbo-nodestreams.runtime.prod.js",
    "/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js",
    "/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js",
    "/node_modules/next/dist/compiled/p-limit/index.js",
    "/node_modules/next/dist/compiled/p-queue/index.js",
    "/node_modules/next/dist/compiled/path-browserify/index.js",

  126 |         ]
  127 |
> 128 |         expect(traceGrouped).toMatchInlineSnapshot(`
      |                              ^
  129 |          [
  130 |            "/node_modules/@img/colour/*",
  131 |            "/node_modules/@img/sharp-*/sharp-*.node",

  at Object.toMatchInlineSnapshot (production/next-server-nft/next-server-nft.test.ts:128:30)

})
}
const onSourceEnd = () => {
runInContext(() => {
Copy link
Member

@lubieowoce lubieowoce Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as currently used, this whole runInContext pattern is wrong. it preserves workUnitAsyncStorage (because that's what we provide in the callbacks that are passed in here) but no other ALSes, so e.g. workAsyncStorage would still get lost. We should do this instead, that's what AsyncLocalStorage.bind is for (bindSnapshot is a wrapper for it):

const onSourceData = bindSnapshot((chunk) => ...)

you could also use const runInContext = AsyncLocalStorage.snapshot() to get a correct version of runInContext, but i think that makes the code messier than it needs to be. The caller of the stream op shouldn't need to care about this, AsyncLocalStorage.bind inside is cleaner.

See #90609 for a PR to remove runInContext from current canary. we should do that and rework the bits in here to match.

Copy link
Member

@lubieowoce lubieowoce Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where's this sourced from? summarizing stuff is nice and all but it'd be good to have some references in here in case we want to check if this is still current sometime in the future

lubieowoce added a commit that referenced this pull request Feb 27, 2026
The `runInContext` pattern that's used all over `stream-ops` is weird
and feels very unnecessary -- we're passing in callbacks that just get
invoked immediately, and all they do is call `AsyncLocalStorage.run`. We
should just use the standard `AsyncLocalStorage.run` method outside
instead. I commented about getting rid of it somewhere on the original
node streams PR, but that got lost when it was was split up.

I believe the original reasoning for addding it at all was that some of
the node-streams implementations needed to preserve async context for
callbacks -- see eg
[here](https://github.com/vercel/next.js/blob/558f4248e5e5641d78b865a291c099791460d486/packages/next/src/server/app-render/node-stream-tee.ts#L244).
But if that's the motivation, then the current `runInContext` pattern
**is wrong anyway** -- sure, it preserves `workUnitAsyncStorage`, but
doesn't preserve `workAsyncStorage` and whetever other ALSes may be
present, so it's not semantically correct. If this is the goal, then the
relevant callbacks should use `bindSnapshot` (i.e.
`AsyncLocalStorage.bind`) instead, because it preserves the entire
context, not just one ALS.

(Note that technically the `runInContext` pattern is salvageable if we
just always pass `AsyncLocalStorage.snapshot()`, but then i see no
reason why the caller should have to do that. `AsyncLocalStorage.bind`
is cleaner)

Note that this'll require changes in #89859
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants