Skip to content

Commit

Permalink
convert use-flight-response and try to tee Readable
Browse files Browse the repository at this point in the history
  • Loading branch information
Ethan-Arrowood committed May 23, 2024
1 parent a32a1f2 commit 2412a0f
Show file tree
Hide file tree
Showing 6 changed files with 266 additions and 83 deletions.
86 changes: 64 additions & 22 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ function ReactServerEntrypoint<T>({
nonce?: string
}): T {
preinitScripts()
const response = useFlightStream(
const response = useFlightStream<T>(
reactServerStream,
clientReferenceManifest,
nonce
Expand Down Expand Up @@ -979,27 +979,38 @@ async function renderToHTMLOrFlightImpl(
}
)

let resultStream: ReadableStream<Uint8Array>
// let resultStream: ReadableStream<Uint8Array>
// if (
// process.env.NEXT_RUNTIME === 'nodejs' &&
// !(serverStream instanceof ReadableStream)
// ) {
// const { PassThrough, Readable } =
// require('node:stream') as typeof import('node:stream')
// resultStream = Readable.toWeb(
// serverStream.pipe(new PassThrough())
// ) as ReadableStream<Uint8Array>
// } else if (!(serverStream instanceof ReadableStream)) {
// throw new Error(
// 'Invariant. Stream is not a ReadableStream in non-Node.js runtime'
// )
// } else {
// resultStream = serverStream
// }

let renderStream, dataStream

if (
process.env.NEXT_RUNTIME === 'nodejs' &&
!(serverStream instanceof ReadableStream)
) {
const { PassThrough, Readable } =
require('node:stream') as typeof import('node:stream')
resultStream = Readable.toWeb(
serverStream.pipe(new PassThrough())
) as ReadableStream<Uint8Array>
} else if (!(serverStream instanceof ReadableStream)) {
throw new Error(
'Invariant. Stream is not a ReadableStream in non-Node.js runtime'
)
const { teeReadable } = require('../stream-utils')
;[renderStream, dataStream] = teeReadable(serverStream)
} else {
resultStream = serverStream
// We are going to consume this render both for SSR and for inlining the flight data
// @ts-ignore
;[renderStream, dataStream] = serverStream.tee()
}

// We are going to consume this render both for SSR and for inlining the flight data
let [renderStream, dataStream] = resultStream.tee()

const children = (
<HeadManagerContext.Provider
value={{
Expand Down Expand Up @@ -1110,7 +1121,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)
Expand Down Expand Up @@ -1198,13 +1222,23 @@ async function renderToHTMLOrFlightImpl(
renderedHTMLStream = convertReadable(resultStream2)
}

let inlinedDataStream = createInlinedDataReadableStream(
dataStream,
nonce,
formState
)

if (
process.env.NEXT_RUNTIME === 'nodejs' &&
!(inlinedDataStream instanceof ReadableStream)
) {
inlinedDataStream = convertReadable(inlinedDataStream)
}

return {
stream: await continueStaticPrerender(renderedHTMLStream, {
inlinedDataStream: createInlinedDataReadableStream(
dataStream,
nonce,
formState
),
// @ts-ignore
inlinedDataStream,
getServerInsertedHTML,
}),
}
Expand All @@ -1213,15 +1247,22 @@ async function renderToHTMLOrFlightImpl(
} else if (renderOpts.postponed) {
stream = convertReadable(stream)
// This is a continuation of either an Incomplete or Dynamic Data Prerender.
const inlinedDataStream = createInlinedDataReadableStream(
let inlinedDataStream = createInlinedDataReadableStream(
dataStream,
nonce,
formState
)
if (
process.env.NEXT_RUNTIME === 'nodejs' &&
!(inlinedDataStream instanceof ReadableStream)
) {
inlinedDataStream = convertReadable(inlinedDataStream)
}
if (resumed) {
// We have new HTML to stream and we also need to include server inserted HTML
return {
stream: await continueDynamicHTMLResume(stream, {
// @ts-ignore
inlinedDataStream,
getServerInsertedHTML,
}),
Expand All @@ -1230,6 +1271,7 @@ async function renderToHTMLOrFlightImpl(
// We are continuing a Dynamic Data Prerender and simply need to append new inlined flight data
return {
stream: await continueDynamicDataResume(stream, {
// @ts-ignore
inlinedDataStream,
}),
}
Expand Down
19 changes: 19 additions & 0 deletions packages/next/src/server/app-render/use-flight-response/index.d.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
flightStream: Readable | ReadableStream<Uint8Array>,
clientReferenceManifest: DeepReadonly<ClientReferenceManifest>,
nonce?: string
): Promise<T>

export function flightRenderComplete(
flightStream: Readable | ReadableStream<Uint8Array>
): Promise<void>

export function createInlinedDataReadableStream(
flightStream: Readable | ReadableStream<Uint8Array>,
nonce: string | undefined,
formState: unknown | null
): Readable | ReadableStream<Uint8Array>
Original file line number Diff line number Diff line change
@@ -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')
}
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<T>(
flightStream: Readable | BinaryStreamOf<T>,
flightStream: BinaryStreamOf<T>,
clientReferenceManifest: DeepReadonly<ClientReferenceManifest>,
nonce?: string
): Promise<T> {
Expand All @@ -38,20 +38,12 @@ export function useFlightStream<T>(
// @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, {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Readable, Promise<any>>()
const encoder = new TextEncoder()

export function useFlightStream<T>(
flightStream: Readable,
clientReferenceManifest: DeepReadonly<ClientReferenceManifest>,
nonce?: string
): Promise<T> {
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<void> {
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
? `<script nonce=${JSON.stringify(nonce)}>`
: '<script>'

const decoder = new TextDecoder('utf-8', { fatal: true })

if (flightStream.readableFlowing) {
flightStream.pause()
}

return new Readable({
construct(callback) {
try {
const chunk = encoder.encode(
`${startScriptTag}(self.__next_f=self.__next_f||[]).push(${htmlEscapeJsonString(
JSON.stringify([INLINE_FLIGHT_PAYLOAD_BOOTSTRAP])
)});self.__next_f.push(${htmlEscapeJsonString(
JSON.stringify([INLINE_FLIGHT_PAYLOAD_FORM_STATE, formState])
)})</script>`
)
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})</script>`
)
)
}
Loading

0 comments on commit 2412a0f

Please sign in to comment.