2022-10-14 22:55:09 +02:00
|
|
|
import type { FlightRouterState } from './app-render'
|
2022-04-19 19:20:20 +02:00
|
|
|
import { nonNullable } from '../lib/non-nullable'
|
|
|
|
|
2022-05-11 15:25:23 +02:00
|
|
|
export type ReactReadableStream = ReadableStream<Uint8Array> & {
|
|
|
|
allReady?: Promise<void> | undefined
|
|
|
|
}
|
|
|
|
|
2022-08-15 16:29:51 +02:00
|
|
|
export function encodeText(input: string) {
|
|
|
|
return new TextEncoder().encode(input)
|
|
|
|
}
|
|
|
|
|
|
|
|
export function decodeText(input?: Uint8Array, textDecoder?: TextDecoder) {
|
|
|
|
return textDecoder
|
|
|
|
? textDecoder.decode(input, { stream: true })
|
|
|
|
: new TextDecoder().decode(input)
|
|
|
|
}
|
|
|
|
|
2022-03-18 00:21:16 +01:00
|
|
|
export function readableStreamTee<T = any>(
|
|
|
|
readable: ReadableStream<T>
|
|
|
|
): [ReadableStream<T>, ReadableStream<T>] {
|
|
|
|
const transformStream = new TransformStream()
|
|
|
|
const transformStream2 = new TransformStream()
|
|
|
|
const writer = transformStream.writable.getWriter()
|
|
|
|
const writer2 = transformStream2.writable.getWriter()
|
|
|
|
|
|
|
|
const reader = readable.getReader()
|
|
|
|
function read() {
|
|
|
|
reader.read().then(({ done, value }) => {
|
|
|
|
if (done) {
|
|
|
|
writer.close()
|
|
|
|
writer2.close()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
writer.write(value)
|
|
|
|
writer2.write(value)
|
|
|
|
read()
|
|
|
|
})
|
|
|
|
}
|
|
|
|
read()
|
|
|
|
|
|
|
|
return [transformStream.readable, transformStream2.readable]
|
|
|
|
}
|
|
|
|
|
|
|
|
export function chainStreams<T>(
|
|
|
|
streams: ReadableStream<T>[]
|
|
|
|
): ReadableStream<T> {
|
|
|
|
const { readable, writable } = new TransformStream()
|
|
|
|
|
|
|
|
let promise = Promise.resolve()
|
|
|
|
for (let i = 0; i < streams.length; ++i) {
|
|
|
|
promise = promise.then(() =>
|
2022-04-07 16:26:30 +02:00
|
|
|
streams[i].pipeTo(writable, { preventClose: i + 1 < streams.length })
|
2022-03-18 00:21:16 +01:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
return readable
|
|
|
|
}
|
|
|
|
|
|
|
|
export function streamFromArray(strings: string[]): ReadableStream<Uint8Array> {
|
|
|
|
// Note: we use a TransformStream here instead of instantiating a ReadableStream
|
|
|
|
// because the built-in ReadableStream polyfill runs strings through TextEncoder.
|
|
|
|
const { readable, writable } = new TransformStream()
|
|
|
|
|
|
|
|
const writer = writable.getWriter()
|
|
|
|
strings.forEach((str) => writer.write(encodeText(str)))
|
|
|
|
writer.close()
|
|
|
|
|
|
|
|
return readable
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function streamToString(
|
|
|
|
stream: ReadableStream<Uint8Array>
|
|
|
|
): Promise<string> {
|
|
|
|
const reader = stream.getReader()
|
2022-04-06 15:34:24 +02:00
|
|
|
const textDecoder = new TextDecoder()
|
|
|
|
|
2022-03-18 00:21:16 +01:00
|
|
|
let bufferedString = ''
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
const { done, value } = await reader.read()
|
|
|
|
|
|
|
|
if (done) {
|
|
|
|
return bufferedString
|
|
|
|
}
|
|
|
|
|
2022-04-06 15:34:24 +02:00
|
|
|
bufferedString += decodeText(value, textDecoder)
|
2022-03-18 00:21:16 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-12 19:41:37 +02:00
|
|
|
export function createBufferedTransformStream(
|
|
|
|
transform: (v: string) => string | Promise<string> = (v) => v
|
|
|
|
): TransformStream<Uint8Array, Uint8Array> {
|
2022-03-18 00:21:16 +01:00
|
|
|
let bufferedString = ''
|
|
|
|
let pendingFlush: Promise<void> | null = null
|
|
|
|
|
|
|
|
const flushBuffer = (controller: TransformStreamDefaultController) => {
|
|
|
|
if (!pendingFlush) {
|
|
|
|
pendingFlush = new Promise((resolve) => {
|
2022-05-12 19:41:37 +02:00
|
|
|
setTimeout(async () => {
|
|
|
|
const buffered = await transform(bufferedString)
|
|
|
|
controller.enqueue(encodeText(buffered))
|
2022-03-18 00:21:16 +01:00
|
|
|
bufferedString = ''
|
|
|
|
pendingFlush = null
|
|
|
|
resolve()
|
|
|
|
}, 0)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
return pendingFlush
|
|
|
|
}
|
|
|
|
|
2022-03-30 20:24:25 +02:00
|
|
|
const textDecoder = new TextDecoder()
|
|
|
|
|
2022-04-07 16:26:30 +02:00
|
|
|
return new TransformStream({
|
2022-03-18 00:21:16 +01:00
|
|
|
transform(chunk, controller) {
|
2022-03-30 20:24:25 +02:00
|
|
|
bufferedString += decodeText(chunk, textDecoder)
|
2022-03-18 00:21:16 +01:00
|
|
|
flushBuffer(controller)
|
|
|
|
},
|
|
|
|
|
|
|
|
flush() {
|
|
|
|
if (pendingFlush) {
|
|
|
|
return pendingFlush
|
|
|
|
}
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-10-03 15:43:35 +02:00
|
|
|
export function createInsertedHTMLStream(
|
|
|
|
getServerInsertedHTML: () => Promise<string>
|
2022-03-18 00:21:16 +01:00
|
|
|
): TransformStream<Uint8Array, Uint8Array> {
|
2022-04-07 16:26:30 +02:00
|
|
|
return new TransformStream({
|
2022-09-29 10:56:28 +02:00
|
|
|
async transform(chunk, controller) {
|
2022-10-03 15:43:35 +02:00
|
|
|
const insertedHTMLChunk = encodeText(await getServerInsertedHTML())
|
2022-04-06 15:34:24 +02:00
|
|
|
|
2022-10-03 15:43:35 +02:00
|
|
|
controller.enqueue(insertedHTMLChunk)
|
2022-04-06 15:34:24 +02:00
|
|
|
controller.enqueue(chunk)
|
2022-03-18 00:21:16 +01:00
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-08-12 15:01:19 +02:00
|
|
|
export function renderToInitialStream({
|
|
|
|
ReactDOMServer,
|
|
|
|
element,
|
|
|
|
streamOptions,
|
|
|
|
}: {
|
|
|
|
ReactDOMServer: any
|
|
|
|
element: React.ReactElement
|
|
|
|
streamOptions?: any
|
|
|
|
}): Promise<ReactReadableStream> {
|
|
|
|
return ReactDOMServer.renderToReadableStream(element, streamOptions)
|
|
|
|
}
|
|
|
|
|
2022-07-14 12:26:25 +02:00
|
|
|
export function createHeadInjectionTransformStream(
|
2022-09-29 10:56:28 +02:00
|
|
|
inject: () => Promise<string>
|
2022-07-14 12:26:25 +02:00
|
|
|
): TransformStream<Uint8Array, Uint8Array> {
|
2022-07-13 01:43:44 +02:00
|
|
|
let injected = false
|
|
|
|
return new TransformStream({
|
2022-09-29 10:56:28 +02:00
|
|
|
async transform(chunk, controller) {
|
2022-07-13 01:43:44 +02:00
|
|
|
const content = decodeText(chunk)
|
|
|
|
let index
|
|
|
|
if (!injected && (index = content.indexOf('</head')) !== -1) {
|
|
|
|
injected = true
|
|
|
|
const injectedContent =
|
2022-09-29 10:56:28 +02:00
|
|
|
content.slice(0, index) + (await inject()) + content.slice(index)
|
2022-07-13 01:43:44 +02:00
|
|
|
controller.enqueue(encodeText(injectedContent))
|
|
|
|
} else {
|
|
|
|
controller.enqueue(chunk)
|
|
|
|
}
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// Suffix after main body content - scripts before </body>,
|
|
|
|
// but wait for the major chunks to be enqueued.
|
|
|
|
export function createDeferredSuffixStream(
|
|
|
|
suffix: string
|
2022-03-18 00:21:16 +01:00
|
|
|
): TransformStream<Uint8Array, Uint8Array> {
|
2022-07-13 01:43:44 +02:00
|
|
|
let suffixFlushed = false
|
|
|
|
let suffixFlushTask: Promise<void> | null = null
|
|
|
|
|
2022-04-07 16:26:30 +02:00
|
|
|
return new TransformStream({
|
2022-03-18 00:21:16 +01:00
|
|
|
transform(chunk, controller) {
|
|
|
|
controller.enqueue(chunk)
|
2022-07-13 01:43:44 +02:00
|
|
|
if (!suffixFlushed && suffix) {
|
|
|
|
suffixFlushed = true
|
|
|
|
suffixFlushTask = new Promise((res) => {
|
2022-03-18 00:21:16 +01:00
|
|
|
// NOTE: streaming flush
|
2022-07-13 01:43:44 +02:00
|
|
|
// Enqueue suffix part before the major chunks are enqueued so that
|
|
|
|
// suffix won't be flushed too early to interrupt the data stream
|
2022-03-18 00:21:16 +01:00
|
|
|
setTimeout(() => {
|
2022-07-13 01:43:44 +02:00
|
|
|
controller.enqueue(encodeText(suffix))
|
2022-03-18 00:21:16 +01:00
|
|
|
res()
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
},
|
|
|
|
flush(controller) {
|
2022-07-13 01:43:44 +02:00
|
|
|
if (suffixFlushTask) return suffixFlushTask
|
|
|
|
if (!suffixFlushed && suffix) {
|
|
|
|
suffixFlushed = true
|
|
|
|
controller.enqueue(encodeText(suffix))
|
2022-03-18 00:21:16 +01:00
|
|
|
}
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
export function createInlineDataStream(
|
|
|
|
dataStream: ReadableStream<Uint8Array>
|
|
|
|
): TransformStream<Uint8Array, Uint8Array> {
|
|
|
|
let dataStreamFinished: Promise<void> | null = null
|
2022-04-07 16:26:30 +02:00
|
|
|
return new TransformStream({
|
2022-03-18 00:21:16 +01:00
|
|
|
transform(chunk, controller) {
|
|
|
|
controller.enqueue(chunk)
|
|
|
|
|
|
|
|
if (!dataStreamFinished) {
|
|
|
|
const dataStreamReader = dataStream.getReader()
|
|
|
|
|
|
|
|
// NOTE: streaming flush
|
|
|
|
// We are buffering here for the inlined data stream because the
|
|
|
|
// "shell" stream might be chunkenized again by the underlying stream
|
|
|
|
// implementation, e.g. with a specific high-water mark. To ensure it's
|
|
|
|
// the safe timing to pipe the data stream, this extra tick is
|
|
|
|
// necessary.
|
|
|
|
dataStreamFinished = new Promise((res) =>
|
|
|
|
setTimeout(async () => {
|
|
|
|
try {
|
|
|
|
while (true) {
|
|
|
|
const { done, value } = await dataStreamReader.read()
|
|
|
|
if (done) {
|
|
|
|
return res()
|
|
|
|
}
|
|
|
|
controller.enqueue(value)
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
controller.error(err)
|
|
|
|
}
|
|
|
|
res()
|
|
|
|
}, 0)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
},
|
|
|
|
flush() {
|
|
|
|
if (dataStreamFinished) {
|
|
|
|
return dataStreamFinished
|
|
|
|
}
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
2022-08-15 16:29:51 +02:00
|
|
|
|
|
|
|
export function createSuffixStream(
|
|
|
|
suffix: string
|
|
|
|
): TransformStream<Uint8Array, Uint8Array> {
|
|
|
|
return new TransformStream({
|
|
|
|
flush(controller) {
|
|
|
|
if (suffix) {
|
|
|
|
controller.enqueue(encodeText(suffix))
|
|
|
|
}
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-10-14 22:55:09 +02:00
|
|
|
export function createRootLayoutValidatorStream(
|
|
|
|
assetPrefix = '',
|
|
|
|
getTree: () => FlightRouterState
|
|
|
|
): TransformStream<Uint8Array, Uint8Array> {
|
2022-10-03 21:27:16 +02:00
|
|
|
let foundHtml = false
|
|
|
|
let foundBody = false
|
|
|
|
|
|
|
|
return new TransformStream({
|
|
|
|
async transform(chunk, controller) {
|
2022-10-21 11:12:27 +02:00
|
|
|
if (!foundHtml || !foundBody) {
|
2022-10-03 21:27:16 +02:00
|
|
|
const content = decodeText(chunk)
|
|
|
|
if (!foundHtml && content.includes('<html')) {
|
|
|
|
foundHtml = true
|
|
|
|
}
|
|
|
|
if (!foundBody && content.includes('<body')) {
|
|
|
|
foundBody = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
controller.enqueue(chunk)
|
|
|
|
},
|
|
|
|
flush(controller) {
|
|
|
|
const missingTags = [
|
|
|
|
foundHtml ? null : 'html',
|
|
|
|
foundBody ? null : 'body',
|
|
|
|
].filter(nonNullable)
|
|
|
|
|
|
|
|
if (missingTags.length > 0) {
|
2022-10-14 22:55:09 +02:00
|
|
|
controller.enqueue(
|
|
|
|
encodeText(
|
|
|
|
`<script>self.__next_root_layout_missing_tags_error=${JSON.stringify(
|
|
|
|
{ missingTags, assetPrefix: assetPrefix ?? '', tree: getTree() }
|
|
|
|
)}</script>`
|
|
|
|
)
|
2022-10-03 21:27:16 +02:00
|
|
|
)
|
|
|
|
}
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-08-15 16:29:51 +02:00
|
|
|
export async function continueFromInitialStream(
|
|
|
|
renderStream: ReactReadableStream,
|
|
|
|
{
|
|
|
|
suffix,
|
|
|
|
dataStream,
|
|
|
|
generateStaticHTML,
|
2022-10-03 15:43:35 +02:00
|
|
|
getServerInsertedHTML,
|
|
|
|
serverInsertedHTMLToHead,
|
2022-10-14 22:55:09 +02:00
|
|
|
validateRootLayout,
|
2022-08-15 16:29:51 +02:00
|
|
|
}: {
|
|
|
|
suffix?: string
|
|
|
|
dataStream?: ReadableStream<Uint8Array>
|
|
|
|
generateStaticHTML: boolean
|
2022-10-03 15:43:35 +02:00
|
|
|
getServerInsertedHTML?: () => Promise<string>
|
|
|
|
serverInsertedHTMLToHead: boolean
|
2022-10-14 22:55:09 +02:00
|
|
|
validateRootLayout?: {
|
|
|
|
assetPrefix?: string
|
|
|
|
getTree: () => FlightRouterState
|
|
|
|
}
|
2022-08-15 16:29:51 +02:00
|
|
|
}
|
|
|
|
): Promise<ReadableStream<Uint8Array>> {
|
|
|
|
const closeTag = '</body></html>'
|
|
|
|
const suffixUnclosed = suffix ? suffix.split(closeTag)[0] : null
|
|
|
|
|
|
|
|
if (generateStaticHTML) {
|
|
|
|
await renderStream.allReady
|
|
|
|
}
|
|
|
|
|
|
|
|
const transforms: Array<TransformStream<Uint8Array, Uint8Array>> = [
|
|
|
|
createBufferedTransformStream(),
|
2022-10-03 15:43:35 +02:00
|
|
|
getServerInsertedHTML && !serverInsertedHTMLToHead
|
|
|
|
? createInsertedHTMLStream(getServerInsertedHTML)
|
2022-08-15 16:29:51 +02:00
|
|
|
: null,
|
|
|
|
suffixUnclosed != null ? createDeferredSuffixStream(suffixUnclosed) : null,
|
|
|
|
dataStream ? createInlineDataStream(dataStream) : null,
|
|
|
|
suffixUnclosed != null ? createSuffixStream(closeTag) : null,
|
2022-09-29 10:56:28 +02:00
|
|
|
createHeadInjectionTransformStream(async () => {
|
2022-10-03 15:43:35 +02:00
|
|
|
// TODO-APP: Insert server side html to end of head in app layout rendering, to avoid
|
2022-08-15 16:29:51 +02:00
|
|
|
// hydration errors. Remove this once it's ready to be handled by react itself.
|
2022-10-03 15:43:35 +02:00
|
|
|
const serverInsertedHTML =
|
|
|
|
getServerInsertedHTML && serverInsertedHTMLToHead
|
|
|
|
? await getServerInsertedHTML()
|
2022-09-29 10:56:28 +02:00
|
|
|
: ''
|
2022-10-09 17:08:51 +02:00
|
|
|
return serverInsertedHTML
|
2022-08-15 16:29:51 +02:00
|
|
|
}),
|
2022-10-14 22:55:09 +02:00
|
|
|
validateRootLayout
|
|
|
|
? createRootLayoutValidatorStream(
|
|
|
|
validateRootLayout.assetPrefix,
|
|
|
|
validateRootLayout.getTree
|
|
|
|
)
|
|
|
|
: null,
|
2022-08-15 16:29:51 +02:00
|
|
|
].filter(nonNullable)
|
|
|
|
|
|
|
|
return transforms.reduce(
|
|
|
|
(readable, transform) => readable.pipeThrough(transform),
|
|
|
|
renderStream
|
|
|
|
)
|
|
|
|
}
|