diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index d794b80acf394d..eb5dc853249a79 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -624,7 +624,7 @@ function ReactServerEntrypoint({ nonce?: string }): T { preinitScripts() - const response = useFlightStream( + const response = useFlightStream( reactServerStream, clientReferenceManifest, nonce 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 00000000000000..5ac2aa942432be --- /dev/null +++ b/packages/next/src/server/app-render/use-flight-response/index.d.ts @@ -0,0 +1,29 @@ +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, + nonce: string | undefined, + formState: unknown | null +): Readable +export function createInlinedDataReadableStream( + flightStream: ReadableStream, + nonce: string | undefined, + formState: unknown | null +): ReadableStream +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 00000000000000..2fd6fc65982003 --- /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 84% 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 5d31ab6b0e52df..5792b247a46a41 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,8 @@ -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' @@ -23,7 +23,7 @@ const encoder = new TextEncoder() * This is only used for renderToHTML, the Flight response does not need additional wrappers. */ export function useFlightStream( - flightStream: Readable | BinaryStreamOf, + flightStream: BinaryStreamOf, clientReferenceManifest: DeepReadonly, nonce?: string ): Promise { @@ -38,20 +38,12 @@ export function useFlightStream( // @TODO: investigate why the aliasing for turbopack doesn't pick this up, requiring this runtime check if (process.env.TURBOPACK) { createFromStream = - flightStream instanceof ReadableStream - ? // eslint-disable-next-line import/no-extraneous-dependencies - require('react-server-dom-turbopack/client.edge') - .createFromReadableStream - : // eslint-disable-next-line import/no-extraneous-dependencies - require('react-server-dom-turbopack/client.node').createFromNodeStream + // eslint-disable-next-line import/no-extraneous-dependencies + require('react-server-dom-turbopack/client.edge').createFromReadableStream } else { createFromStream = - flightStream instanceof ReadableStream - ? // eslint-disable-next-line import/no-extraneous-dependencies - require('react-server-dom-webpack/client.edge') - .createFromReadableStream - : // eslint-disable-next-line import/no-extraneous-dependencies - require('react-server-dom-webpack/client.node').createFromNodeStream + // eslint-disable-next-line import/no-extraneous-dependencies + require('react-server-dom-webpack/client.edge').createFromReadableStream } const newResponse = createFromStream(flightStream, { 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 00000000000000..07590cc3521d2e --- /dev/null +++ b/packages/next/src/server/app-render/use-flight-response/use-flight-response.node.ts @@ -0,0 +1,152 @@ +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, { + ssrManifest: { + 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})` + ) + ) +}