diff --git a/packages/next/src/build/create-compiler-aliases.ts b/packages/next/src/build/create-compiler-aliases.ts index 7d3eb4959100c..48a0f0f331428 100644 --- a/packages/next/src/build/create-compiler-aliases.ts +++ b/packages/next/src/build/create-compiler-aliases.ts @@ -274,6 +274,11 @@ export function createRSCAliases( 'react-dom/server.browser$': `next/dist/build/webpack/alias/react-dom-server-browser${bundledReactChannel}.js`, 'react-dom/server.node$': `next/dist/build/webpack/alias/react-dom-server-browser${bundledReactChannel}.js`, // react-server-dom-webpack alias + 'react-server-dom-webpack/client$': `next/dist/compiled/react-server-dom-webpack${bundledReactChannel}/client`, + 'react-server-dom-webpack/client.edge$': `next/dist/compiled/react-server-dom-webpack${bundledReactChannel}/client.edge`, + 'react-server-dom-webpack/client.node$': `next/dist/compiled/react-server-dom-webpack${bundledReactChannel}/client.node`, + 'react-server-dom-webpack/server.edge$': `next/dist/compiled/react-server-dom-webpack${bundledReactChannel}/server.edge`, + 'react-server-dom-webpack/server.node$': `next/dist/compiled/react-server-dom-webpack${bundledReactChannel}/server.node`, ...createRSCRendererAliases(bundledReactChannel), } diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 72cf877ed1de9..3ba181c93e7bd 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -385,7 +385,7 @@ function limitUntrustedHeaderValueForLogs(value: string) { return value.length > 100 ? value.slice(0, 100) + '...' : value } -type ServerModuleMap = Record< +export type ServerModuleMap = Record< string, | { id: string @@ -609,7 +609,7 @@ export async function handleAction({ // TODO-APP: Add streaming support const formData = await req.request.formData() if (isFetchAction) { - bound = await decodeReply(formData, serverModuleMap) + bound = await decodeReply(formData, serverModuleMap) } else { const action = await decodeAction(formData, serverModuleMap) if (typeof action === 'function') { @@ -648,9 +648,9 @@ export async function handleAction({ if (isURLEncodedAction) { const formData = formDataFromSearchQueryString(actionData) - bound = await decodeReply(formData, serverModuleMap) + bound = await decodeReply(formData, serverModuleMap) } else { - bound = await decodeReply(actionData, serverModuleMap) + bound = await decodeReply(actionData, serverModuleMap) } } } else if ( diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 861ef9aa4782a..07049e5b3a750 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -117,6 +117,7 @@ import { isNodeNextRequest } from '../base-http/helpers' import { HeadersAdapter } from '../web/spec-extension/adapters/headers' import { parseParameter } from '../../shared/lib/router/utils/route-regex' import { parseRelativeUrl } from '../../shared/lib/router/utils/parse-relative-url' +import type { Readable } from 'stream' export type GetDynamicParamFromSegment = ( // [slug] / [[slug]] / [...slug] @@ -315,7 +316,7 @@ async function generateFlight( const { componentMod: { tree: loaderTree, - renderToReadableStream, + renderToStream, createDynamicallyTrackedSearchParams, }, getDynamicParamFromSegment, @@ -362,18 +363,35 @@ async function generateFlight( // For app dir, use the bundled version of Flight server renderer (renderToReadableStream) // which contains the subset React. - const flightReadableStream = renderToReadableStream( + const flightReadableStream = renderToStream( options ? [options.actionResult, buildIdFlightDataPair] : buildIdFlightDataPair, ctx.clientReferenceManifest.clientModules, { onError: ctx.flightDataRendererErrorHandler, + // @ts-expect-error This `renderToStream` wraps the `renderToReadableStream` or `renderToPipeableStream` from `react-server-dom-webpack` which doesn't specify a `nonce` prop on either options object. Leaving it in in case some other method is being used here. nonce: ctx.nonce, } ) - return new FlightRenderResult(flightReadableStream) + let resultStream: Readable | ReadableStream + if ( + process.env.NEXT_RUNTIME === 'nodejs' && + !(flightReadableStream instanceof ReadableStream) + ) { + const { PassThrough } = + require('node:stream') as typeof import('node:stream') + resultStream = flightReadableStream.pipe(new PassThrough()) + } else if (!(flightReadableStream instanceof ReadableStream)) { + throw new Error( + 'Invariant. Stream is not a ReadableStream in non-Node.js runtime.' + ) + } else { + resultStream = flightReadableStream + } + + return new FlightRenderResult(resultStream) } type RenderToStreamResult = { @@ -598,13 +616,13 @@ function ReactServerEntrypoint({ clientReferenceManifest, nonce, }: { - reactServerStream: BinaryStreamOf + reactServerStream: Readable | BinaryStreamOf preinitScripts: () => void clientReferenceManifest: NonNullable nonce?: string }): T { preinitScripts() - const response = useFlightStream( + const response = useFlightStream( reactServerStream, clientReferenceManifest, nonce @@ -954,17 +972,29 @@ async function renderToHTMLOrFlightImpl( // We kick off the Flight Request (render) here. It is ok to initiate the render in an arbitrary // place however it is critical that we only construct the Flight Response inside the SSR // render so that directives like preloads are correctly piped through - const serverStream = ComponentMod.renderToReadableStream( + const serverStream = ComponentMod.renderToStream( , clientReferenceManifest.clientModules, { onError: serverComponentsErrorHandler, + // @ts-expect-error This `renderToStream` wraps the `renderToReadableStream` or `renderToPipeableStream` from `react-server-dom-webpack` which doesn't specify a `nonce` prop on either options object. Leaving it in in case some other method is being used here. nonce, } ) - // We are going to consume this render both for SSR and for inlining the flight data - let [renderStream, dataStream] = serverStream.tee() + let renderStream, dataStream + + if ( + process.env.NEXT_RUNTIME === 'nodejs' && + !(serverStream instanceof ReadableStream) + ) { + const { teeReadable } = require('../stream-utils') + ;[renderStream, dataStream] = teeReadable(serverStream) + } else { + // We are going to consume this render both for SSR and for inlining the flight data + // @ts-ignore + ;[renderStream, dataStream] = serverStream.tee() + } const children = ( - } - - // TODO (@Ethan-Arrowood): Remove this when stream utilities support both stream types. - if (!(stream instanceof ReadableStream)) { - throw new Error("Invariant: stream isn't a ReadableStream") - } - const prerenderState = staticGenerationStore.prerenderState if (prerenderState) { /** @@ -1085,6 +1102,7 @@ async function renderToHTMLOrFlightImpl( // It is possible in the set of stream transforms for Dynamic HTML vs Dynamic Data may differ but currently both states // require the same set so we unify the code path here return { + // @ts-ignore stream: await continueDynamicPrerender(stream, { getServerInsertedHTML, }), @@ -1092,7 +1110,20 @@ async function renderToHTMLOrFlightImpl( } else { // We may still be rendering the RSC stream even though the HTML is finished. // We wait for the RSC stream to complete and check again if dynamic was used - const [original, flightSpy] = dataStream.tee() + let original, flightSpy + + if ( + process.env.NEXT_RUNTIME === 'nodejs' && + !(dataStream instanceof ReadableStream) + ) { + const { teeReadable } = require('../stream-utils') + ;[original, flightSpy] = teeReadable(dataStream) + } else { + // We are going to consume this render both for SSR and for inlining the flight data + // @ts-ignore + ;[original, flightSpy] = dataStream.tee() + } + dataStream = original await flightRenderComplete(flightSpy) @@ -1115,6 +1146,7 @@ async function renderToHTMLOrFlightImpl( // It is possible in the set of stream transforms for Dynamic HTML vs Dynamic Data may differ but currently both states // require the same set so we unify the code path here return { + // @ts-ignore stream: await continueDynamicPrerender(stream, { getServerInsertedHTML, }), @@ -1148,7 +1180,14 @@ async function renderToHTMLOrFlightImpl( // We don't actually want to render anything so we just pass a stream // that never resolves. The resume call is going to abort immediately anyway - const foreverStream = new ReadableStream() + let foreverStream + + if (process.env.NEXT_RUNTIME === 'nodejs') { + const { Readable } = require('node:stream') + foreverStream = new Readable() + } else { + foreverStream = new ReadableStream() + } const resumeChildren = ( , clientReferenceManifest.clientModules, { onError: serverComponentsErrorHandler, + // @ts-expect-error This `renderToStream` wraps the `renderToReadableStream` or `renderToPipeableStream` from `react-server-dom-webpack` which doesn't specify a `nonce` prop on either options object. Leaving it in in case some other method is being used here. nonce, } ) + let resultStream2: Readable | ReadableStream + if ( + process.env.NEXT_RUNTIME === 'nodejs' && + !(errorServerStream instanceof ReadableStream) + ) { + const { PassThrough } = + require('node:stream') as typeof import('node:stream') + resultStream2 = errorServerStream.pipe(new PassThrough()) + } else if (!(errorServerStream instanceof ReadableStream)) { + throw new Error('Invariant. Stream is not ReadableStream') + } else { + resultStream2 = errorServerStream + } + try { let fizzStream = await renderToInitialFizzStream({ element: ( - } - - // TODO (@Ethan-Arrowood): Remove this when stream utilities support both stream types. - if (!(fizzStream instanceof ReadableStream)) { - throw new Error("Invariant: stream isn't a ReadableStream") - } - return { // Returning the error that was thrown so it can be used to handle // the response in the caller. @@ -1369,8 +1413,8 @@ async function renderToHTMLOrFlightImpl( polyfills, renderServerInsertedHTML, serverCapturedErrors: [], + tracingMetadata: undefined, basePath: renderOpts.basePath, - tracingMetadata: tracingMetadata, }), serverInsertedHTMLToHead: true, validateRootLayout, diff --git a/packages/next/src/server/app-render/entry-base.ts b/packages/next/src/server/app-render/entry-base.ts index e7160c2a7f221..a5644d22fa957 100644 --- a/packages/next/src/server/app-render/entry-base.ts +++ b/packages/next/src/server/app-render/entry-base.ts @@ -1,10 +1,9 @@ -// eslint-disable-next-line import/no-extraneous-dependencies export { - renderToReadableStream, - decodeReply, + renderToStream, decodeAction, decodeFormState, -} from 'react-server-dom-webpack/server.edge' + decodeReply, +} from './react-server-dom-webpack' import AppRouter from '../../client/components/app-router' import LayoutRouter from '../../client/components/layout-router' 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 f14dfbc97b94b..fd9dd76941dcd 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 from '../render-result' @@ -5,7 +6,7 @@ import RenderResult from '../render-result' * 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) { + constructor(response: string | ReadableStream | Readable) { super(response, { contentType: RSC_CONTENT_TYPE_HEADER, metadata: {} }) } } diff --git a/packages/next/src/server/app-render/react-server-dom-webpack/index.d.ts b/packages/next/src/server/app-render/react-server-dom-webpack/index.d.ts new file mode 100644 index 0000000000000..3350d5ec9ab59 --- /dev/null +++ b/packages/next/src/server/app-render/react-server-dom-webpack/index.d.ts @@ -0,0 +1,26 @@ +import type { Edge } from 'react-server-dom-webpack/server.edge' +import type { Node } from 'react-server-dom-webpack/server.node' +import type { PipeableStream } from 'react-dom/server.node' +import type { + DecodeAction, + DecodeFormState, + DecodeReply, +} from '../../../../types/react-server-dom-webpack' +import type { AppRenderContext } from '../app-render' + +export function renderToStream( + model: any, + webpackMap: AppRenderContext['clientReferenceManifest']['clientModules'], + options?: Edge.RenderToReadableStreamOptions & + Node.RenderToPipeableStreamOptions +): ReadableStream | PipeableStream + +export function decodeReply( + ...args: Parameters> +): ReturnType> +export function decodeAction( + ...args: Parameters> +): ReturnType> +export function decodeFormState( + ...args: Parameters> +): ReturnType> diff --git a/packages/next/src/server/app-render/react-server-dom-webpack/index.js b/packages/next/src/server/app-render/react-server-dom-webpack/index.js new file mode 100644 index 0000000000000..13f12a86c97f1 --- /dev/null +++ b/packages/next/src/server/app-render/react-server-dom-webpack/index.js @@ -0,0 +1,21 @@ +if ( + process.env.NEXT_RUNTIME === 'nodejs' && + process.env.EXPERIMENTAL_NODE_STREAMS_SUPPORT === '1' +) { + const mod = require('./react-server-dom-webpack.node.js'); + module.exports = { + renderToStream: mod.renderToPipeableStream, + decodeReply: mod.decodeReply, + decodeAction: mod.decodeAction, + decodeFormState: mod.decodeFormState, + // decodeReplyFromBusboy: mod.decodeReplyFromBusboy, + } +} else { + const mod = require('./react-server-dom-webpack.edge.js'); + module.exports = { + renderToStream: mod.renderToReadableStream, + decodeReply: mod.decodeReply, + decodeAction: mod.decodeAction, + decodeFormState: mod.decodeFormState, + } +} diff --git a/packages/next/src/server/app-render/react-server-dom-webpack/react-server-dom-webpack.edge.ts b/packages/next/src/server/app-render/react-server-dom-webpack/react-server-dom-webpack.edge.ts new file mode 100644 index 0000000000000..3975cb700ecdb --- /dev/null +++ b/packages/next/src/server/app-render/react-server-dom-webpack/react-server-dom-webpack.edge.ts @@ -0,0 +1,7 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +export { + renderToReadableStream, + decodeReply, + decodeAction, + decodeFormState, +} from 'react-server-dom-webpack/server.edge' diff --git a/packages/next/src/server/app-render/react-server-dom-webpack/react-server-dom-webpack.node.ts b/packages/next/src/server/app-render/react-server-dom-webpack/react-server-dom-webpack.node.ts new file mode 100644 index 0000000000000..27dbf8797e24b --- /dev/null +++ b/packages/next/src/server/app-render/react-server-dom-webpack/react-server-dom-webpack.node.ts @@ -0,0 +1,8 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +export { + renderToPipeableStream, + decodeReply, + decodeAction, + decodeFormState, + // decodeReplyFromBusboy, +} from 'react-server-dom-webpack/server.node' diff --git a/packages/next/src/server/app-render/render-to-string.tsx b/packages/next/src/server/app-render/render-to-string.tsx deleted file mode 100644 index 8fd3e17eccb0d..0000000000000 --- a/packages/next/src/server/app-render/render-to-string.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { streamToString } from '../stream-utils' -import { AppRenderSpan } from '../lib/trace/constants' -import { getTracer } from '../lib/trace/tracer' - -export async function renderToString({ - ReactDOMServer, - element, -}: { - ReactDOMServer: typeof import('react-dom/server.edge') - element: React.ReactElement -}) { - return getTracer().trace(AppRenderSpan.renderToString, async () => { - const renderStream = await ReactDOMServer.renderToReadableStream(element) - await renderStream.allReady - return streamToString(renderStream) - }) -} diff --git a/packages/next/src/server/app-render/use-flight-response/index.d.ts b/packages/next/src/server/app-render/use-flight-response/index.d.ts new file mode 100644 index 0000000000000..0ec228e177b3c --- /dev/null +++ b/packages/next/src/server/app-render/use-flight-response/index.d.ts @@ -0,0 +1,19 @@ +import type { Readable } from 'node:stream' +import type { DeepReadonly } from '../../../shared/lib/deep-readonly' +import type { ClientReferenceManifest } from '../../../build/webpack/plugins/flight-manifest-plugin' + +export function useFlightStream( + flightStream: Readable | ReadableStream, + clientReferenceManifest: DeepReadonly, + nonce?: string +): Promise + +export function flightRenderComplete( + flightStream: Readable | ReadableStream +): Promise + +export function createInlinedDataReadableStream( + flightStream: Readable | ReadableStream, + nonce: string | undefined, + formState: unknown | null +): Readable | ReadableStream diff --git a/packages/next/src/server/app-render/use-flight-response/index.js b/packages/next/src/server/app-render/use-flight-response/index.js new file mode 100644 index 0000000000000..2fd6fc6598200 --- /dev/null +++ b/packages/next/src/server/app-render/use-flight-response/index.js @@ -0,0 +1,8 @@ +if ( + process.env.NEXT_RUNTIME === 'nodejs' && + process.env.EXPERIMENTAL_NODE_STREAMS_SUPPORT === '1' +) { + module.exports = require('./use-flight-response.node.js') +} else { + module.exports = require('./use-flight-response.edge.js') +} diff --git a/packages/next/src/server/app-render/use-flight-response.tsx b/packages/next/src/server/app-render/use-flight-response/use-flight-response.edge.ts similarity index 91% rename from packages/next/src/server/app-render/use-flight-response.tsx rename to packages/next/src/server/app-render/use-flight-response/use-flight-response.edge.ts index 86831a58bd550..5792b247a46a4 100644 --- a/packages/next/src/server/app-render/use-flight-response.tsx +++ b/packages/next/src/server/app-render/use-flight-response/use-flight-response.edge.ts @@ -1,8 +1,9 @@ -import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight-manifest-plugin' -import type { BinaryStreamOf } from './app-render' +import type { ClientReferenceManifest } from '../../../build/webpack/plugins/flight-manifest-plugin' +import type { BinaryStreamOf } from '../app-render' -import { htmlEscapeJsonString } from '../htmlescape' -import type { DeepReadonly } from '../../shared/lib/deep-readonly' +import { htmlEscapeJsonString } from '../../htmlescape' +import type { DeepReadonly } from '../../../shared/lib/deep-readonly' +import type { Readable } from 'node:stream' const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' @@ -11,7 +12,10 @@ const INLINE_FLIGHT_PAYLOAD_DATA = 1 const INLINE_FLIGHT_PAYLOAD_FORM_STATE = 2 const INLINE_FLIGHT_PAYLOAD_BINARY = 3 -const flightResponses = new WeakMap, Promise>() +const flightResponses = new WeakMap< + Readable | BinaryStreamOf, + Promise +>() const encoder = new TextEncoder() /** @@ -30,19 +34,19 @@ export function useFlightStream( } // react-server-dom-webpack/client.edge must not be hoisted for require cache clearing to work correctly - let createFromReadableStream + let createFromStream // @TODO: investigate why the aliasing for turbopack doesn't pick this up, requiring this runtime check if (process.env.TURBOPACK) { - createFromReadableStream = + createFromStream = // eslint-disable-next-line import/no-extraneous-dependencies require('react-server-dom-turbopack/client.edge').createFromReadableStream } else { - createFromReadableStream = + createFromStream = // eslint-disable-next-line import/no-extraneous-dependencies require('react-server-dom-webpack/client.edge').createFromReadableStream } - const newResponse = createFromReadableStream(flightStream, { + const newResponse = createFromStream(flightStream, { ssrManifest: { moduleLoading: clientReferenceManifest.moduleLoading, moduleMap: isEdgeRuntime diff --git a/packages/next/src/server/app-render/use-flight-response/use-flight-response.node.ts b/packages/next/src/server/app-render/use-flight-response/use-flight-response.node.ts new file mode 100644 index 0000000000000..e9d1352326aa5 --- /dev/null +++ b/packages/next/src/server/app-render/use-flight-response/use-flight-response.node.ts @@ -0,0 +1,155 @@ +import { Readable } from 'node:stream' +import type { ClientReferenceManifest } from '../../../build/webpack/plugins/flight-manifest-plugin' +import type { DeepReadonly } from '../../../shared/lib/deep-readonly' +import { htmlEscapeJsonString } from '../../htmlescape' +import isError from '../../../lib/is-error' + +const flightResponses = new WeakMap>() +const encoder = new TextEncoder() + +export function useFlightStream( + flightStream: Readable, + clientReferenceManifest: DeepReadonly, + nonce?: string +): Promise { + const response = flightResponses.get(flightStream) + + if (response) { + return response + } + + // react-server-dom-webpack/client.edge must not be hoisted for require cache clearing to work correctly + let createFromStream + // @TODO: investigate why the aliasing for turbopack doesn't pick this up, requiring this runtime check + if (process.env.TURBOPACK) { + createFromStream = + // eslint-disable-next-line import/no-extraneous-dependencies + require('react-server-dom-turbopack/client.node').createFromNodeStream + } else { + createFromStream = + // eslint-disable-next-line import/no-extraneous-dependencies + require('react-server-dom-webpack/client.node').createFromNodeStream + } + + const newResponse = createFromStream( + flightStream, + { + moduleLoading: clientReferenceManifest.moduleLoading, + moduleMap: clientReferenceManifest.ssrModuleMapping, + }, + { + nonce, + } + ) + + flightResponses.set(flightStream, newResponse) + + return newResponse +} + +export function flightRenderComplete(flightStream: Readable): Promise { + return new Promise((resolve, reject) => { + flightStream + .resume() + .on('end', () => { + resolve() + }) + .on('error', (error) => { + reject(error) + }) + }) +} + +const INLINE_FLIGHT_PAYLOAD_BOOTSTRAP = 0 +const INLINE_FLIGHT_PAYLOAD_DATA = 1 +const INLINE_FLIGHT_PAYLOAD_FORM_STATE = 2 +const INLINE_FLIGHT_PAYLOAD_BINARY = 3 + +export function createInlinedDataReadableStream( + flightStream: Readable, + nonce: string | undefined, + formState: unknown | null +): Readable { + const startScriptTag = nonce + ? `` + ) + this.push(chunk) + return callback(null) + } catch (error) { + return isError(error) ? callback(error) : callback() + } + }, + read() { + try { + const chunk = flightStream.read() + if (chunk) { + try { + const decodedString = decoder.decode(chunk, { stream: true }) + writeFlightDataInstruction(this, startScriptTag, decodedString) + } catch { + writeFlightDataInstruction(this, startScriptTag, chunk) + } + } else { + try { + const decodedString = decoder.decode() + if (decodedString) { + writeFlightDataInstruction(this, startScriptTag, decodedString) + } + } catch {} + + this.push(null) + } + } catch (error) { + if (isError(error)) { + this.destroy(error) + } + } + }, + }) +} + +function writeFlightDataInstruction( + readable: Readable, + scriptStart: string, + chunk: string | Uint8Array +) { + let htmlInlinedData: string + + if (typeof chunk === 'string') { + htmlInlinedData = htmlEscapeJsonString( + JSON.stringify([INLINE_FLIGHT_PAYLOAD_DATA, chunk]) + ) + } else { + // The chunk cannot be embedded as a UTF-8 string in the script tag. + // Instead let's inline it in base64. + // Credits to Devon Govett (devongovett) for the technique. + // https://github.com/devongovett/rsc-html-stream + const base64 = btoa(String.fromCodePoint(...chunk)) + htmlInlinedData = htmlEscapeJsonString( + JSON.stringify([INLINE_FLIGHT_PAYLOAD_BINARY, base64]) + ) + } + + readable.push( + encoder.encode( + `${scriptStart}self.__next_f.push(${htmlInlinedData})` + ) + ) +} diff --git a/packages/next/src/server/pipe-readable.ts b/packages/next/src/server/pipe-readable.ts index 2722c0c474cb8..83347d3935a0d 100644 --- a/packages/next/src/server/pipe-readable.ts +++ b/packages/next/src/server/pipe-readable.ts @@ -8,6 +8,7 @@ import { DetachedPromise } from '../lib/detached-promise' import { getTracer } from './lib/trace/tracer' import { NextNodeServerSpan } from './lib/trace/constants' import { getClientComponentLoaderMetrics } from './client-component-renderer-logger' +import type { Readable } from 'node:stream' export function isAbortError(e: any): e is Error & { name: 'AbortError' } { return e?.name === 'AbortError' || e?.name === ResponseAbortedName @@ -121,7 +122,7 @@ function createWriterFromResponse( } export async function pipeToNodeResponse( - readable: ReadableStream, + readable: Readable | ReadableStream, res: ServerResponse, waitUntilForEnd?: Promise ) { @@ -130,13 +131,15 @@ export async function pipeToNodeResponse( const { errored, destroyed } = res if (errored || destroyed) return - // Create a new AbortController so that we can abort the readable if the - // client disconnects. - const controller = createAbortController(res) - - const writer = createWriterFromResponse(res, waitUntilForEnd) - - await readable.pipeTo(writer, { signal: controller.signal }) + if (readable instanceof ReadableStream) { + // Create a new AbortController so that we can abort the readable if the + // client disconnects. + const controller = createAbortController(res) + const writer = createWriterFromResponse(res, waitUntilForEnd) + await readable.pipeTo(writer, { signal: controller.signal }) + } else { + readable.pipe(res) + } } catch (err: any) { // If this isn't related to an abort error, re-throw it. if (isAbortError(err)) return diff --git a/packages/next/src/server/render-result.ts b/packages/next/src/server/render-result.ts index 1acdfeef6c1d5..de23cedd20c92 100644 --- a/packages/next/src/server/render-result.ts +++ b/packages/next/src/server/render-result.ts @@ -10,6 +10,7 @@ import { streamToString, } from './stream-utils' import { isAbortError, pipeToNodeResponse } from './pipe-readable' +import type { Readable, Writable } from 'stream' type ContentTypeOption = string | undefined @@ -49,6 +50,8 @@ export type RenderResultMetadata = AppPageRenderResultMetadata & StaticRenderResultMetadata export type RenderResultResponse = + | Readable[] + | Readable | ReadableStream[] | ReadableStream | string @@ -179,7 +182,7 @@ export default class RenderResult< * Returns the response if it is a stream, or throws an error if it is a * string. */ - private get readable(): ReadableStream { + private get readable(): Readable | ReadableStream { if (this.response === null) { throw new Error('Invariant: null responses cannot be streamed') } @@ -207,24 +210,36 @@ export default class RenderResult< * * @param readable The new stream to chain */ - public chain(readable: ReadableStream) { + public chain(readable: Readable | ReadableStream) { if (this.response === null) { throw new Error('Invariant: response is null. This is a bug in Next.js') } // If the response is not an array of streams already, make it one. - let responses: ReadableStream[] + let responses: ReadableStream[] | Readable[] if (typeof this.response === 'string') { + // @ts-ignore responses = [streamFromString(this.response)] } else if (Array.isArray(this.response)) { responses = this.response } else if (Buffer.isBuffer(this.response)) { responses = [streamFromBuffer(this.response)] } else { + // @ts-ignore responses = [this.response] } + if ( + (responses[0] instanceof ReadableStream && + !(readable instanceof ReadableStream)) || + (!(responses[0] instanceof ReadableStream) && + readable instanceof ReadableStream) + ) { + throw new Error('Invariant. Cannot mix stream types') + } + // Add the new stream to the array. + // @ts-ignore responses.push(readable) // Update the response. @@ -238,37 +253,67 @@ export default class RenderResult< * * @param writable Writable stream to pipe the response to */ - public async pipeTo(writable: WritableStream): Promise { - try { - await this.readable.pipeTo(writable, { - // We want to close the writable stream ourselves so that we can wait - // for the waitUntil promise to resolve before closing it. If an error - // is encountered, we'll abort the writable stream if we swallowed the - // error. - preventClose: true, - }) - - // If there is a waitUntil promise, wait for it to resolve before - // closing the writable stream. - if (this.waitUntil) await this.waitUntil - - // Close the writable stream. - await writable.close() - } catch (err) { - // If this is an abort error, we should abort the writable stream (as we - // took ownership of it when we started piping). We don't need to re-throw - // because we handled the error. - if (isAbortError(err)) { - // Abort the writable stream if an error is encountered. - await writable.abort(err) - - return + public async pipeTo( + writable: Writable | WritableStream + ): Promise { + const writableInstanceOfWritableStream = writable instanceof WritableStream + const readableInstanceOfReadableStream = + this.readable instanceof ReadableStream + + if (writableInstanceOfWritableStream && readableInstanceOfReadableStream) { + try { + // @ts-ignore + await this.readable.pipeTo(writable, { + // We want to close the writable stream ourselves so that we can wait + // for the waitUntil promise to resolve before closing it. If an error + // is encountered, we'll abort the writable stream if we swallowed the + // error. + preventClose: true, + }) + + // If there is a waitUntil promise, wait for it to resolve before + // closing the writable stream. + if (this.waitUntil) await this.waitUntil + + // Close the writable stream. + // @ts-ignore + await writable.close() + } catch (err) { + // If this is an abort error, we should abort the writable stream (as we + // took ownership of it when we started piping). We don't need to re-throw + // because we handled the error. + if (isAbortError(err)) { + // Abort the writable stream if an error is encountered. + // @ts-ignore + await writable.abort(err) + + return + } + + // We're not aborting the writer here as when this method throws it's not + // clear as to how so the caller should assume it's their responsibility + // to clean up the writer. + throw err + } + } else if ( + process.env.NEXT_RUNTIME === 'nodejs' && + !readableInstanceOfReadableStream && + !writableInstanceOfWritableStream + ) { + const { Writable } = + require('node:stream') as typeof import('node:stream') + // at least we know `this.readable` is a Readable + if (writable instanceof WritableStream) { + writable = Writable.fromWeb(writable) } - // We're not aborting the writer here as when this method throws it's not - // clear as to how so the caller should assume it's their responsibility - // to clean up the writer. - throw err + this.readable.pipe(writable, { end: false }) + if (this.waitUntil) await this.waitUntil + writable.end() + } else { + throw new Error( + `Invariant. Mistmatching stream types. Readable ${readableInstanceOfReadableStream ? 'is' : 'is not'} an instance of ReadableStream. Writable ${writableInstanceOfWritableStream ? 'is' : 'is not'} an instance of WritableStream.` + ) } } diff --git a/packages/next/src/server/route-modules/app-page/vendored/ssr/entrypoints.ts b/packages/next/src/server/route-modules/app-page/vendored/ssr/entrypoints.ts index b295d2d26257a..37639dc7e453a 100644 --- a/packages/next/src/server/route-modules/app-page/vendored/ssr/entrypoints.ts +++ b/packages/next/src/server/route-modules/app-page/vendored/ssr/entrypoints.ts @@ -13,6 +13,8 @@ function getAltProxyForBindingsDEV( pkg: | 'react-server-dom-turbopack/client.edge' | 'react-server-dom-webpack/client.edge' + | 'react-server-dom-turbopack/client.node' + | 'react-server-dom-webpack/client.node' ) { if (process.env.NODE_ENV === 'development') { const altType = type === 'Turbopack' ? 'Webpack' : 'Turbopack' @@ -31,24 +33,39 @@ function getAltProxyForBindingsDEV( } } -let ReactServerDOMTurbopackClientEdge, ReactServerDOMWebpackClientEdge +let ReactServerDOMTurbopackClientEdge, + ReactServerDOMWebpackClientEdge, + ReactServerDOMTurbopackClientNode, + ReactServerDOMWebpackClientNode if (process.env.TURBOPACK) { // eslint-disable-next-line import/no-extraneous-dependencies ReactServerDOMTurbopackClientEdge = require('react-server-dom-turbopack/client.edge') + // eslint-disable-next-line import/no-extraneous-dependencies + ReactServerDOMTurbopackClientNode = require('react-server-dom-turbopack/client.node') if (process.env.NODE_ENV === 'development') { ReactServerDOMWebpackClientEdge = getAltProxyForBindingsDEV( 'Turbopack', 'react-server-dom-turbopack/client.edge' ) + ReactServerDOMWebpackClientNode = getAltProxyForBindingsDEV( + 'Turbopack', + 'react-server-dom-turbopack/client.node' + ) } } else { // eslint-disable-next-line import/no-extraneous-dependencies ReactServerDOMWebpackClientEdge = require('react-server-dom-webpack/client.edge') + // eslint-disable-next-line import/no-extraneous-dependencies + ReactServerDOMWebpackClientNode = require('react-server-dom-webpack/client.node') if (process.env.NODE_ENV === 'development') { ReactServerDOMTurbopackClientEdge = getAltProxyForBindingsDEV( 'Webpack', 'react-server-dom-webpack/client.edge' ) + ReactServerDOMTurbopackClientNode = getAltProxyForBindingsDEV( + 'Webpack', + 'react-server-dom-webpack/client.node' + ) } } @@ -60,5 +77,7 @@ export { ReactDOM, ReactDOMServerEdge, ReactServerDOMTurbopackClientEdge, + ReactServerDOMTurbopackClientNode, ReactServerDOMWebpackClientEdge, + ReactServerDOMWebpackClientNode, } diff --git a/packages/next/src/server/stream-utils/index.d.ts b/packages/next/src/server/stream-utils/index.d.ts index 3b3b1b4d8da5a..174299fc1cc40 100644 --- a/packages/next/src/server/stream-utils/index.d.ts +++ b/packages/next/src/server/stream-utils/index.d.ts @@ -16,5 +16,133 @@ export function renderToInitialFizzStream( export function renderToString(element: React.ReactElement): Promise export function streamToString( - stream: Readable | ReadableStream + stream: Readable | ReadableStream ): Promise + +export function streamFromString( + string: string +): Readable | ReadableStream + +export function streamToBuffer( + stream: Readable | ReadableStream +): Promise + +export function chainStreams( + ...streams: ReadableStream[] | Readable[] +): ReadableStream | Readable + +export function continueFizzStream( + stream: Readable, + options: { + inlinedDataStream?: Readable + isStaticGeneration: boolean + getServerInsertedHTML?: () => Promise + serverInsertedHTMLToHead: boolean + validateRootLayout?: boolean + suffix?: string + } +): Promise +export function continueFizzStream( + stream: ReadableStream, + options: { + inlinedDataStream?: ReadableStream + isStaticGeneration: boolean + getServerInsertedHTML?: () => Promise + serverInsertedHTMLToHead: boolean + validateRootLayout?: boolean + suffix?: string + } +): Promise> +export function continueFizzStream( + stream: Readable | ReadableStream, + options: { + inlinedDataStream?: Readable | ReadableStream + isStaticGeneration: boolean + getServerInsertedHTML?: () => Promise + serverInsertedHTMLToHead: boolean + validateRootLayout?: boolean + suffix?: string + } +): Promise> + +export function continueDynamicPrerender( + prerenderStream: Readable, + options: { + getServerInsertedHTML: () => Promise + } +): Promise +export function continueDynamicPrerender( + prerenderStream: ReadableStream, + options: { + getServerInsertedHTML: () => Promise + } +): Promise> +export function continueDynamicPrerender( + prerenderStream: ReadableStream, + options: { + getServerInsertedHTML: () => Promise + } +): Promise> + +export function continueStaticPrerender( + prerenderStream: Readable, + option: { + inlinedDataStream: Readable + getServerInsertedHTML: () => Promise + } +): Promise +export function continueStaticPrerender( + prerenderStream: ReadableStream, + option: { + inlinedDataStream: ReadableStream + getServerInsertedHTML: () => Promise + } +): Promise> +export function continueStaticPrerender( + prerenderStream: Readable | ReadableStream, + option: { + inlinedDataStream: Readable | ReadableStream + getServerInsertedHTML: () => Promise + } +): Promise> + +export function continueDynamicHTMLResume( + renderStream: Readable, + option: { + inlinedDataStream: Readable + getServerInsertedHTML: () => Promise + } +): Promise +export function continueDynamicHTMLResume( + renderStream: ReadableStream, + option: { + inlinedDataStream: ReadableStream + getServerInsertedHTML: () => Promise + } +): Promise> +export function continueDynamicHTMLResume( + renderStream: Readable | ReadableStream, + option: { + inlinedDataStream: Readable | ReadableStream + getServerInsertedHTML: () => Promise + } +): Promise> + +export function continueDynamicDataResume( + renderStream: Readable, + option: { + inlinedDataStream: Readable + } +): Promise +export function continueDynamicDataResume( + renderStream: ReadableStream, + option: { + inlinedDataStream: ReadableStream + } +): Promise> +export function continueDynamicDataResume( + renderStream: Readable | ReadableStream, + option: { + inlinedDataStream: Readable | ReadableStream + } +): Promise> diff --git a/packages/next/src/server/stream-utils/stream-utils.edge.test.tsx b/packages/next/src/server/stream-utils/stream-utils.edge.test.tsx new file mode 100644 index 0000000000000..3a88567e411db --- /dev/null +++ b/packages/next/src/server/stream-utils/stream-utils.edge.test.tsx @@ -0,0 +1,45 @@ +import { createBufferedTransformStream } from './stream-utils.edge' +import { renderToReadableStream } from 'react-dom/server.edge' +import { Suspense } from 'react' + +function App() { + const Data = async () => { + const data = await Promise.resolve('1') + return

{data}

+ } + + return ( + + + My App + + +

Hello, World!

+ Fallback}> + + + + + ) +} + +async function createInput(app = ): Promise> { + const stream = await renderToReadableStream(app) + await stream.allReady + return stream +} + +describe('createBufferedTransformStream', () => { + it('should return a TransformStream that buffers input chunks across rendering boundaries', async () => { + const input = await createInput() + const actualStream = input.pipeThrough(createBufferedTransformStream()) + + const actualChunks = [] + // @ts-expect-error + for await (const chunks of actualStream) { + actualChunks.push(chunks) + } + + expect(actualChunks.length).toBe(1) + }) +}) diff --git a/packages/next/src/server/stream-utils/stream-utils.edge.ts b/packages/next/src/server/stream-utils/stream-utils.edge.ts index cdf2e58f4e878..3facf0972805f 100644 --- a/packages/next/src/server/stream-utils/stream-utils.edge.ts +++ b/packages/next/src/server/stream-utils/stream-utils.edge.ts @@ -328,7 +328,7 @@ function createDeferredSuffixStream( // Merge two streams into one. Ensure the final transform stream is closed // when both are finished. -function createMergedTransformStream( +export function createMergedTransformStream( stream: ReadableStream ): TransformStream { let pull: Promise | null = null diff --git a/packages/next/src/server/stream-utils/stream-utils.node.test.tsx b/packages/next/src/server/stream-utils/stream-utils.node.test.tsx new file mode 100644 index 0000000000000..3c0e4106806ed --- /dev/null +++ b/packages/next/src/server/stream-utils/stream-utils.node.test.tsx @@ -0,0 +1,85 @@ +import { + continueFizzStream, + createBufferedTransformStream, +} from './stream-utils.node' +import { PassThrough } from 'node:stream' +import { renderToPipeableStream } from 'react-dom/server.node' +import { Suspense } from 'react' + +function App() { + const Data = async () => { + const data = await Promise.resolve('1') + return

{data}

+ } + + return ( + + + My App + + +

Hello, World!

+ Fallback}> + + + + + ) +} + +function createInput(app = ): Promise { + return new Promise((resolve, reject) => { + const { pipe } = renderToPipeableStream(app, { + onAllReady() { + resolve(pipe(new PassThrough())) + }, + onShellError(error) { + reject(error) + }, + }) + }) +} + +describe('createBufferedTransformStream', () => { + it('should return a TransformStream that buffers input chunks across rendering boundaries', (done) => { + createInput().then((input) => { + const stream = input.pipe(createBufferedTransformStream()) + const actualChunks = [] + stream.on('data', (chunk) => { + actualChunks.push(chunk) + }) + + stream.resume() + + stream.on('end', () => { + expect(actualChunks.length).toBe(1) + done() + }) + }) + }) +}) + +describe('continueFizzStream', () => { + it.only('should passthrough render stream and buffered transform stream', (done) => { + createInput().then((input) => { + continueFizzStream(input, { + isStaticGeneration: false, + serverInsertedHTMLToHead: false, + }).then((stream) => { + const actualChunks: Uint8Array[] = [] + stream.on('data', (chunk) => { + actualChunks.push(chunk) + }) + + stream.resume() + + stream.on('end', () => { + console.log('ended') + expect(actualChunks.length).toBe(2) + console.log(actualChunks[0].toString()) + done() + }) + }) + }) + }) +}) diff --git a/packages/next/src/server/stream-utils/stream-utils.node.ts b/packages/next/src/server/stream-utils/stream-utils.node.ts index d3ee4e9d0fc33..b89fbd660a64a 100644 --- a/packages/next/src/server/stream-utils/stream-utils.node.ts +++ b/packages/next/src/server/stream-utils/stream-utils.node.ts @@ -2,9 +2,25 @@ * By default, this file exports the methods from streams-utils.edge since all of those are based on Node.js web streams. * This file will then be an incremental re-implementation of all of those methods into Node.js only versions (based on proper Node.js Streams). */ - -import { PassThrough, type Readable, Writable } from 'node:stream' -import type { Options as RenderToPipeableStreamOptions } from 'react-dom/server.node' +import type { Stream } from 'node:stream' +import { + PassThrough, + Readable, + Transform, + Writable, + pipeline, +} from 'node:stream' +import type { + PipeableStream, + Options as RenderToPipeableStreamOptions, +} from 'react-dom/server.node' +import isError from '../../lib/is-error' +import { + indexOfUint8Array, + isEquivalentUint8Arrays, + removeFromUint8Array, +} from './uint8array-helpers' +import { ENCODED_TAGS } from './encodedTags' export * from './stream-utils.edge' @@ -21,9 +37,7 @@ export async function renderToInitialFizzStream({ const { pipe } = ReactDOMServer.renderToPipeableStream(element, { ...streamOptions, onShellReady() { - const passThrough = new PassThrough() - pipe(passThrough) - resolve(passThrough) + resolve(pipe(new PassThrough())) }, onShellError(error) { reject(error) @@ -76,3 +90,431 @@ export async function streamToString(stream: Readable) { return string } + +export async function streamToBuffer(stream: Readable): Promise { + const buffers: Buffer[] = [] + + for await (const chunk of stream) { + buffers.push(chunk) + } + + return Buffer.concat(buffers) +} + +export function chainStreams(...streams: Readable[]): Readable { + if (streams.length === 0) { + throw new Error('Invariant: chainStreams requires at least one stream') + } + if (streams.length === 1) { + return streams[0] + } + + const pt = new PassThrough() + + for (const stream of streams) { + stream.pipe(pt, { end: false }) + } + + pt.end() + + return pt +} + +export function streamFromString(string: string): Readable { + return Readable.from(string) +} + +/** + * This utility function buffers all of the chunks it receives from the input + * during a single "macro-task". The transform function schedules a + * `setImmediate` callback that will push the buffered chunks to the readable. + * The transform also ensures not to execute the final callback too early. The + * overall timing of this utility is very specific and must match that of the + * edge based version. + */ +export function createBufferedTransformStream(): Transform { + let bufferedChunks: Uint8Array[] = [] + let bufferedChunksByteLength = 0 + let pending = false + + return new Transform({ + transform(chunk, _, callback) { + bufferedChunks.push(chunk) + bufferedChunksByteLength += chunk.byteLength + + if (pending) return callback() + + pending = true + + setImmediate(() => { + try { + const bufferedChunk = new Uint8Array(bufferedChunksByteLength) + let copiedBytes = 0 + for (let i = 0; i < bufferedChunks.length; i++) { + bufferedChunk.set(bufferedChunks[i], copiedBytes) + copiedBytes += bufferedChunks[i].byteLength + } + bufferedChunks.length = 0 + bufferedChunksByteLength = 0 + callback(null, bufferedChunk) + } catch (err: unknown) { + if (isError(err)) callback(err) + } finally { + pending = false + } + }) + }, + flush(callback) { + if (!pending) return callback() + + process.nextTick(() => { + callback() + }) + }, + }) +} + +const encoder = new TextEncoder() + +function createInsertedHTMLStream( + getServerInsertedHTML: () => Promise +): Transform { + return new Transform({ + transform(chunk, _, callback) { + getServerInsertedHTML() + .then((html) => { + if (html) { + this.push(encoder.encode(html)) + } + + return callback(null, chunk) + }) + .catch((err) => { + return callback(err) + }) + }, + }) +} + +function createHeadInsertionTransformStream( + insert: () => Promise +): Transform { + let inserted = false + let freezing = false + let hasBytes = false + return new Transform({ + transform(chunk, _, callback) { + hasBytes = true + if (freezing) { + return callback(null, chunk) + } + insert() + .then((insertion) => { + if (inserted) { + if (insertion) { + this.push(encoder.encode(insertion)) + } + this.push(chunk) + freezing = true + } else { + const index = indexOfUint8Array(chunk, ENCODED_TAGS.CLOSED.HEAD) + if (index !== -1) { + if (insertion) { + const encodedInsertion = encoder.encode(insertion) + const insertedHeadContent = new Uint8Array( + chunk.length + encodedInsertion.length + ) + insertedHeadContent.set(chunk.slice(0, index)) + insertedHeadContent.set(encodedInsertion, index) + insertedHeadContent.set( + chunk.slice(index), + index + encodedInsertion.length + ) + this.push(insertedHeadContent) + } else { + this.push(chunk) + } + freezing = true + inserted = true + } + } + + if (!inserted) { + this.push(chunk) + } else { + setImmediate(() => { + freezing = false + }) + } + + return callback() + }) + .catch((err) => { + return callback(err) + }) + }, + flush(callback) { + if (hasBytes) { + return insert() + .then((insertion) => { + return callback(null, insertion && encoder.encode(insertion)) + }) + .catch((err) => { + return callback(err) + }) + } + + return callback() + }, + }) +} + +function createDeferredSuffixStream(suffix: string): Transform { + let flushed = false + let pending = false + + return new Transform({ + transform(chunk, _, callback) { + this.push(chunk) + + if (flushed) return callback() + + flushed = true + pending = true + setImmediate(() => { + try { + this.push(encoder.encode(suffix)) + } catch { + } finally { + pending = false + return callback() + } + }) + }, + flush(callback) { + if (pending || flushed) return callback() + return callback(null, encoder.encode(suffix)) + }, + }) +} + +function createMoveSuffixStream(suffix: string): Transform { + let found = false + const encodedSuffix = encoder.encode(suffix) + return new Transform({ + transform(chunk, _, callback) { + if (found) { + return callback(null, chunk) + } + + const index = indexOfUint8Array(chunk, encodedSuffix) + if (index > -1) { + found = true + + if (chunk.length === suffix.length) { + return callback() + } + + const before = chunk.slice(0, index) + this.push(before) + + if (chunk.length > suffix.length + index) { + return callback(null, chunk.slice(index + suffix.length)) + } + } else { + return callback(null, chunk) + } + }, + flush(callback) { + return callback(null, encodedSuffix) + }, + }) +} + +// eslint-disable-next-line +function createStripDocumentClosingTagsTransform(): Transform { + return new Transform({ + transform(chunk, _, callback) { + if ( + isEquivalentUint8Arrays(chunk, ENCODED_TAGS.CLOSED.BODY_AND_HTML) || + isEquivalentUint8Arrays(chunk, ENCODED_TAGS.CLOSED.BODY) || + isEquivalentUint8Arrays(chunk, ENCODED_TAGS.CLOSED.HTML) + ) { + return callback() + } + + chunk = removeFromUint8Array(chunk, ENCODED_TAGS.CLOSED.BODY) + chunk = removeFromUint8Array(chunk, ENCODED_TAGS.CLOSED.HTML) + + return callback(null, chunk) + }, + }) +} + +function createRootLayoutValidatorStream(): Transform { + let foundHtml = false + let foundBody = false + return new Transform({ + transform(chunk, _, callback) { + if ( + !foundHtml && + indexOfUint8Array(chunk, ENCODED_TAGS.OPENING.HTML) > -1 + ) { + foundHtml = true + } + + if ( + !foundBody && + indexOfUint8Array(chunk, ENCODED_TAGS.OPENING.BODY) > -1 + ) { + foundBody = true + } + + return callback(null, chunk) + }, + flush(callback) { + const missingTags: typeof window.__next_root_layout_missing_tags = [] + if (!foundHtml) missingTags.push('html') + if (!foundBody) missingTags.push('body') + + if (!missingTags.length) return + + return callback( + null, + encoder.encode( + `` + ) + ) + }, + }) +} + +export function continueFizzStream( + renderStream: Readable, + { + suffix, + inlinedDataStream, + // eslint-disable-next-line + isStaticGeneration, + getServerInsertedHTML, + serverInsertedHTMLToHead, + validateRootLayout, + }: { + inlinedDataStream?: Readable + isStaticGeneration: boolean + getServerInsertedHTML?: () => Promise + serverInsertedHTMLToHead: boolean + validateRootLayout?: boolean + suffix?: string + } +): Promise { + const closeTag = '' + const suffixUnclosed = suffix ? suffix.split(closeTag, 1)[0] : null + + // this doesn't make sense anymore, but keep it in mind if there are issues rendering static stuff. the renderToInitialFizzStream may be calling `pipe` either in `onShellReady` or `onAllReady` + // if (isStaticGeneration && 'allReady' in renderStream) { + // await renderStream.allReady + // } + + const pt = new PassThrough() + + const streams: Stream[] = [renderStream, createBufferedTransformStream()] + + if (getServerInsertedHTML && !serverInsertedHTMLToHead) { + streams.push(createInsertedHTMLStream(getServerInsertedHTML)) + } + + if (suffixUnclosed != null && suffixUnclosed.length > 0) { + streams.push(createDeferredSuffixStream(suffixUnclosed)) + } + + if (inlinedDataStream) { + streams.push(inlinedDataStream.pipe(new PassThrough())) + } + + if (validateRootLayout) { + streams.push(createRootLayoutValidatorStream()) + } + + streams.push(createMoveSuffixStream(closeTag)) + + if (getServerInsertedHTML && serverInsertedHTMLToHead) { + streams.push(createHeadInsertionTransformStream(getServerInsertedHTML)) + } + + streams.push(pt) + + return new Promise((resolve, reject) => { + // @ts-expect-error + pipeline(streams, (error) => { + if (error) return reject(error) + else return resolve(pt) + }) + }) +} + +export function teeReadable( + stream: PipeableStream | Readable +): [Readable, Readable] { + if (!(stream instanceof Readable)) { + stream = stream.pipe(new PassThrough()) + } + return [stream.pipe(new PassThrough()), stream.pipe(new PassThrough())] +} + +export type ContinueStaticPrerenderOptions = { + inlinedDataStream: Readable + getServerInsertedHTML: () => Promise +} + +export async function continueStaticPrerender( + prerenderStream: Readable, + { inlinedDataStream, getServerInsertedHTML }: ContinueStaticPrerenderOptions +) { + const closeTag = '' + + return prerenderStream + .pipe(createBufferedTransformStream()) + .pipe(createHeadInsertionTransformStream(getServerInsertedHTML)) + .pipe(inlinedDataStream.pipe(new PassThrough())) + .pipe(createMoveSuffixStream(closeTag)) +} + +type ContinueResumeOptions = { + inlinedDataStream: Readable + getServerInsertedHTML: () => Promise +} + +export async function continueDynamicHTMLResume( + renderStream: Readable, + { inlinedDataStream, getServerInsertedHTML }: ContinueResumeOptions +) { + const closeTag = '' + + return renderStream + .pipe(createBufferedTransformStream()) + .pipe(createHeadInsertionTransformStream(getServerInsertedHTML)) + .pipe(inlinedDataStream.pipe(new PassThrough())) + .pipe(createMoveSuffixStream(closeTag)) +} + +type ContinueDynamicDataResumeOptions = { + inlinedDataStream: Readable +} + +export async function continueDynamicDataResume( + renderStream: Readable, + { inlinedDataStream }: ContinueDynamicDataResumeOptions +) { + const closeTag = '' + + return ( + renderStream + // Insert the inlined data (Flight data, form state, etc.) stream into the HTML + .pipe(inlinedDataStream.pipe(new PassThrough())) + // Close tags should always be deferred to the end + .pipe(createMoveSuffixStream(closeTag)) + ) +} diff --git a/packages/next/types/$$compiled.internal.d.ts b/packages/next/types/$$compiled.internal.d.ts index 18d4b9f961815..bb4e5b6d19176 100644 --- a/packages/next/types/$$compiled.internal.d.ts +++ b/packages/next/types/$$compiled.internal.d.ts @@ -15,6 +15,7 @@ declare module 'next/dist/compiled/react-server-dom-webpack/client.edge' declare module 'next/dist/compiled/react-server-dom-webpack/client.browser' declare module 'next/dist/compiled/react-server-dom-webpack/server.browser' declare module 'next/dist/compiled/react-server-dom-webpack/server.edge' +declare module 'next/dist/compiled/react-server-dom-webpack/server.node' declare module 'next/dist/compiled/react-server-dom-turbopack/client' declare module 'next/dist/compiled/react-server-dom-turbopack/client.edge' declare module 'next/dist/compiled/react-server-dom-turbopack/client.browser' @@ -24,6 +25,7 @@ declare module 'next/dist/client/app-call-server' declare module 'next/dist/compiled/react-dom/server' declare module 'next/dist/compiled/react-dom/server.edge' declare module 'next/dist/compiled/react-dom/server.browser' +declare module 'next/dist/compiled/react-dom/server.node' declare module 'next/dist/compiled/browserslist' declare module 'react-server-dom-webpack/client' diff --git a/packages/next/types/react-server-dom-webpack.d.ts b/packages/next/types/react-server-dom-webpack.d.ts new file mode 100644 index 0000000000000..445b677cd799d --- /dev/null +++ b/packages/next/types/react-server-dom-webpack.d.ts @@ -0,0 +1,172 @@ +// https://github.com/facebook/react/blob/46339720d75337ae1d1e113fd56ac99e7fd1a0b3/packages/shared/ReactTypes.js#L181 +export type ReactComponentInfo = { + name?: string + env?: string + owner?: null | ReactComponentInfo + stack?: null | string +} + +// https://github.com/facebook/react/blob/46339720d75337ae1d1e113fd56ac99e7fd1a0b3/packages/shared/ReactTypes.js#L188 +export type ReactAsyncInfo = { + started?: number + completed?: number + stack?: string +} + +// https://github.com/facebook/react/blob/46339720d75337ae1d1e113fd56ac99e7fd1a0b3/packages/shared/ReactTypes.js#L194 +export type ReactDebugInfo = Array + +export interface Wakeable { + then(onFulfill: () => unknown, onReject: () => unknown): void | Wakeable +} + +// The subset of a Promise that React APIs rely on. This resolves a value. +// This doesn't require a return value neither from the handler nor the +// then function. +interface ThenableImpl { + then( + onFulfill: (value: T) => unknown, + onReject: (error: unknown) => unknown + ): void | Wakeable +} +interface UntrackedThenable extends ThenableImpl { + status?: void + _debugInfo?: null | ReactDebugInfo +} + +export interface PendingThenable extends ThenableImpl { + status: 'pending' + _debugInfo?: null | ReactDebugInfo +} + +export interface FulfilledThenable extends ThenableImpl { + status: 'fulfilled' + value: T + _debugInfo?: null | ReactDebugInfo +} + +export interface RejectedThenable extends ThenableImpl { + status: 'rejected' + reason: unknown + _debugInfo?: null | ReactDebugInfo +} + +export type Thenable = + | UntrackedThenable + | PendingThenable + | FulfilledThenable + | RejectedThenable + +// https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server/src/ReactFlightServerTemporaryReferences.js#L18 +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type TemporaryReference = {} + +// https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server/src/ReactFlightServerTemporaryReferences.js#L12 +type TemporaryReferenceSet = WeakMap, string> + +// https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server/src/ReactFlightServer.js#L319 +export type ReactClientValue = any + +// https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server-dom-webpack/src/shared/ReactFlightImportMetadata.js#L10 +type ImportManifestEntry = { + id: string + chunks: string[] + name: string +} + +// https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack.js#L39 +type ServerManifest = { + [id: string]: ImportManifestEntry +} + +// https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler.js#L30 +type ClientReferenceManifestEntry = ImportManifestEntry + +// https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler.js#L23 +export type ClientManifest = { + [id: string]: ClientReferenceManifestEntry +} + +// https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server/src/ReactFlightActionServer.js#L25 +type ServerReferenceId = any + +// https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server/src/ReactFlightActionServer.js#L83 +export type DecodeAction = ( + body: FormData, + ServerManifest: ServerModuleMap +) => Promise<() => T> | null + +// https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server/src/ReactFlightActionServer.js#L125 +export type DecodeFormState = ( + actionResult: S, + body: FormData, + ServerManifest?: ServerModuleMap +) => Promise | null> + +// https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js#L101 +export type DecodeReply = ( + body: string | FormData, + webpackMap?: ServerModuleMap, + option?: { + temporaryReferences?: TemporaryReferenceSet + } +) => Thenable + +declare module 'react-server-dom-webpack/server.edge' { + // https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js#L46 + export interface RenderToReadableStreamOptions { + environmentName?: string + identifierPrefix?: string + signal?: AbortSignal + temporaryReferences?: TemporaryReferenceSet + onError?: (error: unknown) => void + onPostpone?: (reason: string) => void + } + + // https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js#L55 + export function renderToReadableStream( + model: ReactClientValue, + webpackMap: ClientManifest, + option?: RenderToReadableStreamOptions + ): ReadableStream + + export type decodeReply = DecodeReply + + export type decodeAction = DecodeAction + + export type decodeFormState = DecodeFormState +} + +import type { PipeableStream } from 'react-dom/server.node' +import type { ServerModuleMap } from '../src/server/app-render/action-handler' + +declare module 'react-server-dom-webpack/server.node' { + // https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js#L69 + export interface RenderToPipeableStreamOptions { + environmentName?: string + onError?: (error: unknown) => void + onPostpone?: (reason: string) => void + identifierPrefix?: string + temporaryReferences?: TemporaryReferenceSet + } + // https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js#L82 + export function renderToPipeableStream( + model: ReactClientValue, + webpackMap: ClientManifest, + options?: RenderToPipeableStreamOptions + ): PipeableStream + + // If this is needed, ensure to add it to the `packages/next/src/server/app-render/react-server-dom-webpack/{react-server-dom-webpack.node.ts, index.js}` exports + export type decodeReplyFromBusboy = never + // // https://github.com/facebook/react/blob/26f24960935cc395dd9892b3ac48249c9dbcc195/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js#L127 + // export function decodeReplyFromBusboy( + // busboyStream: unknown, + // webpackMap: ServerManifest + // ): Thenable + + export type decodeReply = DecodeReply + + export type decodeAction = DecodeAction + + export type decodeFormState = DecodeFormState +} diff --git a/packages/next/webpack.config.js b/packages/next/webpack.config.js index ef03bbf3481fa..c59686c6466b5 100644 --- a/packages/next/webpack.config.js +++ b/packages/next/webpack.config.js @@ -56,6 +56,7 @@ function makeAppAliases(reactChannel = '') { 'react-server-dom-turbopack/server.node$': `next/dist/compiled/react-server-dom-turbopack${reactChannel}/server.node`, 'react-server-dom-webpack/client$': `next/dist/compiled/react-server-dom-webpack${reactChannel}/client`, 'react-server-dom-webpack/client.edge$': `next/dist/compiled/react-server-dom-webpack${reactChannel}/client.edge`, + 'react-server-dom-webpack/client.node$': `next/dist/compiled/react-server-dom-webpack${reactChannel}/client.node`, 'react-server-dom-webpack/server.edge$': `next/dist/compiled/react-server-dom-webpack${reactChannel}/server.edge`, 'react-server-dom-webpack/server.node$': `next/dist/compiled/react-server-dom-webpack${reactChannel}/server.node`, }