diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 2c62c2d34243c60..12af4c6801ec18e 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -610,14 +610,6 @@ export default async function getBaseWebpackConfig( } : {}), - ...(webServerRuntime - ? { - 'react-dom/server': dev - ? 'react-dom/cjs/react-dom-server.browser.development' - : 'react-dom/cjs/react-dom-server.browser.production.min', - } - : {}), - setimmediate: 'next/dist/compiled/setimmediate', }, ...(targetWeb diff --git a/packages/next/server/node-polyfill-readable-stream.js b/packages/next/server/node-polyfill-readable-stream.js new file mode 100644 index 000000000000000..6c6c56fd0f4a74e --- /dev/null +++ b/packages/next/server/node-polyfill-readable-stream.js @@ -0,0 +1,6 @@ +import { ReadableStream } from './web/sandbox/readable-stream' + +// Polyfill ReadableStream in the Node.js environment +if (!global.ReadableStream) { + global.ReadableStream = ReadableStream +} diff --git a/packages/next/server/render-result.ts b/packages/next/server/render-result.ts index 925f6c1680d5b48..002fd05b0af5e9c 100644 --- a/packages/next/server/render-result.ts +++ b/packages/next/server/render-result.ts @@ -1,15 +1,14 @@ import type { ServerResponse } from 'http' -import type { Writable } from 'stream' -export type NodeWritablePiper = ( - res: Writable, +export type ResultPiper = ( + push: (chunks: Uint8Array[]) => void, next: (err?: Error) => void ) => void export default class RenderResult { - _result: string | NodeWritablePiper + _result: string | ResultPiper - constructor(response: string | NodeWritablePiper) { + constructor(response: string | ResultPiper) { this._result = response } @@ -29,8 +28,35 @@ export default class RenderResult { ) } const response = this._result + const flush = + typeof (res as any).flush === 'function' + ? () => (res as any).flush() + : () => {} + return new Promise((resolve, reject) => { - response(res, (err) => (err ? reject(err) : resolve())) + let fatalError = false + response( + (chunks) => { + // The state of the stream is non-deterministic after + // writing, so any error becomes fatal. + fatalError = true + res.cork() + chunks.forEach((chunk) => res.write(chunk)) + res.uncork() + flush() + }, + (err) => { + if (err) { + if (fatalError) { + res.destroy(err) + } + reject(err) + } else { + res.end() + resolve() + } + } + ) }) } diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index ac70a6996431207..e5de330cd9eafde 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -1,8 +1,6 @@ import { IncomingMessage, ServerResponse } from 'http' import { ParsedUrlQuery, stringify as stringifyQuery } from 'querystring' -import type { Writable as WritableType } from 'stream' import React from 'react' -import ReactDOMServer from 'react-dom/server' import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack' import { renderToReadableStream } from 'next/dist/compiled/react-server-dom-webpack/writer.browser.server' import { StyleRegistry, createStyleRegistry } from 'styled-jsx' @@ -60,12 +58,10 @@ import { Redirect, } from '../lib/load-custom-routes' import { DomainLocale } from './config' -import RenderResult, { NodeWritablePiper } from './render-result' +import RenderResult, { ResultPiper } from './render-result' import isError from '../lib/is-error' import { readableStreamTee } from './web/utils' -let Writable: typeof import('stream').Writable -let Buffer: typeof import('buffer').Buffer let optimizeAmp: typeof import('./optimize-amp').default let getFontDefinitionFromManifest: typeof import('./font-utils').getFontDefinitionFromManifest let tryGetPreviewData: typeof import('./api-utils').tryGetPreviewData @@ -75,8 +71,7 @@ let postProcess: typeof import('../shared/lib/post-process').default const DOCTYPE = '' if (!process.browser) { - Writable = require('stream').Writable - Buffer = require('buffer').Buffer + require('./node-polyfill-readable-stream') optimizeAmp = require('./optimize-amp').default getFontDefinitionFromManifest = require('./font-utils').getFontDefinitionFromManifest @@ -1112,8 +1107,8 @@ export async function renderToHTML( serverComponentManifest ) const reader = stream.getReader() - const piper: NodeWritablePiper = (innerRes, next) => { - bufferedReadFromReadableStream(reader, (val) => innerRes.write(val)).then( + const piper: ResultPiper = (push, next) => { + bufferedReadFromReadableStream(reader, push).then( () => next(), (innerErr) => next(innerErr) ) @@ -1152,6 +1147,10 @@ export async function renderToHTML( return inAmpMode ? children :
{children}
} + const ReactDOMServer = concurrentFeatures + ? require('react-dom/server.browser') + : require('react-dom/server') + /** * Rules of Static & Dynamic HTML: * @@ -1253,13 +1252,13 @@ export async function renderToHTML( // up to date when getWrappedApp is called const content = renderContent() - return process.browser - ? await renderToWebStream( - content, - suffix, - serverComponentsInlinedTransformStream - ) - : await renderToNodeStream(content, suffix, generateStaticHTML) + return await renderToWebStream( + ReactDOMServer, + content, + suffix, + serverComponentsInlinedTransformStream, + generateStaticHTML + ) } } else { const content = renderContent() @@ -1384,7 +1383,7 @@ export async function renderToHTML( ) let documentHTML: string - if (process.browser) { + if (concurrentFeatures) { // There is no `renderToStaticMarkup` exposed in the web environment, use // blocking `renderToReadableStream` to get the similar result. let result = '' @@ -1443,7 +1442,7 @@ export async function renderToHTML( prefix.push('') } - let pipers: Array = [ + let pipers: Array = [ piperFromArray(prefix), await documentResult.bodyResult(renderTargetSuffix), ] @@ -1536,142 +1535,19 @@ function serializeError( } } -function renderToNodeStream( - element: React.ReactElement, - suffix: string, - generateStaticHTML: boolean -): Promise { - return new Promise((resolve, reject) => { - let underlyingStream: WritableType | null = null - let queuedCallbacks: Array<(error?: Error | null) => void> = [] - let shellFlushed = false - - const closeTag = '' - const [suffixUnclosed] = suffix.split(closeTag) - - // Based on the suggestion here: - // https://github.com/reactwg/react-18/discussions/110 - let suffixFlushed = false - class NextWritable extends Writable { - _write( - chunk: any, - encoding: string, - callback: (error?: Error | null) => void - ) { - if (!underlyingStream) { - throw new Error( - 'invariant: write called without an underlying stream. This is a bug in Next.js' - ) - } - // The compression module (https://github.com/expressjs/compression) doesn't - // support callbacks, so we have to wait for a drain event. - if (!underlyingStream.write(chunk, encoding)) { - queuedCallbacks.push(callback) - } else { - callback() - } - - if (!shellFlushed) { - shellFlushed = true - // In the first round of streaming, all chunks will be finished in the micro task. - // We use setTimeout to guarantee the suffix is flushed after the micro task. - setTimeout(() => { - // Flush the suffix if stream is not closed. - if (underlyingStream) { - suffixFlushed = true - underlyingStream.write(suffixUnclosed) - } - }) - } - } - - flush() { - if (!underlyingStream) { - throw new Error( - 'invariant: flush called without an underlying stream. This is a bug in Next.js' - ) - } - - const anyWritable = underlyingStream as any - if (typeof anyWritable.flush === 'function') { - anyWritable.flush() - } - } - } - - const stream = new NextWritable() - stream.on('drain', () => { - const callbacks = queuedCallbacks - queuedCallbacks = [] - callbacks.forEach((callback) => callback()) - }) - - let resolved = false - const doResolve = (startWriting: any) => { - if (!resolved) { - resolved = true - resolve((res, next) => { - const doNext = (err?: Error) => { - // Some cases when the stream is closed too fast before setTimeout, - // have to ensure suffix is flushed anyway. - if (!suffixFlushed) { - res.write(suffixUnclosed) - } - if (!err) { - res.write(closeTag) - } - underlyingStream = null - queuedCallbacks = [] - next(err) - } - - stream.once('error', (err) => doNext(err)) - stream.once('finish', () => doNext()) - - underlyingStream = res - startWriting() - }) - } - } - - const { abort, pipe } = (ReactDOMServer as any).renderToPipeableStream( - element, - { - onError(error: Error) { - if (!resolved) { - resolved = true - reject(error) - } - abort() - }, - onCompleteShell() { - shellFlushed = true - if (!generateStaticHTML) { - doResolve(() => pipe(stream)) - } - }, - onCompleteAll() { - doResolve(() => pipe(stream)) - }, - } - ) - }) -} - async function bufferedReadFromReadableStream( reader: ReadableStreamDefaultReader, - writeFn: (val: string) => void + writeFn: (chunks: Uint8Array[]) => void ): Promise { - const decoder = new TextDecoder() - let bufferedString = '' + let bufferedChunks: Uint8Array[] = [] let pendingFlush: Promise | null = null const flushBuffer = () => { if (!pendingFlush) { pendingFlush = new Promise((resolve) => setTimeout(() => { - writeFn(bufferedString) - bufferedString = '' + writeFn(bufferedChunks) + bufferedChunks = [] pendingFlush = null resolve() }, 0) @@ -1685,7 +1561,7 @@ async function bufferedReadFromReadableStream( break } - bufferedString += typeof value === 'string' ? value : decoder.decode(value) + bufferedChunks.push(value) flushBuffer() } @@ -1694,18 +1570,48 @@ async function bufferedReadFromReadableStream( } function renderToWebStream( + ReactDOMServer: typeof import('react-dom/server'), element: React.ReactElement, suffix: string, - serverComponentsInlinedTransformStream: TransformStream | null -): Promise { + serverComponentsInlinedTransformStream: TransformStream | null, + generateStaticHTML: boolean +): Promise { return new Promise((resolve, reject) => { let resolved = false const inlinedDataReader = serverComponentsInlinedTransformStream ? serverComponentsInlinedTransformStream.readable.getReader() : null - const closeTag = '' - const [suffixUnclosed] = suffix.split(closeTag) + const closeTagString = '' + const encoder = new TextEncoder() + const closeTag = encoder.encode(closeTagString) + const suffixUnclosed = encoder.encode(suffix.split(closeTagString)[0]) + + const doResolve = () => { + if (!resolved) { + resolved = true + resolve((push, next) => { + let shellFlushed = false + Promise.all([ + bufferedReadFromReadableStream(reader, (val) => { + push(val) + if (!shellFlushed) { + shellFlushed = true + push([suffixUnclosed]) + } + }), + inlinedDataReader && + bufferedReadFromReadableStream(inlinedDataReader, push), + ]).then( + () => { + push([closeTag]) + next() + }, + (err) => next(err) + ) + }) + } + } const stream: ReadableStream = ( ReactDOMServer as any @@ -1717,76 +1623,56 @@ function renderToWebStream( } }, onCompleteShell() { - if (!resolved) { - resolved = true - resolve((res, next) => { - let shellFlushed = false - Promise.all([ - bufferedReadFromReadableStream(reader, (val) => { - if (!shellFlushed) { - shellFlushed = true - val += suffixUnclosed - } - res.write(val) - }), - inlinedDataReader && - bufferedReadFromReadableStream(inlinedDataReader, res.write), - ]).then( - () => { - res.write(closeTag) - next() - }, - (err) => next(err) - ) - }) + if (!generateStaticHTML) { + doResolve() } }, + onCompleteAll() { + doResolve() + }, }) const reader = stream.getReader() }) } -function chainPipers(pipers: NodeWritablePiper[]): NodeWritablePiper { +function chainPipers(pipers: ResultPiper[]): ResultPiper { return pipers.reduceRight( - (lhs, rhs) => (res, next) => { - rhs(res, (err) => (err ? next(err) : lhs(res, next))) + (lhs, rhs) => (push, next) => { + rhs(push, (err) => (err ? next(err) : lhs(push, next))) }, - (res, next) => { - res.end() + (_, next) => { next() } ) } -function piperFromArray(chunks: string[]): NodeWritablePiper { - return (res, next) => { - if (typeof (res as any).cork === 'function') { - res.cork() - } - chunks.forEach((chunk) => res.write(chunk)) - if (typeof (res as any).uncork === 'function') { - res.uncork() - } +function piperFromArray(strings: string[]): ResultPiper { + const encoder = new TextEncoder() + const chunks = Array.from(strings.map((str) => encoder.encode(str))) + return (push, next) => { + push(chunks) next() } } -function piperToString(input: NodeWritablePiper): Promise { +function piperToString(input: ResultPiper): Promise { return new Promise((resolve, reject) => { - const bufferedChunks: Buffer[] = [] - const stream = new Writable({ - writev(chunks, callback) { - chunks.forEach((chunk) => bufferedChunks.push(chunk.chunk)) - callback() - }, - }) - input(stream, (err) => { - if (err) { - reject(err) - } else { - resolve(Buffer.concat(bufferedChunks).toString()) + const textDecoder = new TextDecoder() + let bufferedString = '' + + input( + (chunks) => + chunks.forEach( + (chunk) => (bufferedString += textDecoder.decode(chunk)) + ), + (err) => { + if (err) { + reject(err) + } else { + resolve(bufferedString) + } } - }) + ) }) } diff --git a/packages/next/server/web-server.ts b/packages/next/server/web-server.ts index 5e61a18a52ca186..3a3f88a8d6507fc 100644 --- a/packages/next/server/web-server.ts +++ b/packages/next/server/web-server.ts @@ -146,11 +146,13 @@ export default class NextWebServer extends BaseServer { ): Promise { // @TODO const writer = res.transformStream.writable.getWriter() - const encoder = new TextEncoder() options.result.pipe({ - write: (str: string) => writer.write(encoder.encode(str)), + write: (chunk: Uint8Array) => writer.write(chunk), end: () => writer.close(), - // Not implemented: cork/uncork/on/removeListener + destroy: (err: Error) => writer.abort(err), + cork: () => {}, + uncork: () => {}, + // Not implemented: on/removeListener } as any) // To prevent Safari's bfcache caching the "shell", we have to add the diff --git a/packages/next/server/web/sandbox/polyfills.ts b/packages/next/server/web/sandbox/polyfills.ts index d842f9876324f85..3b47e18af6cf36a 100644 --- a/packages/next/server/web/sandbox/polyfills.ts +++ b/packages/next/server/web/sandbox/polyfills.ts @@ -1,7 +1,7 @@ import { Crypto as WebCrypto } from 'next/dist/compiled/@peculiar/webcrypto' import { CryptoKey } from 'next/dist/compiled/@peculiar/webcrypto' -import { TransformStream } from 'next/dist/compiled/web-streams-polyfill' import { v4 as uuid } from 'next/dist/compiled/uuid' +import { ReadableStream } from './readable-stream' import crypto from 'crypto' @@ -13,76 +13,9 @@ export function btoa(str: string) { return Buffer.from(str, 'binary').toString('base64') } -export { CryptoKey } +export { CryptoKey, ReadableStream } export class Crypto extends WebCrypto { // @ts-ignore Remove once types are updated and we deprecate node 12 randomUUID = crypto.randomUUID || uuid } - -export class ReadableStream { - constructor(opts: UnderlyingSource = {}) { - let closed = false - let pullPromise: any - - let transformController: TransformStreamDefaultController - const { readable, writable } = new TransformStream( - { - start: (controller: TransformStreamDefaultController) => { - transformController = controller - }, - }, - undefined, - { - highWaterMark: 1, - } - ) - - const writer = writable.getWriter() - const encoder = new TextEncoder() - const controller: ReadableStreamController = { - get desiredSize() { - return transformController.desiredSize - }, - close: () => { - if (!closed) { - closed = true - writer.close() - } - }, - enqueue: (chunk: T) => { - writer.write(typeof chunk === 'string' ? encoder.encode(chunk) : chunk) - pull() - }, - error: (reason: any) => { - transformController.error(reason) - }, - } - - const pull = () => { - if (opts.pull) { - if (!pullPromise) { - pullPromise = Promise.resolve().then(() => { - pullPromise = 0 - opts.pull!(controller) - }) - } - } - } - - if (opts.start) { - opts.start(controller) - } - - if (opts.cancel) { - readable.cancel = (reason: any) => { - opts.cancel!(reason) - return readable.cancel(reason) - } - } - - pull() - - return readable - } -} diff --git a/packages/next/server/web/sandbox/readable-stream.ts b/packages/next/server/web/sandbox/readable-stream.ts new file mode 100644 index 000000000000000..0403c2b3bc3ae88 --- /dev/null +++ b/packages/next/server/web/sandbox/readable-stream.ts @@ -0,0 +1,68 @@ +import { TransformStream } from 'next/dist/compiled/web-streams-polyfill' + +export class ReadableStream { + constructor(opts: UnderlyingSource = {}) { + let closed = false + let pullPromise: any + + let transformController: TransformStreamDefaultController + const { readable, writable } = new TransformStream( + { + start: (controller: TransformStreamDefaultController) => { + transformController = controller + }, + }, + undefined, + { + highWaterMark: 1, + } + ) + + const writer = writable.getWriter() + const encoder = new TextEncoder() + const controller: ReadableStreamController = { + get desiredSize() { + return transformController.desiredSize + }, + close: () => { + if (!closed) { + closed = true + writer.close() + } + }, + enqueue: (chunk: T) => { + writer.write(typeof chunk === 'string' ? encoder.encode(chunk) : chunk) + pull() + }, + error: (reason: any) => { + transformController.error(reason) + }, + } + + const pull = () => { + if (opts.pull) { + if (!pullPromise) { + pullPromise = Promise.resolve().then(() => { + pullPromise = 0 + opts.pull!(controller) + }) + } + } + } + + if (opts.start) { + opts.start(controller) + } + + if (opts.cancel) { + readable.cancel = (reason: any) => { + opts.cancel!(reason) + return readable.cancel(reason) + } + } + + pull() + + return readable + } +} diff --git a/test/integration/react-18/test/require-hook.js b/test/integration/react-18/test/require-hook.js index 2a88f8499ca1386..93d0836eaa57c32 100644 --- a/test/integration/react-18/test/require-hook.js +++ b/test/integration/react-18/test/require-hook.js @@ -4,6 +4,7 @@ const hookPropertyMap = new Map([ ['react', 'react-18'], ['react-dom', 'react-dom-18'], ['react-dom/server', 'react-dom-18/server'], + ['react-dom/server.browser', 'react-dom-18/server.browser'], ]) const resolveFilename = mod._resolveFilename diff --git a/test/integration/react-18/test/with-react-18.js b/test/integration/react-18/test/with-react-18.js index b2a75d6408fee45..2f09e99d8150954 100644 --- a/test/integration/react-18/test/with-react-18.js +++ b/test/integration/react-18/test/with-react-18.js @@ -13,6 +13,7 @@ module.exports = function withReact18(config) { alias['react'] = 'react-18' alias['react-dom'] = 'react-dom-18' alias['react-dom/server'] = 'react-dom-18/server' + alias['react-dom/server.browser'] = 'react-dom-18/server.browser' return webpackConfig }